72 Commits

Author SHA1 Message Date
jiang d80a071987 删除 copilot 自述文件
Build Push and Deploy / docker-image (push) Successful in 13s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-09 18:24:37 +08:00
jiang 216c7b1ab9 docs: add repository guidelines 2026-06-09 18:18:22 +08:00
jiang 7d966a5e91 feat(map): add coordinate zoom action 2026-06-09 17:55:17 +08:00
jiang 22afdbf2e8 fix(chat): 移除旧代码设计
Build Push and Deploy / docker-image (push) Successful in 3m42s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 20:25:48 +08:00
jiang ed9828befe fix(chat): hide actions while streaming
Build Push and Deploy / deploy-fallback-log (push) Has been cancelled
Build Push and Deploy / docker-image (push) Has been cancelled
2026-06-08 20:16:58 +08:00
jiang 968d798a2a fix(chat): hide raw permission metadata
Build Push and Deploy / docker-image (push) Failing after 42s
Build Push and Deploy / deploy-fallback-log (push) Successful in 0s
2026-06-08 20:12:08 +08:00
jiang 7da0ed0e39 fix(chat): mark aborted permissions
Build Push and Deploy / docker-image (push) Successful in 1m1s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 19:54:25 +08:00
jiang 166b45e529 fix(chat): normalize loaded messages
Build Push and Deploy / docker-image (push) Successful in 1m34s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 19:47:13 +08:00
jiang e5f13c3d46 fix(chat): remove regenerate action
Build Push and Deploy / docker-image (push) Successful in 1m7s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 19:33:06 +08:00
jiang 36cdb1df8d refactor(chat): split oversized chat modules 2026-06-08 19:23:46 +08:00
jiang 865e425748 feat(chat): refine shared todo card 2026-06-08 19:14:30 +08:00
jiang 3a36c693cd fix(chat): update question abort state 2026-06-08 18:39:45 +08:00
jiang b23cb6acdd fix(chat): wire question and todo cards 2026-06-08 18:10:28 +08:00
jiang 2691f42581 refactor: simplify chat fork flow
Build Push and Deploy / docker-image (push) Successful in 1m29s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 16:07:39 +08:00
jiang 34fd5bfb1a fix(chat): guard generated title events 2026-06-08 15:13:21 +08:00
jiang 40cc355fff fix(chat): 重新生成前撤销旧消息
Build Push and Deploy / docker-image (push) Successful in 1m47s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 14:38:52 +08:00
jiang f7cd5ebfa7 feat(chat): 添加权限批准模式切换 2026-06-08 14:14:52 +08:00
jiang d31565d52c fix(chat): 优化权限请求折叠状态
Build Push and Deploy / docker-image (push) Successful in 2m28s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 13:44:23 +08:00
jiang e32823e4b5 feat: add permission request UI
Build Push and Deploy / docker-image (push) Successful in 1m2s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 13:32:50 +08:00
jiang 5fc1812d53 fix(chat): 修复 abort 后 progress 仍显示工作中的问题
Build Push and Deploy / docker-image (push) Successful in 7s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-05 13:08:56 +08:00
jiang 709b029c4e fix(chat):建立连接前进行 token 有效性验证 2026-06-05 13:06:20 +08:00
jiang 57369772c7 fix(chat): 更新工具进度名称
Build Push and Deploy / docker-image (push) Successful in 1m17s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-04 18:02:38 +08:00
jiang 7764e25398 增加流式信息中断处理机制 2026-06-04 16:27:15 +08:00
jiang e60e1f6453 refactor: use backend chat sessions 2026-06-04 15:02:27 +08:00
jiang 20ca410e0a 新增 TurnList 组件,优化消息渲染逻辑
Build Push and Deploy / docker-image (push) Successful in 1m19s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-03 17:49:39 +08:00
jiang 06a3f32d2d 重构组件,优化性能并移除不必要的属性;撤销滚动条修改; 2026-06-03 16:58:10 +08:00
jiang fa3e6b6e84 输入框状态剥离,避免受长信息列表渲染影响;覆写滚动条状态动作,不再强制拉到最底 2026-06-03 15:01:24 +08:00
jiang 888132a60f 统一时间时区请求 2026-06-03 11:17:27 +08:00
jiang 9761ade8d8 新增应用样式 agent 工具
Build Push and Deploy / docker-image (push) Successful in 1m30s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-29 10:27:27 +08:00
jiang 0e82c080df 新增自动应用样式功能;强制计算后自动应用默认样式 2026-05-29 10:02:26 +08:00
jiang a4f0ffcd32 调整时间轴样式
Build Push and Deploy / docker-image (push) Successful in 2m0s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-28 17:11:08 +08:00
jiang 9dc8549f31 修复水流、等值线图层显示bug 2026-05-28 17:02:38 +08:00
jiang 6b447eb398 修复会话记录可能存储两次的bug;更改会话行为,默认进入新对话
Build Push and Deploy / docker-image (push) Successful in 1m1s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-22 14:19:14 +08:00
jiang 54fbf15be8 实现会话记录项目隔离
Build Push and Deploy / docker-image (push) Successful in 1m13s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-22 11:20:06 +08:00
jiang 4bf99e8069 Refine chat session storage and title handling
Build Push and Deploy / docker-image (push) Successful in 8s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-21 17:33:48 +08:00
jiang e4d45300b1 Fix missing chat session summary import
Build Push and Deploy / docker-image (push) Successful in 1m29s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-20 16:14:57 +08:00
jiang 477350a2a1 修复bug
Build Push and Deploy / docker-image (push) Failing after 41s
Build Push and Deploy / deploy-fallback-log (push) Successful in 0s
2026-05-20 15:43:35 +08:00
jiang 424555aae2 无对话的新对话不进入历史会话
Build Push and Deploy / docker-image (push) Failing after 41s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
2026-05-20 15:33:43 +08:00
jiang 98635e5247 调整聊天框宽度限制,调整 header 按钮位置
Build Push and Deploy / docker-image (push) Successful in 1m38s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-20 11:43:58 +08:00
jiang adf8ea5ca8 新增指令
Build Push and Deploy / docker-image (push) Successful in 1m52s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-19 18:06:39 +08:00
jiang 91a57123a4 优化代码格式,提升可读性
Build Push and Deploy / docker-image (push) Successful in 1m16s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-19 17:55:05 +08:00
jiang 4f54da64d0 重构会话标题编辑和删除确认逻辑;重构历史会话时间记录 2026-05-19 17:54:09 +08:00
jiang 2fbfba118f 优化历史会话排序逻辑,按首条消息时间排序
Build Push and Deploy / docker-image (push) Failing after 41s
Build Push and Deploy / deploy-fallback-log (push) Successful in 0s
2026-05-19 16:48:56 +08:00
jiang 9106b8d4a9 增加会话标题重命名功能,优化历史面板交互
Build Push and Deploy / docker-image (push) Successful in 2m0s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-19 16:42:28 +08:00
jiang 3800d73e85 修复 Agent 对话框覆盖地图区域的问题
Build Push and Deploy / docker-image (push) Successful in 11s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-18 18:06:12 +08:00
jiang e4424b87d1 优化渲染节点功能,使用 ref 文件渲染大量节点
Build Push and Deploy / docker-image (push) Successful in 7s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-18 16:15:38 +08:00
jiang 39ee9a02e5 拆分、重构 Toolbar 2026-05-18 15:49:38 +08:00
jiang 45274955c6 增加渲染节点功能,优化工具操作和样式 2026-05-18 15:44:36 +08:00
jiang 03ca56d2a7 增加触发 Gitea 管道的脚本,更新 package.json 2026-05-18 15:32:53 +08:00
jiang 570d2c7de1 增加会话标题支持,优化聊天头部展示
Build Push and Deploy / docker-image (push) Successful in 1m1s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-15 17:32:38 +08:00
jiang 8058b7b859 增加模型选择功能,支持不同 Agent 模型
Build Push and Deploy / docker-image (push) Successful in 1m3s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-13 18:12:22 +08:00
jiang a4486e3d89 优化 Agent 过程展示,增加时间格式化和状态管理 2026-05-13 17:43:06 +08:00
jiang 536cd6a5d1 增加获取用户 ID 的功能,Agent chat 请求头新增传递 userId
Build Push and Deploy / docker-image (push) Successful in 1m12s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-11 16:37:55 +08:00
jiang 133f5d417f 调整聊天框为临时模式,优化滚动和过渡效果,避免出现页面横向拉伸 2026-05-08 17:42:21 +08:00
jiang cf43700459 重构聊天会话标题管理,支持首轮对话更新 2026-05-08 17:22:54 +08:00
jiang 5cfb7cc38f 增加对最新标签的支持
Build Push and Deploy / docker-image (push) Successful in 1m4s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 16:49:27 +08:00
jiang d4050a841b ci: add webhook fallback and response diagnostics
Build Push and Deploy / docker-image (push) Successful in 6s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-04-30 16:18:22 +08:00
jiang ba66abb4ee ci: improve deploy webhook diagnostics
Build Push and Deploy / docker-image (push) Failing after 3m13s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
2026-04-30 16:11:56 +08:00
jiang e0e78cd95a 重构聊天会话管理,支持会话历史和存储 2026-04-30 15:02:08 +08:00
jiang c5b0f43a0d 强制要求 onClose 属性,简化面板关闭逻辑 2026-04-30 13:47:45 +08:00
jiang 8f3c288823 优化关闭按钮逻辑,简化代码结构 2026-04-30 13:46:22 +08:00
jiang 24d81e04e0 优化工具栏面板关闭逻辑,增强用户体验 2026-04-30 13:42:04 +08:00
jiang 85b4f45d4a 解析工具调用参数,优化事件处理逻辑 2026-04-30 13:38:53 +08:00
jiang 36d1a8d6ea 重构 Agent 聊天,支持分支管理与消息克隆 2026-04-30 13:05:45 +08:00
jiang e5ca9e24aa Agent 初版设计 2026-04-29 17:15:49 +08:00
jiang 2c1afdc97c 添加进度面板,优化消息处理逻辑 2026-04-29 16:55:14 +08:00
jiang 30d85173ee 修复会话 ID 设置错误,更新类型定义 2026-04-29 15:42:37 +08:00
jiang 3b5a493cda 适配新的 opencode Agent 框架 2026-04-29 15:33:08 +08:00
jiang 49fd4f5eb1 调整比较模式提示框位置 2026-04-27 16:08:00 +08:00
jiang 3db2af0271 更新配置文件,优化路径匹配规则 2026-04-27 16:00:02 +08:00
jiang 07861bee03 添加对比模式功能,优化地图组件 2026-04-27 15:59:49 +08:00
jiang 60181dba54 更新属性面板为可拖动,优化工具栏激活逻辑 2026-04-27 11:56:56 +08:00
73 changed files with 14532 additions and 4592 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ NEXTAUTH_URL="https://demo.waternetwork.cn/"
# 为前端暴露的变量添加 NEXT_PUBLIC_ 前缀
NEXT_PUBLIC_BACKEND_URL="https://server.waternetwork.cn"
NEXT_PUBLIC_COPILOT_URL="https://agent.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"
+43 -7
View File
@@ -4,6 +4,7 @@ on:
push:
tags:
- "v*"
- "latest"
jobs:
docker-image:
@@ -59,11 +60,12 @@ jobs:
REGISTRY_HOST="${REGISTRY_HOST#https://}"
REGISTRY_HOST="${REGISTRY_HOST%/}"
REPOSITORY_PATH="${RAW_REPOSITORY#/}"
REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
IMAGE_NAME="${REGISTRY_HOST}/${REPOSITORY_PATH}"
IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}"
{
echo "REGISTRY_HOST=${REGISTRY_HOST}"
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}"
echo "IMAGE_NAME=${IMAGE_NAME}"
echo "IMAGE_TAG=${IMAGE_TAG}"
echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}"
@@ -102,7 +104,7 @@ jobs:
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
-t "${IMAGE_NAME}:latest" \
--build-arg NEXT_PUBLIC_BACKEND_URL="${{ vars.NEXT_PUBLIC_BACKEND_URL }}" \
--build-arg NEXT_PUBLIC_COPILOT_URL="${{ vars.NEXT_PUBLIC_COPILOT_URL }}" \
--build-arg NEXT_PUBLIC_AGENT_URL="${{ vars.NEXT_PUBLIC_AGENT_URL }}" \
--build-arg NEXT_PUBLIC_AUDIO_SERVICE_URL="${{ vars.NEXT_PUBLIC_AUDIO_SERVICE_URL }}" \
--build-arg NEXT_PUBLIC_MAP_URL="${{ vars.NEXT_PUBLIC_MAP_URL }}" \
--build-arg NEXT_PUBLIC_MAP_WORKSPACE="${{ vars.NEXT_PUBLIC_MAP_WORKSPACE }}" \
@@ -116,10 +118,44 @@ jobs:
- name: Notify Deploy Server
run: |
curl -fsSL -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \
-d "{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}"
post_deploy_webhook() {
label="$1"
payload="$2"
http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \
-d "$payload")
echo "[$label] webhook HTTP status: ${http_code}"
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
return 0
fi
echo "[$label] response headers:"
cat /tmp/deploy_headers.txt
echo "[$label] response body:"
cat /tmp/deploy_response.txt
return 1
}
PRIMARY_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}"
FALLBACK_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${IMAGE_REPOSITORY_PATH}\"}"
echo "Deploy webhook target: ${{ vars.DEPLOY_WEBHOOK_URL }}"
echo "Deploy payload(primary): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${REPOSITORY_PATH}"
if post_deploy_webhook "primary" "$PRIMARY_PAYLOAD"; then
exit 0
fi
echo "Primary webhook request failed, retrying with lowercase repo path..."
echo "Deploy payload(fallback): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${IMAGE_REPOSITORY_PATH}"
if post_deploy_webhook "fallback" "$FALLBACK_PAYLOAD"; then
exit 0
fi
echo "Deploy webhook failed after primary and fallback attempts."
exit 1
deploy-fallback-log:
runs-on: ubuntu-22.04
-60
View File
@@ -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.
+41
View File
@@ -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 components, `camelCase` for variables/functions, and descriptive feature-oriented filenames. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused.
## 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.
+1 -1
View File
@@ -18,7 +18,7 @@ FROM base AS builder
# 只定义 ARG 接收来自构建命令或 docker-compose.yaml 的参数
# Next.js 在 build 时会自动读取同名的 ARG 作为环境变量
ARG NEXT_PUBLIC_BACKEND_URL
ARG NEXT_PUBLIC_COPILOT_URL
ARG NEXT_PUBLIC_AGENT_URL
ARG NEXT_PUBLIC_AUDIO_SERVICE_URL
ARG NEXT_PUBLIC_MAP_URL
ARG NEXT_PUBLIC_MAP_WORKSPACE
+1 -1
View File
@@ -8,7 +8,7 @@ services:
dockerfile: Dockerfile
args:
NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL}
NEXT_PUBLIC_COPILOT_URL: ${NEXT_PUBLIC_COPILOT_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}
+1
View File
@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
distDir: process.env.NEXT_DIST_DIR || ".next",
output: "standalone",
images: {
remotePatterns: [
+2 -1
View File
@@ -13,7 +13,8 @@
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"refine": "refine"
"refine": "refine",
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
},
"dependencies": {
"@emotion/react": "^11.8.2",
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1777523623582" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11701" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M384.1536 952.1664a38.4 38.4 0 0 1-49.3568 22.528 498.3808 498.3808 0 0 1-284.928-273.92 38.4 38.4 0 0 1 70.8608-29.6448 421.5808 421.5808 0 0 0 240.896 231.6288 38.4 38.4 0 0 1 22.528 49.408zM952.1152 384.9728a38.4 38.4 0 0 1-49.4592-22.528 421.5296 421.5296 0 0 0-234.1376-241.5104 38.4 38.4 0 0 1 29.184-71.0656 498.3296 498.3296 0 0 1 276.8896 285.696 38.4 38.4 0 0 1-22.528 49.408z" fill="#CE75FF" p-id="11702"></path><path d="M511.9488 276.736l-27.8528 114.7392A126.0544 126.0544 0 0 1 391.3216 484.352l-114.7904 27.8528 114.7904 27.8016a126.0544 126.0544 0 0 1 92.7744 92.8256L512 747.52l27.8016-114.7392a126.0544 126.0544 0 0 1 92.8256-92.8256l114.7392-27.8016-114.7392-27.8528a126.0544 126.0544 0 0 1-92.8256-92.8256L512 276.736z m55.6544-62.1568c-14.1312-58.368-97.1776-58.368-111.36 0L417.28 375.296a57.344 57.344 0 0 1-42.1888 42.1888l-160.6656 38.912c-58.4192 14.1824-58.4192 97.28 0 111.4112l160.6656 38.9632c20.8384 5.12 37.12 21.3504 42.1888 42.1888l38.9632 160.7168c14.1824 58.368 97.2288 58.368 111.36 0l38.9632-160.7168a57.344 57.344 0 0 1 42.1888-42.1888l160.7168-38.912c58.368-14.1824 58.368-97.28 0-111.4112l-160.7168-38.9632a57.344 57.344 0 0 1-42.1888-42.1888l-38.912-160.7168z" fill="#F3E2FF" p-id="11703"></path><path d="M981.248 768.0512a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.2992 0zM127.9488 256.0512a42.6496 42.6496 0 1 1-85.3504 0 42.6496 42.6496 0 0 1 85.3504 0z" fill="#F62E76" p-id="11704"></path><path d="M810.496 938.8544a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0zM298.496 85.504a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0z" fill="#CD88FF" p-id="11705"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1777457471585" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5556" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M550.4 486.4c0-8.533333 4.266667-12.8 12.8-12.8h4.266667c4.266667 0 4.266667 4.266667 4.266666 4.266667s4.266667 4.266667 4.266667 8.533333v4.266667s0 4.266667-4.266667 4.266666c0 0-4.266667 0-4.266666 4.266667h-4.266667-4.266667s-4.266667 0-4.266666-4.266667c0 0 0-4.266667-4.266667-4.266666v-4.266667z" fill="#4D6BFE" p-id="5557"></path><path d="M994.133333 196.266667c-8.533333-4.266667-12.8 4.266667-21.333333 8.533333l-4.266667 4.266667c-12.8 17.066667-34.133333 25.6-55.466666 25.6-34.133333 0-59.733333 8.533333-85.333334 34.133333-4.266667-29.866667-21.333333-51.2-51.2-64-12.8-4.266667-29.866667-12.8-38.4-25.6-8.533333-8.533333-8.533333-21.333333-12.8-29.866667 0-4.266667 0-12.8-8.533333-12.8s-12.8 4.266667-12.8 12.8c-12.8 21.333333-21.333333 46.933333-17.066667 72.533334 0 59.733333 25.6 106.666667 72.533334 136.533333 4.266667 4.266667 8.533333 8.533333 4.266666 12.8-4.266667 12.8-8.533333 21.333333-8.533333 34.133333-4.266667 8.533333-4.266667 8.533333-12.8 4.266667-25.6-12.8-51.2-29.866667-68.266667-46.933333-34.133333-34.133333-64-72.533333-102.4-102.4-8.533333-8.533333-17.066667-12.8-25.6-21.333334-46.933333-34.133333 0-64 8.533334-68.266666 12.8-4.266667 4.266667-17.066667-29.866667-17.066667-34.133333 0-68.266667 12.8-106.666667 29.866667-8.533333 0-12.8 0-21.333333 4.266666-38.4-8.533333-76.8-8.533333-115.2-4.266666-76.8 8.533333-136.533333 42.666667-179.2 106.666666-51.2 76.8-64 157.866667-51.2 247.466667 17.066667 93.866667 64 170.666667 132.266667 230.4 72.533333 64 157.866667 93.866667 256 85.333333 59.733333-4.266667 123.733333-12.8 200.533333-76.8 17.066667 8.533333 38.4 12.8 72.533333 17.066667 25.6 4.266667 51.2 0 68.266667-4.266667 29.866667-4.266667 25.6-34.133333 17.066667-38.4-85.333333-42.666667-68.266667-25.6-85.333334-38.4 42.666667-51.2 110.933333-106.666667 136.533334-285.866666v-34.133334c0-8.533333 4.266667-8.533333 12.8-8.533333 21.333333-4.266667 42.666667-8.533333 59.733333-21.333333 55.466667-29.866667 76.8-81.066667 85.333333-145.066667 0-8.533333 0-17.066667-12.8-21.333333zM507.733333 746.666667c-85.333333-68.266667-123.733333-89.6-140.8-89.6-17.066667 0-12.8 21.333333-8.533333 29.866666 4.266667 12.8 8.533333 21.333333 12.8 29.866667 4.266667 8.533333 8.533333 17.066667-4.266667 25.6-25.6 17.066667-72.533333-4.266667-76.8-8.533333-55.466667-34.133333-98.133333-76.8-132.266666-136.533334-29.866667-51.2-46.933333-110.933333-46.933334-174.933333 0-17.066667 4.266667-21.333333 17.066667-25.6 21.333333-4.266667 42.666667-4.266667 59.733333 0 85.333333 12.8 157.866667 51.2 217.6 115.2 34.133333 34.133333 59.733333 76.8 89.6 119.466667 29.866667 42.666667 59.733333 85.333333 98.133334 119.466666 12.8 12.8 25.6 21.333333 34.133333 25.6-29.866667 0-81.066667 0-119.466667-29.866666z m166.4-196.266667c-8.533333 4.266667-17.066667 4.266667-25.6 4.266667-12.8 0-25.6-4.266667-29.866666-8.533334-12.8-8.533333-17.066667-12.8-21.333334-29.866666v-25.6c4.266667-12.8 0-21.333333-8.533333-29.866667-8.533333-4.266667-17.066667-8.533333-25.6-8.533333-4.266667 0-8.533333 0-8.533333-4.266667 0 0-4.266667 0-4.266667-4.266667v-4.266666-4.266667-4.266667c0-4.266667 8.533333-8.533333 8.533333-8.533333 12.8-8.533333 29.866667-4.266667 46.933334 0 12.8 4.266667 25.6 17.066667 38.4 29.866667 17.066667 17.066667 17.066667 25.6 25.6 38.4 8.533333 12.8 12.8 21.333333 17.066666 34.133333 0 12.8-4.266667 21.333333-12.8 25.6z" fill="#4D6BFE" p-id="5558"></path></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
echo "Usage: bash scripts/trigger-gitea-pipeline.sh [remote] [tag]"
echo ""
echo "Examples:"
echo " bash scripts/trigger-gitea-pipeline.sh"
echo " bash scripts/trigger-gitea-pipeline.sh gitea latest"
echo " bash scripts/trigger-gitea-pipeline.sh gitea v2026.05.15.1"
exit 0
fi
REMOTE="${1:-gitea}"
TAG="${2:-latest}"
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "[ERROR] Current directory is not a git repository."
exit 1
fi
if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then
echo "[ERROR] Remote '$REMOTE' does not exist."
echo "Available remotes:"
git remote -v
exit 1
fi
HEAD_SHA="$(git rev-parse --short HEAD)"
MESSAGE="manual trigger: ${TAG} $(date '+%F %T')"
echo "[INFO] HEAD: ${HEAD_SHA}"
echo "[INFO] Recreate annotated tag '${TAG}'"
git tag -fa "$TAG" -m "$MESSAGE"
echo "[INFO] Push '${TAG}' to remote '${REMOTE}' (force update)"
git push "$REMOTE" "refs/tags/${TAG}" --force
echo "[INFO] Verify remote tag reference"
git ls-remote --tags "$REMOTE" "refs/tags/${TAG}"
echo "[DONE] Pipeline trigger request sent by updating tag '${TAG}'."
@@ -8,7 +8,11 @@ export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar queryType="scheme" schemeType="burst_analysis" />
<MapToolbar
queryType="scheme"
schemeType="burst_analysis"
enableCompare
/>
<BurstPipeAnalysisPanel />
</MapComponent>
</div>
@@ -8,7 +8,11 @@ export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar queryType="scheme" schemeType="contaminant_analysis" />
<MapToolbar
queryType="scheme"
schemeType="contaminant_analysis"
enableCompare
/>
<WaterQualityPanel />
</MapComponent>
</div>
+1 -1
View File
@@ -169,7 +169,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
{
name: "Hydraulic Simulation",
meta: {
icon: <MdWater className="w-6 h-6" />,
// icon: <MdWater className="w-6 h-6" />,
label: "事件模拟",
},
},
+1
View File
@@ -6,4 +6,5 @@ body {
height: 100%;
margin: 0;
padding: 0;
overflow-x: hidden;
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
import React from "react";
import {
Box,
Chip,
Paper,
Stack,
Typography,
alpha,
useTheme,
} from "@mui/material";
import type { Theme } from "@mui/material/styles";
import BarChartRounded from "@mui/icons-material/BarChartRounded";
import LocationOnRounded from "@mui/icons-material/LocationOnRounded";
import SensorsRounded from "@mui/icons-material/SensorsRounded";
import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded";
import { ChatInlineChart } from "./ChatInlineChart";
import type { ChatChartSeries } from "./ChatInlineChart";
import type { AgentArtifact } from "./GlobalChatbox.types";
const artifactIcon = (kind: AgentArtifact["kind"]) => {
if (kind === "chart") return <BarChartRounded sx={{ fontSize: 18 }} />;
if (kind === "map") return <LocationOnRounded sx={{ fontSize: 18 }} />;
if (kind === "panel") return <SensorsRounded sx={{ fontSize: 18 }} />;
return <BuildCircleRounded sx={{ fontSize: 18 }} />;
};
const artifactColor = (kind: AgentArtifact["kind"], theme: Theme) => {
if (kind === "chart") return theme.palette.info.main;
if (kind === "map") return theme.palette.success.main;
if (kind === "panel") return theme.palette.warning.main;
return theme.palette.primary.main;
};
export const AgentArtifactPanel = ({ artifacts }: { artifacts: AgentArtifact[] }) => {
const theme = useTheme();
if (!artifacts.length) return null;
return (
<Stack spacing={1.25}>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="caption" fontWeight={800} color="text.primary">
</Typography>
<Chip
size="small"
label={`${artifacts.length}`}
sx={{ height: 20, fontSize: "0.68rem" }}
/>
</Stack>
{artifacts.map((artifact) => {
const color = artifactColor(artifact.kind, theme);
if (artifact.kind === "chart") {
return (
<ChatInlineChart
key={artifact.id}
title={(artifact.params.title as string) ?? artifact.title}
chart_type={
(artifact.params.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={(artifact.params.x_data as string[]) ?? []}
series={(artifact.params.series as ChatChartSeries[]) ?? []}
x_axis_name={(artifact.params.x_axis_name as string) ?? undefined}
y_axis_name={(artifact.params.y_axis_name as string) ?? undefined}
/>
);
}
return (
<Paper
key={artifact.id}
elevation={0}
sx={{
p: 1.35,
borderRadius: 3,
border: `1px solid ${alpha(color, 0.22)}`,
bgcolor: alpha(color, 0.055),
}}
>
<Stack direction="row" spacing={1.25} alignItems="center">
<Box
sx={{
width: 32,
height: 32,
borderRadius: 2,
bgcolor: alpha(color, 0.12),
color,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{artifactIcon(artifact.kind)}
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="caption" fontWeight={800} color="text.primary">
{artifact.title}
</Typography>
{artifact.description ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{artifact.description}
</Typography>
) : null}
</Box>
<Chip
size="small"
label="已执行"
sx={{
height: 22,
fontSize: "0.68rem",
bgcolor: alpha(color, 0.12),
color,
}}
/>
</Stack>
</Paper>
);
})}
</Stack>
);
};
+521
View File
@@ -0,0 +1,521 @@
"use client";
import Image from "next/image";
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
Box,
Chip,
Collapse,
FormControl,
IconButton,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
alpha,
useTheme,
} from "@mui/material";
import SendRounded from "@mui/icons-material/SendRounded";
import StopRounded from "@mui/icons-material/StopRounded";
import MicRounded from "@mui/icons-material/MicRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
import BoltRounded from "@mui/icons-material/BoltRounded";
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded";
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
export type AgentComposerHandle = {
focus: () => void;
clear: () => void;
append: (text: string) => void;
setValue: (value: string) => void;
getValue: () => string;
};
type AgentComposerProps = {
isHydrating?: boolean;
isStreaming: boolean;
isListening: boolean;
isSttSupported: boolean;
presets: string[];
onSend: (prompt: string) => void;
onAbort: () => void;
onStartListening: () => void;
onStopListening: () => void;
selectedModel: AgentModel;
onModelChange: (model: AgentModel) => void;
approvalMode: AgentApprovalMode;
onApprovalModeChange: (mode: AgentApprovalMode) => void;
};
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
isHydrating = false,
isStreaming,
isListening,
isSttSupported,
presets,
onSend,
onAbort,
onStartListening,
onStopListening,
selectedModel,
onModelChange,
approvalMode,
onApprovalModeChange,
}, ref) {
const theme = useTheme();
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
const [input, setInput] = React.useState("");
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
React.useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
clear: () => setInput(""),
append: (text: string) => setInput((prev) => prev + text),
setValue: (value: string) => setInput(value),
getValue: () => input,
}),
[input],
);
const handleSend = React.useCallback(() => {
const prompt = input.trim();
if (!prompt || isStreaming || isHydrating) return;
setInput("");
onSend(prompt);
}, [input, isHydrating, isStreaming, onSend]);
return (
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
<Paper
elevation={isPresetOpen ? 4 : 0}
sx={{
mb: 1.5,
px: 1.5,
py: 1,
borderRadius: 4,
bgcolor: alpha("#fff", 0.6),
border: `1px solid ${alpha("#fff", 0.5)}`,
backdropFilter: "blur(24px)",
boxShadow: isPresetOpen ? `0 -8px 24px ${alpha("#00acc1", 0.1)}` : "none",
transition: "all 0.3s ease",
}}
>
<Stack direction="row" spacing={1} alignItems="center">
<Image
src="/ai-agent.svg"
alt="TJWater Agent"
width={18}
height={18}
style={{
objectFit: "contain",
flexShrink: 0,
}}
/>
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
</Typography>
<Box sx={{ flex: 1 }} />
<IconButton
size="small"
onClick={() => setIsPresetOpen((value) => !value)}
aria-label={isPresetOpen ? "收起常用管网任务" : "展开常用管网任务"}
sx={{ width: 28, height: 28, color: "text.secondary", bgcolor: alpha("#fff", 0.5) }}
>
{isPresetOpen ? (
<KeyboardArrowDownRounded fontSize="small" />
) : (
<KeyboardArrowUpRounded fontSize="small" />
)}
</IconButton>
</Stack>
<Collapse in={isPresetOpen} timeout="auto" unmountOnExit>
<Box sx={{ mt: 1.5, mb: 0.5, pb: 1 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{presets.map((prompt) => (
<Chip
key={prompt}
label={prompt.replace(/[。.]$/, "")}
size="medium"
clickable
onClick={() => {
setInput(prompt);
setIsPresetOpen(false);
window.setTimeout(() => {
inputRef.current?.focus();
}, 0);
}}
sx={{
height: 32,
borderRadius: "16px",
bgcolor: alpha("#fff", 0.7),
border: `1px solid ${alpha("#00acc1", 0.15)}`,
color: "text.primary",
fontWeight: 600,
fontSize: '0.85rem',
boxShadow: `0 2px 6px ${alpha("#000", 0.03)}`,
backdropFilter: "blur(10px)",
"&:hover": {
bgcolor: alpha("#fff", 0.95),
boxShadow: `0 4px 10px ${alpha("#00acc1", 0.2)}`,
borderColor: alpha("#00acc1", 0.4),
color: "#00acc1"
}
}}
/>
))}
</Box>
</Box>
</Collapse>
</Paper>
<motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }}>
<Paper
elevation={12}
sx={{
display: "flex",
flexDirection: "column",
p: 1.5,
borderRadius: 5,
bgcolor: alpha("#ffffff", 0.75),
backdropFilter: "blur(40px)",
border: `1px solid ${alpha("#ffffff", 0.9)}`,
boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
}}
>
<TextField
inputRef={inputRef}
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
}
}}
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
fullWidth
multiline
maxRows={5}
variant="standard"
disabled={isHydrating}
InputProps={{
disableUnderline: true,
sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" },
}}
/>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
<Stack direction="row" spacing={0.5} alignItems="center">
<IconButton size="small" aria-label="上传附件" sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}>
<AttachFileRounded fontSize="small" />
</IconButton>
{isSttSupported ? (
isListening ? (
<motion.div
animate={{ scale: [1, 1.14, 1] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
>
<IconButton
onClick={onStopListening}
aria-label="停止语音输入"
size="small"
sx={{
color: "error.main",
bgcolor: alpha(theme.palette.error.main, 0.15),
width: 36,
height: 36,
}}
>
<MicRounded fontSize="small" />
</IconButton>
</motion.div>
) : (
<IconButton
onClick={onStartListening}
disabled={isStreaming || isHydrating}
aria-label="语音输入"
size="small"
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
>
<MicRounded fontSize="small" />
</IconButton>
)
) : null}
<FormControl size="small" sx={{ minWidth: 96 }}>
<Select
value={approvalMode}
onChange={(event) =>
onApprovalModeChange(event.target.value as AgentApprovalMode)
}
disabled={isHydrating || isStreaming}
aria-label="权限批准模式"
renderValue={(val) => (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.45 }}>
{val === "always" ? (
<AdminPanelSettingsRounded sx={{ fontSize: 18, color: "inherit" }} />
) : (
<VerifiedUserRounded sx={{ fontSize: 18, color: "inherit" }} />
)}
<Typography sx={{ fontSize: "0.75rem", fontWeight: 600, color: "inherit" }}>
{val === "always" ? "始终允许" : "请求批准"}
</Typography>
</Box>
)}
MenuProps={{
anchorOrigin: { vertical: "top", horizontal: "left" },
transformOrigin: { vertical: "bottom", horizontal: "left" },
sx: { zIndex: (theme) => theme.zIndex.modal + 110 },
PaperProps: {
sx: {
mb: 1.5,
width: 210,
borderRadius: 4,
bgcolor: alpha("#fff", 0.9),
backdropFilter: "blur(24px)",
border: `1px solid ${alpha("#fff", 0.9)}`,
boxShadow: `0 -12px 40px ${alpha("#000", 0.08)}`,
"& .MuiList-root": { p: 1 },
"& .MuiMenuItem-root": {
px: 1.5,
py: 1.2,
mb: 0.5,
borderRadius: 3,
alignItems: "flex-start",
"&:last-child": { mb: 0 },
"&.Mui-selected": {
bgcolor: alpha("#00acc1", 0.08),
"&:hover": { bgcolor: alpha("#00acc1", 0.12) },
"& .title": { color: "#00838f" },
"& .icon": { color: "#00acc1" },
},
},
},
},
}}
sx={{
height: 36,
borderRadius: "18px",
bgcolor: alpha("#fff", 0.6),
color: "text.secondary",
".MuiOutlinedInput-notchedOutline": { border: "none" },
".MuiSelect-select": {
py: 0,
pl: 1,
pr: "28px !important",
minHeight: 36,
display: "flex",
alignItems: "center",
},
"&:hover, &:has(.MuiSelect-select[aria-expanded=\"true\"])": {
bgcolor: alpha("#000", 0.06),
color: "text.primary",
},
".MuiSelect-icon": {
color: "text.secondary",
right: 4,
},
}}
>
<MenuItem value="request">
<VerifiedUserRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} />
<Box>
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}></Typography>
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}></Typography>
</Box>
</MenuItem>
<MenuItem value="always">
<AdminPanelSettingsRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} />
<Box>
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}></Typography>
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}></Typography>
</Box>
</MenuItem>
</Select>
</FormControl>
</Stack>
<Stack direction="row" spacing={1} alignItems="center">
<FormControl size="small" sx={{ minWidth: 80 }}>
<Select
value={selectedModel}
onChange={(event) => onModelChange(event.target.value as AgentModel)}
disabled={isHydrating || isStreaming}
aria-label="模型选择"
renderValue={(val) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{val === "deepseek/deepseek-v4-flash" ? (
<BoltRounded sx={{ fontSize: 18, color: "inherit", transition: "color 0.2s" }} />
) : (
<AutoAwesomeRounded sx={{ fontSize: 16, color: "inherit", transition: "color 0.2s" }} />
)}
<Typography sx={{ fontSize: "0.8rem", fontWeight: 600, color: "inherit", transition: "color 0.2s" }}>
{val === "deepseek/deepseek-v4-flash" ? "快速" : "专家"}
</Typography>
</Box>
)}
MenuProps={{
anchorOrigin: { vertical: "top", horizontal: "center" },
transformOrigin: { vertical: "bottom", horizontal: "center" },
sx: { zIndex: (theme) => theme.zIndex.modal + 110 },
PaperProps: {
sx: {
mb: 1.5,
width: 230,
borderRadius: 4,
bgcolor: alpha("#fff", 0.85),
backdropFilter: "blur(24px)",
border: `1px solid ${alpha("#fff", 0.9)}`,
boxShadow: `0 -12px 40px ${alpha("#000", 0.08)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
"& .MuiList-root": {
p: 1,
},
"& .MuiMenuItem-root": {
px: 1.5,
py: 1.2,
mb: 0.5,
"&:last-child": { mb: 0 },
borderRadius: 3,
alignItems: "flex-start",
transition: "all 0.2s ease",
"&:hover": {
bgcolor: alpha("#000", 0.03),
},
"&.Mui-selected": {
bgcolor: alpha("#00acc1", 0.08),
"&:hover": {
bgcolor: alpha("#00acc1", 0.12),
},
"& .title": { color: "#00838f" },
"& .icon": { color: "#00acc1" },
}
}
}
}
}}
sx={{
height: 36,
borderRadius: "18px",
bgcolor: "transparent",
color: "text.secondary",
transition: "all 0.2s ease",
".MuiOutlinedInput-notchedOutline": {
border: "none",
},
".MuiSelect-select": {
py: 0,
pl: 1,
pr: "28px !important",
display: "flex",
alignItems: "center",
},
"&:hover, &:has(.MuiSelect-select[aria-expanded=\"true\"])": {
bgcolor: alpha("#000", 0.06),
color: "text.primary",
".MuiSelect-icon": {
color: "text.primary",
}
},
".MuiSelect-icon": {
color: "text.secondary",
right: 4,
transition: "color 0.2s ease",
}
}}
>
<Box sx={{ px: 2, py: 1.5, pb: 1, display: "flex", alignItems: "center", gap: 1, pointerEvents: "none" }}>
<Box
component="img"
src="/deepseek-logo.svg"
alt="DeepSeek"
sx={{ width: 16, height: 16, display: "block", flexShrink: 0 }}
/>
<Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}>
DEEPSEEK V4
</Typography>
</Box>
<MenuItem value="deepseek/deepseek-v4-flash">
<BoltRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 20, color: "text.secondary", transition: "color 0.2s" }} />
<Box>
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}></Typography>
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}></Typography>
</Box>
</MenuItem>
<MenuItem value="deepseek/deepseek-v4-pro">
<AutoAwesomeRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 18, color: "text.secondary", transition: "color 0.2s" }} />
<Box>
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}></Typography>
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}></Typography>
</Box>
</MenuItem>
</Select>
</FormControl>
<AnimatePresence mode="wait">
{isStreaming ? (
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
<IconButton
onClick={onAbort}
aria-label="停止生成"
size="small"
sx={{
bgcolor: "error.main",
color: "#fff",
width: 40,
height: 40,
boxShadow: `0 4px 12px ${alpha(theme.palette.error.main, 0.4)}`,
"&:hover": { bgcolor: "error.dark" },
}}
>
<StopRounded />
</IconButton>
</motion.div>
) : (
<motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
<IconButton
disabled={!canSend}
onClick={handleSend}
aria-label="发送"
size="small"
sx={{
bgcolor: canSend ? "#00acc1" : alpha("#fff", 0.5),
color: canSend ? "#fff" : "action.disabled",
width: 40,
height: 40,
boxShadow: canSend ? `0 6px 16px ${alpha("#00acc1", 0.4)}` : "none",
"&:hover": { bgcolor: canSend ? "#00838f" : alpha("#fff", 0.5) },
}}
>
<SendRounded sx={{ ml: 0.35 }} />
</IconButton>
</motion.div>
)}
</AnimatePresence>
</Stack>
</Stack>
</Paper>
</motion.div>
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5, opacity: 0.6 }}>
<Image
src="/deepseek-logo.svg"
alt="DeepSeek"
width={14}
height={14}
style={{ width: 14, height: 14 }}
/>
<Typography variant="caption" sx={{ fontSize: "0.65rem", color: "text.secondary", fontWeight: 500, letterSpacing: 0.5 }}>
Powered by DeepSeek V4 · TJWater Agent Intelligence
</Typography>
</Box>
</Box>
);
});
+341
View File
@@ -0,0 +1,341 @@
"use client";
import Image from "next/image";
import React from "react";
import { motion } from "framer-motion";
import {
Avatar,
Box,
IconButton,
Stack,
TextField,
Tooltip,
Typography,
alpha,
useTheme,
} from "@mui/material";
import CheckRounded from "@mui/icons-material/CheckRounded";
import CloseRounded from "@mui/icons-material/CloseRounded";
import EditRounded from "@mui/icons-material/EditRounded";
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
import HistoryRounded from "@mui/icons-material/HistoryRounded";
type AgentHeaderProps = {
sessionTitle?: string;
canRenameSessionTitle?: boolean;
isHydrating?: boolean;
isStreaming: boolean;
isHistoryOpen: boolean;
onHistoryToggle: () => void;
onRenameSessionTitle?: (title: string) => void;
onNewConversation: () => void;
onClose: () => void;
};
export const AgentHeader = ({
sessionTitle,
canRenameSessionTitle = false,
isHydrating = false,
isStreaming,
isHistoryOpen,
onHistoryToggle,
onRenameSessionTitle,
onNewConversation,
onClose,
}: AgentHeaderProps) => {
const theme = useTheme();
const displayTitle = sessionTitle?.trim() || "新对话";
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || "");
React.useEffect(() => {
if (!isEditingTitle) {
setDraftTitle(sessionTitle?.trim() || "");
}
}, [isEditingTitle, sessionTitle]);
const handleStartEditing = () => {
if (!canRenameSessionTitle || isHydrating || isStreaming) return;
setDraftTitle(sessionTitle?.trim() || "");
setIsEditingTitle(true);
};
const handleCancelEditing = () => {
setDraftTitle(sessionTitle?.trim() || "");
setIsEditingTitle(false);
};
const handleConfirmEditing = () => {
const normalizedTitle = draftTitle.trim();
if (!normalizedTitle) return;
onRenameSessionTitle?.(normalizedTitle);
setIsEditingTitle(false);
};
return (
<Box
sx={{
px: 3,
py: 2.5,
zIndex: 10,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
backdropFilter: "blur(20px)",
borderBottom: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
background: `linear-gradient(to bottom, ${alpha("#fff", 0.4)}, ${alpha("#fff", 0.1)})`,
boxShadow: `0 1px 0 ${alpha("#fff", 0.6)} inset`,
}}
>
<Stack direction="row" alignItems="center" spacing={2} sx={{ minWidth: 0, flex: 1, mr: 2 }}>
<motion.div whileHover={{ rotate: 10, scale: 1.05 }} whileTap={{ scale: 0.95 }} style={{ display: "flex", flexShrink: 0 }}>
<Box sx={{ position: "relative" }}>
<Avatar
sx={{
background: alpha("#ffffff", 0.9),
boxShadow: `0 8px 24px ${alpha("#00acc1", 0.4)}`,
width: 44,
height: 44,
border: `2px solid ${alpha("#fff", 0.8)}`,
p: 0.75,
}}
>
<Image
src="/ai-agent.svg"
alt="TJWater Agent"
width={30}
height={30}
style={{ width: "100%", height: "100%", objectFit: "contain" }}
/>
</Avatar>
<Box
sx={{
position: "absolute",
bottom: -2,
right: -2,
width: 14,
height: 14,
bgcolor: isStreaming ? "#ff9800" : "#00e676",
borderRadius: "50%",
border: "2.5px solid #fff",
boxShadow: `0 0 10px ${isStreaming ? "#ff9800" : "#00e676"}`,
animation: isStreaming ? "pulse 1.5s infinite" : "none",
"@keyframes pulse": {
"0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` },
"70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` },
"100%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0)}` },
},
}}
/>
</Box>
</motion.div>
<Box sx={{ minWidth: 0, flex: 1 }}>
{isEditingTitle ? (
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ width: "100%" }}>
<TextField
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
size="small"
autoFocus
placeholder="请输入对话标题"
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleConfirmEditing();
} else if (event.key === "Escape") {
event.preventDefault();
handleCancelEditing();
}
}}
sx={{
flex: 1,
minWidth: 0,
"& .MuiOutlinedInput-root": {
padding: "6px 8px",
bgcolor: "transparent",
borderRadius: 1.5,
transition: "all 0.2s ease-in-out",
"&.Mui-focused": {
bgcolor: alpha("#fff", 0.6),
boxShadow: `0 2px 10px ${alpha("#000", 0.05)}`,
},
"& fieldset": {
borderColor: "transparent",
},
"&:hover fieldset": {
borderColor: alpha(theme.palette.primary.main, 0.2),
},
"&.Mui-focused fieldset": {
borderColor: alpha(theme.palette.primary.main, 0.5),
borderWidth: "1px",
},
},
"& .MuiInputBase-input": {
padding: 0,
height: "auto",
fontSize: "1.25rem",
fontWeight: 800,
letterSpacing: -0.3,
lineHeight: "1.2",
background: `linear-gradient(90deg, #01579b, #00838f)`,
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}
}}
/>
<IconButton
size="small"
aria-label="确认"
onClick={handleConfirmEditing}
disabled={!draftTitle.trim()}
sx={{
width: 30,
height: 30,
color: "success.main",
bgcolor: alpha(theme.palette.success.main, 0.1),
"&:hover": { bgcolor: alpha(theme.palette.success.main, 0.2) },
}}
>
<CheckRounded sx={{ fontSize: 18 }} />
</IconButton>
<IconButton
size="small"
aria-label="取消"
onClick={handleCancelEditing}
sx={{
width: 30,
height: 30,
color: "text.secondary",
bgcolor: alpha("#000", 0.05),
"&:hover": { bgcolor: alpha("#000", 0.1) },
}}
>
<CloseRounded sx={{ fontSize: 18 }} />
</IconButton>
</Stack>
) : (
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}>
<Typography
variant="h6"
fontWeight={800}
sx={{
background: `linear-gradient(90deg, #01579b, #00838f)`,
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
letterSpacing: -0.3,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
px: "8px",
lineHeight: 1.2,
}}
>
{displayTitle}
</Typography>
{canRenameSessionTitle ? (
<Tooltip title="修改对话标题">
<span>
<IconButton
size="small"
aria-label="修改对话标题"
onClick={handleStartEditing}
disabled={isHydrating || isStreaming}
sx={{
width: 30,
height: 30,
color: "text.secondary",
bgcolor: alpha("#fff", 0.45),
"&:hover": {
color: "primary.main",
bgcolor: alpha(theme.palette.primary.main, 0.08),
},
}}
>
<EditRounded sx={{ fontSize: 18 }} />
</IconButton>
</span>
</Tooltip>
) : null}
</Stack>
)}
</Box>
</Stack>
<Stack direction="row" spacing={1.25} alignItems="center" sx={{ flexShrink: 0 }}>
<Tooltip title="新建对话">
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
<IconButton
onClick={onNewConversation}
aria-label="新建对话"
sx={{
width: 36,
height: 36,
color: "text.primary",
bgcolor: alpha("#fff", 0.54),
border: `1px solid ${alpha("#fff", 0.4)}`,
boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`,
"&:hover": {
bgcolor: "#fff",
color: "#00acc1",
borderColor: alpha("#fff", 0.8),
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`,
},
}}
>
<EditNoteRounded sx={{ fontSize: 22 }} />
</IconButton>
</motion.div>
</Tooltip>
<Tooltip title={isHistoryOpen ? "收起历史会话" : "打开历史会话"}>
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
<IconButton
onClick={onHistoryToggle}
aria-label={isHistoryOpen ? "收起历史会话" : "打开历史会话"}
sx={{
width: 36,
height: 36,
color: isHistoryOpen ? "#00acc1" : "text.primary",
bgcolor: isHistoryOpen ? alpha("#00acc1", 0.12) : alpha("#fff", 0.54),
border: `1px solid ${isHistoryOpen ? alpha("#00acc1", 0.2) : alpha("#fff", 0.4)}`,
boxShadow: `0 2px 8px ${isHistoryOpen ? alpha("#00acc1", 0.05) : alpha("#000", 0.02)}`,
"&:hover": {
bgcolor: isHistoryOpen ? alpha("#00acc1", 0.16) : "#fff",
borderColor: isHistoryOpen ? alpha("#00acc1", 0.3) : alpha("#fff", 0.8),
boxShadow: `0 4px 12px ${isHistoryOpen ? alpha("#00acc1", 0.1) : alpha("#000", 0.05)}`,
},
}}
>
<HistoryRounded sx={{ fontSize: 20 }} />
</IconButton>
</motion.div>
</Tooltip>
<Tooltip title="关闭 Agent">
<motion.div whileHover={{ scale: 1.08, rotate: 90 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
<IconButton
onClick={onClose}
aria-label="关闭 Agent"
sx={{
width: 36,
height: 36,
color: "text.primary",
bgcolor: alpha("#fff", 0.54),
border: `1px solid ${alpha("#fff", 0.4)}`,
boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`,
"&:hover": {
bgcolor: "#fff",
color: "#e53935",
borderColor: alpha("#fff", 0.8),
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`,
},
}}
>
<CloseRounded sx={{ fontSize: 20 }} />
</IconButton>
</motion.div>
</Tooltip>
</Stack>
</Box>
);
};
@@ -0,0 +1,69 @@
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import { AgentHistoryPanel } from "./AgentHistoryPanel";
const renderWithTheme = (ui: React.ReactElement) =>
render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>);
describe("AgentHistoryPanel", () => {
it("renames a history session from the list", () => {
const onRenameSession = jest.fn();
renderWithTheme(
<AgentHistoryPanel
sessions={[
{
id: "session-1",
title: "旧会话标题",
createdAt: Date.now(),
updatedAt: Date.now(),
},
]}
activeSessionId="session-1"
onNewSession={jest.fn()}
onRenameSession={onRenameSession}
onSelectSession={jest.fn()}
onDeleteSession={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "修改会话标题" }));
fireEvent.change(screen.getByPlaceholderText("请输入会话标题"), {
target: { value: "新的会话标题" },
});
fireEvent.click(screen.getByLabelText("确认"));
expect(onRenameSession).toHaveBeenCalledWith("session-1", "新的会话标题");
});
it("orders history by the first message time instead of the latest update time", () => {
renderWithTheme(
<AgentHistoryPanel
sessions={[
{
id: "session-newer-update",
title: "较新的更新",
createdAt: new Date("2026-05-18T09:00:00+08:00").getTime(),
updatedAt: new Date("2026-05-19T12:00:00+08:00").getTime(),
},
{
id: "session-newer-first-message",
title: "较新的首条消息",
createdAt: new Date("2026-05-19T08:00:00+08:00").getTime(),
updatedAt: new Date("2026-05-19T08:30:00+08:00").getTime(),
},
]}
onNewSession={jest.fn()}
onRenameSession={jest.fn()}
onSelectSession={jest.fn()}
onDeleteSession={jest.fn()}
/>,
);
const sessionTitles = screen.getAllByText(/较新的/).map((element) => element.textContent);
expect(sessionTitles).toEqual(["较新的首条消息", "较新的更新"]);
});
});
+546
View File
@@ -0,0 +1,546 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
IconButton,
Paper,
Stack,
TextField,
Tooltip,
Typography,
alpha,
useTheme,
} from "@mui/material";
import CheckRounded from "@mui/icons-material/CheckRounded";
import CloseRounded from "@mui/icons-material/CloseRounded";
import EditRounded from "@mui/icons-material/EditRounded";
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
import DeleteOutlineRounded from "@mui/icons-material/DeleteOutlineRounded";
import ChatBubbleOutlineRounded from "@mui/icons-material/ChatBubbleOutlineRounded";
import SearchRounded from "@mui/icons-material/SearchRounded";
import WarningRounded from "@mui/icons-material/WarningRounded";
import type { ChatSessionSummary } from "./GlobalChatbox.types";
type AgentHistoryPanelProps = {
sessions: ChatSessionSummary[];
activeSessionId?: string;
isHydrating?: boolean;
onNewSession: () => void;
onRenameSession: (sessionId: string, title: string) => void;
onSelectSession: (sessionId: string) => void;
onDeleteSession: (sessionId: string) => void;
};
const formatRelativeDate = (timestamp: number) => {
const date = new Date(timestamp);
const now = new Date();
const isSameDay = date.toDateString() === now.toDateString();
if (isSameDay) {
return date.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
}
return date.toLocaleDateString("zh-CN", {
month: "numeric",
day: "numeric",
});
};
const getDayStart = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const getSessionGroupLabel = (timestamp: number) => {
const now = new Date();
const todayStart = getDayStart(now);
const yesterdayStart = todayStart - 24 * 60 * 60 * 1000;
const lastWeekStart = todayStart - 7 * 24 * 60 * 60 * 1000;
if (timestamp >= todayStart) return "今天";
if (timestamp >= yesterdayStart) return "昨天";
if (timestamp >= lastWeekStart) return "过去 7 天";
return "更早";
};
export const AgentHistoryPanel = ({
sessions,
activeSessionId,
isHydrating = false,
onNewSession,
onRenameSession,
onSelectSession,
onDeleteSession,
}: AgentHistoryPanelProps) => {
const theme = useTheme();
const [keyword, setKeyword] = React.useState("");
const [editingSessionId, setEditingSessionId] = React.useState<string | null>(null);
const [draftTitle, setDraftTitle] = React.useState("");
const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState<string | null>(null);
const filteredSessions = React.useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) return sessions;
return sessions.filter((session) => session.title.toLowerCase().includes(normalizedKeyword));
}, [keyword, sessions]);
const sortedFilteredSessions = React.useMemo(
() =>
[...filteredSessions].sort((left, right) => {
const createdAtDiff = right.createdAt - left.createdAt;
if (createdAtDiff !== 0) return createdAtDiff;
const updatedAtDiff = right.updatedAt - left.updatedAt;
if (updatedAtDiff !== 0) return updatedAtDiff;
return right.id.localeCompare(left.id);
}),
[filteredSessions],
);
const groupedSessions = React.useMemo(() => {
const groups = new Map<string, ChatSessionSummary[]>();
sortedFilteredSessions.forEach((session) => {
const label = getSessionGroupLabel(session.createdAt);
const existing = groups.get(label);
if (existing) {
existing.push(session);
} else {
groups.set(label, [session]);
}
});
return Array.from(groups.entries());
}, [sortedFilteredSessions]);
const pendingDeleteSession = filteredSessions.find(
(session) => session.id === pendingDeleteSessionId,
);
const handleStartRename = (sessionId: string, title: string) => {
setEditingSessionId(sessionId);
setDraftTitle(title);
};
const handleCancelRename = () => {
setEditingSessionId(null);
setDraftTitle("");
};
const handleConfirmRename = (sessionId: string) => {
const normalizedTitle = draftTitle.trim();
if (!normalizedTitle) return;
onRenameSession(sessionId, normalizedTitle);
handleCancelRename();
};
return (
<>
<Paper
elevation={0}
sx={{
width: 268,
minWidth: 268,
height: "100%",
display: "flex",
flexDirection: "column",
bgcolor: alpha("#ffffff", 0.54),
borderRight: `1px solid ${alpha("#fff", 0.75)}`,
backdropFilter: "blur(28px)",
boxShadow: `inset -1px 0 0 ${alpha("#fff", 0.35)}`,
}}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.5 }}>
<Box>
<Typography variant="subtitle2" fontWeight={800} color="text.primary">
</Typography>
</Box>
<Tooltip title="新建对话">
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
<IconButton
disabled={isHydrating}
onClick={onNewSession}
aria-label="新建对话"
sx={{
width: 36,
height: 36,
color: "text.primary",
bgcolor: alpha("#fff", 0.65),
border: `1px solid ${alpha("#fff", 0.5)}`,
boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`,
"&:hover": {
bgcolor: "#fff",
color: "#00acc1",
borderColor: alpha("#fff", 0.9),
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`,
},
}}
>
<EditNoteRounded sx={{ fontSize: 22 }} />
</IconButton>
</motion.div>
</Tooltip>
</Stack>
<Box sx={{ px: 1.5, pb: 1.5 }}>
<TextField
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索历史会话"
size="small"
fullWidth
disabled={isHydrating}
InputProps={{
startAdornment: <SearchRounded sx={{ fontSize: 16, color: "text.secondary", mr: 0.75 }} />,
sx: {
borderRadius: 3,
bgcolor: alpha("#fff", 0.62),
fontSize: "0.85rem",
},
}}
/>
</Box>
<Divider sx={{ borderColor: alpha("#fff", 0.6) }} />
<Box sx={{ flex: 1, overflowY: "auto", px: 1.25, py: 1.25 }}>
{sessions.length === 0 ? (
<Stack
alignItems="center"
justifyContent="center"
spacing={1}
sx={{
height: "100%",
textAlign: "center",
color: "text.secondary",
px: 2,
}}
>
<ChatBubbleOutlineRounded sx={{ fontSize: 24, opacity: 0.7 }} />
<Typography variant="body2" fontWeight={700}>
</Typography>
<Typography variant="caption">
</Typography>
</Stack>
) : filteredSessions.length === 0 ? (
<Stack
alignItems="center"
justifyContent="center"
spacing={1}
sx={{
height: "100%",
textAlign: "center",
color: "text.secondary",
px: 2,
}}
>
<SearchRounded sx={{ fontSize: 24, opacity: 0.7 }} />
<Typography variant="body2" fontWeight={700}>
</Typography>
<Typography variant="caption">
</Typography>
</Stack>
) : (
<Stack spacing={1.5}>
{groupedSessions.map(([groupLabel, groupSessions]) => (
<Box key={groupLabel}>
<Typography
variant="caption"
color="text.secondary"
fontWeight={800}
sx={{ px: 0.5, mb: 0.75, display: "block", letterSpacing: 0.3 }}
>
{groupLabel}
</Typography>
<Stack spacing={1}>
{groupSessions.map((session) => {
const isActive = session.id === activeSessionId;
return (
<Paper
key={session.id}
elevation={0}
onClick={() => {
if (editingSessionId === session.id) return;
onSelectSession(session.id);
}}
sx={{
px: 1.25,
py: 1,
borderRadius: 3,
cursor: isHydrating ? "default" : "pointer",
bgcolor: isActive ? alpha("#00acc1", 0.12) : alpha("#fff", 0.56),
border: `1px solid ${isActive ? alpha("#00acc1", 0.25) : alpha("#fff", 0.72)}`,
boxShadow: isActive ? `0 8px 20px ${alpha("#00acc1", 0.12)}` : `0 4px 12px ${alpha("#000", 0.03)}`,
transition: "all 0.2s ease",
pointerEvents: isHydrating ? "none" : "auto",
"&:hover": {
bgcolor: isActive ? alpha("#00acc1", 0.14) : alpha("#fff", 0.86),
borderColor: alpha("#00acc1", 0.2),
},
}}
>
<Stack direction="row" spacing={1} alignItems="center">
<Box sx={{ flex: 1, minWidth: 0 }}>
{editingSessionId === session.id ? (
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ minHeight: 46 }}>
<TextField
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
size="small"
autoFocus
placeholder="请输入会话标题"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
handleConfirmRename(session.id);
} else if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
handleCancelRename();
}
}}
sx={{
flex: 1,
minWidth: 0,
"& .MuiOutlinedInput-root": {
height: 32,
bgcolor: alpha("#fff", 0.75),
borderRadius: 1.5,
transition: "all 0.2s ease-in-out",
"& fieldset": {
borderColor: alpha("#000", 0.08),
},
"&:hover fieldset": {
borderColor: alpha(theme.palette.primary.main, 0.4),
},
"&.Mui-focused fieldset": {
borderColor: theme.palette.primary.main,
borderWidth: "1.5px",
boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`,
},
},
"& .MuiInputBase-input": {
padding: "4px 10px",
fontSize: "0.85rem",
fontWeight: 700,
color: theme.palette.text.primary,
}
}}
/>
<IconButton
size="small"
aria-label="确认"
onClick={(event) => {
event.stopPropagation();
handleConfirmRename(session.id);
}}
disabled={!draftTitle.trim()}
sx={{
width: 28,
height: 28,
color: "success.main",
bgcolor: alpha(theme.palette.success.main, 0.1),
"&:hover": { bgcolor: alpha(theme.palette.success.main, 0.2) },
}}
>
<CheckRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
aria-label="取消"
onClick={(event) => {
event.stopPropagation();
handleCancelRename();
}}
sx={{
width: 28,
height: 28,
color: "text.secondary",
bgcolor: alpha("#000", 0.05),
"&:hover": { bgcolor: alpha("#000", 0.1) },
}}
>
<CloseRounded sx={{ fontSize: 16 }} />
</IconButton>
</Stack>
) : pendingDeleteSessionId === session.id ? (
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minHeight: 46 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 20,
height: 20,
borderRadius: "50%",
bgcolor: alpha("#ef5350", 0.15),
color: "#ef5350",
flexShrink: 0
}}
>
<WarningRounded sx={{ fontSize: 13 }} />
</Box>
<Typography
variant="body2"
fontWeight={800}
color="error.main"
sx={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
</Typography>
</Stack>
) : (
<Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}>
<Typography
variant="body2"
fontWeight={isActive ? 800 : 700}
color="text.primary"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
>
{session.title}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: "block" }}>
{formatRelativeDate(session.createdAt)}
</Typography>
</Box>
)}
</Box>
{!(editingSessionId === session.id || pendingDeleteSessionId === session.id) && (
<Stack direction="row" spacing={0.25}>
<Tooltip title="修改会话标题">
<span>
<IconButton
size="small"
aria-label="修改会话标题"
onClick={(event) => {
event.stopPropagation();
handleStartRename(session.id, session.title);
}}
disabled={isHydrating || editingSessionId === session.id}
sx={{
width: 28,
height: 28,
color: "text.secondary",
"&:hover": {
color: "primary.main",
bgcolor: alpha("#00acc1", 0.08),
},
}}
>
<EditRounded sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="删除会话">
<span>
<IconButton
size="small"
aria-label="删除会话"
onClick={(event) => {
event.stopPropagation();
setPendingDeleteSessionId(session.id);
}}
disabled={isHydrating}
sx={{
width: 28,
height: 28,
color: "text.secondary",
"&:hover": {
color: "error.main",
bgcolor: alpha("#ef5350", 0.08),
},
}}
>
<DeleteOutlineRounded sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
</Stack>
)}
{pendingDeleteSessionId === session.id && (
<Stack direction="row" spacing={0.5} alignItems="center">
<IconButton
size="small"
aria-label="确认删除"
onClick={(event) => {
event.stopPropagation();
onDeleteSession(session.id);
setPendingDeleteSessionId(null);
}}
sx={{
width: 28,
height: 28,
color: "error.main",
bgcolor: alpha("#ef5350", 0.1),
"&:hover": { bgcolor: alpha("#ef5350", 0.2) },
}}
>
<CheckRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
aria-label="取消删除"
onClick={(event) => {
event.stopPropagation();
setPendingDeleteSessionId(null);
}}
sx={{
width: 28,
height: 28,
color: "text.secondary",
bgcolor: alpha("#000", 0.05),
"&:hover": { bgcolor: alpha("#000", 0.1) },
}}
>
<CloseRounded sx={{ fontSize: 16 }} />
</IconButton>
</Stack>
)}
</Stack>
</Paper>
);
})}
</Stack>
</Box>
))}
</Stack>
)}
</Box>
</Paper>
</>
);
};
@@ -0,0 +1,27 @@
"use client";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
export const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, "");
export const MarkdownBlock = ({ children }: { children: string }) => {
const handleCopy = React.useCallback((event: React.ClipboardEvent<HTMLDivElement>) => {
const selectedText = window.getSelection()?.toString();
if (!selectedText) return;
event.preventDefault();
event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText));
}, []);
return (
<div className={markdownStyles.markdown} onCopy={handleCopy}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{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,110 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { AgentProgressTimeline } from "./AgentProgressTimeline";
import type { ChatProgress } from "./GlobalChatbox.types";
describe("AgentProgressTimeline", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
});
afterEach(() => {
jest.useRealTimers();
});
it("shows the running step and keeps the timeline expanded while running", () => {
const now = Date.now();
const progress: ChatProgress[] = [
{
id: "start",
phase: "start",
status: "running",
title: "收到请求",
startedAt: now - 5000,
elapsedMs: 5000,
elapsedSnapshotAt: now,
},
{
id: "tool",
phase: "tool",
status: "running",
title: "正在调用 tjwater_cli",
detail: "analysis bottlenecks",
startedAt: now - 1200,
elapsedMs: 1200,
elapsedSnapshotAt: now,
},
];
render(<AgentProgressTimeline progress={progress} />);
expect(screen.getByText(/Agent 过程:/)).toBeInTheDocument();
expect(screen.getByText(/耗时 5.0s/)).toBeInTheDocument();
expect(screen.getByText("查询后端数据")).toBeInTheDocument();
expect(screen.getByText("analysis bottlenecks")).toBeInTheDocument();
expect(screen.getByText("1.2s")).toBeInTheDocument();
});
it("summarizes completed steps and lets users expand details", async () => {
const progress: ChatProgress[] = [
{
id: "request-received",
phase: "start",
status: "completed",
title: "收到请求",
startedAt: Date.now() - 8000,
endedAt: Date.now(),
durationMs: 8000,
},
{
id: "done",
phase: "complete",
status: "completed",
title: "分析完成",
startedAt: Date.now() - 1000,
endedAt: Date.now(),
durationMs: 1000,
},
];
render(<AgentProgressTimeline progress={progress} />);
expect(screen.getByText(/已完成 \(2 步\)/)).toBeInTheDocument();
expect(screen.getByText(/耗时 8.0s/)).toBeInTheDocument();
expect(screen.queryByText("分析完成")).not.toBeVisible();
fireEvent.click(screen.getByText(/Agent 过程:/));
expect(screen.getByText("分析完成")).toBeVisible();
});
it("treats stale running steps as finished after a complete event", () => {
const progress: ChatProgress[] = [
{
id: "tool",
phase: "tool",
status: "completed",
title: "正在调用 tjwater_cli",
startedAt: Date.now() - 4000,
endedAt: Date.now(),
},
{
id: "done",
phase: "complete",
status: "completed",
title: "分析完成",
startedAt: Date.now() - 500,
endedAt: Date.now(),
durationMs: 500,
},
];
render(<AgentProgressTimeline progress={progress} />);
expect(screen.getByText(/已完成 \(2 步\)/)).toBeInTheDocument();
expect(screen.getByText("4.0s")).toBeInTheDocument();
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,372 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Collapse,
LinearProgress,
Stack,
Typography,
alpha,
useTheme,
} from "@mui/material";
import AutoAwesome from "@mui/icons-material/AutoAwesome";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
import ManageSearchRounded from "@mui/icons-material/ManageSearchRounded";
import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded";
import TaskAltRounded from "@mui/icons-material/TaskAltRounded";
import PsychologyRounded from "@mui/icons-material/PsychologyRounded";
import SyncRounded from "@mui/icons-material/SyncRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import type { ChatProgress } from "./GlobalChatbox.types";
const formatDuration = (durationMs: number) => {
if (!Number.isFinite(durationMs) || durationMs < 0) {
return "0s";
}
if (durationMs < 10_000) {
return `${(durationMs / 1000).toFixed(1)}s`;
}
const totalSeconds = Math.round(durationMs / 1000);
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) {
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`;
}
const hours = Math.floor(minutes / 60);
const remainMinutes = minutes % 60;
return `${hours}h ${remainMinutes.toString().padStart(2, "0")}m`;
};
const getProgressElapsedMs = (item: ChatProgress, nowMs: number) => {
if (item.durationMs !== undefined) {
return item.durationMs;
}
if (item.status === "running") {
if (item.elapsedMs !== undefined && item.elapsedSnapshotAt !== undefined) {
return Math.max(0, item.elapsedMs + (nowMs - item.elapsedSnapshotAt));
}
if (item.startedAt !== undefined) {
return Math.max(0, nowMs - item.startedAt);
}
return item.elapsedMs;
}
if (item.startedAt !== undefined && item.endedAt !== undefined) {
return Math.max(0, item.endedAt - item.startedAt);
}
return item.elapsedMs;
};
const phaseIcon = (phase: string, status: ChatProgress["status"]) => {
const sx = { fontSize: 16 };
if (status === "completed") return <CheckCircleRounded sx={{ ...sx, color: "success.main" }} />;
if (status === "error") return <ErrorOutlineRounded sx={{ ...sx, color: "error.main" }} />;
if (phase === "planning") return <PsychologyRounded sx={{ ...sx, color: "#00acc1" }} />;
if (phase === "tool") return <BuildCircleRounded sx={{ ...sx, color: "warning.main" }} />;
if (phase === "complete") return <TaskAltRounded sx={{ ...sx, color: "success.main" }} />;
if (phase === "session") return <SyncRounded sx={{ ...sx, color: "info.main" }} />;
if (phase === "start") return <ManageSearchRounded sx={{ ...sx, color: "#00acc1" }} />;
return <AutoAwesome sx={{ ...sx, color: "#00acc1" }} />;
};
const formatToolTitle = (item: ChatProgress) => {
const text = `${item.title} ${item.detail ?? ""}`;
if (text.includes("tjwater_cli")) return "查询后端数据";
if (text.includes("show_chart")) return "生成图表";
if (text.includes("locate_features")) return "地图定位";
if (text.includes("view_history")) return "打开历史曲线";
if (text.includes("view_scada")) return "打开 SCADA 面板";
if (text.includes("render_junctions")) return "渲染节点";
return item.title;
};
type AgentProgressTimelineProps = {
progress: ChatProgress[];
isAborted?: boolean;
};
const AgentProgressTimelineInner = ({ progress, isAborted }: AgentProgressTimelineProps) => {
const theme = useTheme();
const [nowMs, setNowMs] = useState(() => Date.now());
// 判断是否最终完成(哪怕中间有报错,只要有完整的标记就算成功)
const isOverallComplete = progress.some(
(item) => item.phase === "complete" && item.status === "completed",
);
// 修正状态判断:如果外部标记为中断,或者没有完成标记
const hasRunning = !isAborted && !isOverallComplete && progress.some((item) => item.status === "running");
const hasError = isAborted || progress.some((item) => item.status === "error");
useEffect(() => {
if (!hasRunning) {
return;
}
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 500);
return () => window.clearInterval(timer);
}, [hasRunning]);
// 展开状态逻辑:默认折叠,保持界面整洁
const [expanded, setExpanded] = useState(false);
const summary = useMemo(() => {
if (isAborted) return `已中断 (进行到第 ${progress.length} 步)`;
if (isOverallComplete) {
return hasError ? `已完成 (含 ${progress.length} 步探索)` : `已完成 (${progress.length} 步)`;
}
const runningItem = [...progress].reverse().find((item) => item.status === "running");
if (runningItem) return `${runningItem.title}...`;
if (hasError) return "过程异常,尝试恢复中...";
return `已执行 ${progress.length}`;
}, [isOverallComplete, hasError, progress, isAborted]);
const totalDurationLabel = useMemo(() => {
const requestProgress = progress.find((item) => item.id === "request-received");
const requestElapsed =
requestProgress ? getProgressElapsedMs(requestProgress, nowMs) : undefined;
if (requestElapsed !== undefined) {
return formatDuration(requestElapsed);
}
const startedAtValues = progress
.map((item) => item.startedAt)
.filter((value): value is number => value !== undefined);
if (startedAtValues.length === 0) {
return undefined;
}
const minStartedAt = Math.min(...startedAtValues);
const endedAtValues = progress
.map((item) => item.endedAt)
.filter((value): value is number => value !== undefined);
const endAnchor = isOverallComplete
? endedAtValues.length > 0
? Math.max(...endedAtValues)
: nowMs
: nowMs;
return formatDuration(Math.max(0, endAnchor - minStartedAt));
}, [isOverallComplete, nowMs, progress]);
// 根据整体状态决定顶部卡片的颜色主题
const statusColor = isOverallComplete
? "#4caf50" // Success Green
: isAborted || (hasError && !hasRunning)
? theme.palette.error.main // Error Red
: "#00acc1"; // Primary Cyan
// 默认折叠:只显示最新的三条
const visibleCount = 3;
const isCollapsible = progress.length > visibleCount;
return (
<Box
sx={{
borderRadius: 4,
bgcolor: alpha(statusColor, 0.04),
border: `1px solid ${alpha(statusColor, 0.15)}`,
backdropFilter: "blur(12px)",
overflow: "hidden",
transition: "all 0.3s ease",
"&:hover": {
bgcolor: alpha(statusColor, 0.06),
borderColor: alpha(statusColor, 0.25),
}
}}
>
<Stack
direction="row"
spacing={1.5}
alignItems="center"
onClick={() => setExpanded(!expanded)}
sx={{
px: 2,
py: 1.25,
cursor: "pointer",
userSelect: "none"
}}
>
{isOverallComplete ? (
<TaskAltRounded sx={{ fontSize: 18, color: statusColor }} />
) : hasRunning ? (
<AutoAwesome sx={{ fontSize: 18, color: statusColor, animation: "spin 2s linear infinite", "@keyframes spin": { "0%": { transform: "rotate(0deg)" }, "100%": { transform: "rotate(360deg)" } } }} />
) : hasError ? (
<ErrorOutlineRounded sx={{ fontSize: 18, color: statusColor }} />
) : (
<AutoAwesome sx={{ fontSize: 18, color: statusColor }} />
)}
<Typography variant="caption" fontWeight={700} color="text.primary" sx={{ flex: 1, letterSpacing: 0.3 }}>
Agent : {summary}
{totalDurationLabel ? ` · 耗时 ${totalDurationLabel}` : ""}
</Typography>
<KeyboardArrowDownRounded
sx={{
fontSize: 20,
color: "text.secondary",
transform: expanded ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
}}
/>
</Stack>
{hasRunning && !expanded ? (
<LinearProgress
sx={{
height: 2,
bgcolor: "transparent",
"& .MuiLinearProgress-bar": { bgcolor: statusColor }
}}
/>
) : null}
<Collapse in={expanded || hasRunning} timeout="auto" unmountOnExit={false}>
<Box>
{hasRunning ? (
<LinearProgress
sx={{
height: 1,
bgcolor: alpha(statusColor, 0.1),
"& .MuiLinearProgress-bar": { bgcolor: statusColor }
}}
/>
) : (
<Box sx={{ height: 1, bgcolor: alpha(statusColor, 0.1) }} />
)}
<Stack spacing={0} sx={{ px: 2, py: 1.5 }}>
{progress.map((item, index) => {
const isLast = index === progress.length - 1;
const isHiddenWhenCollapsed = isCollapsible && index < progress.length - visibleCount;
const stepElapsedMs = getProgressElapsedMs(item, nowMs);
const itemColor = isAborted && isLast
? theme.palette.error.main
: item.status === "error"
? theme.palette.error.main
: item.status === "completed"
? "#4caf50"
: "#00acc1";
const content = (
<Stack key={item.id} direction="row" spacing={1.5} alignItems="stretch">
<Box
sx={{
position: "relative",
width: 20,
display: "flex",
justifyContent: "center",
flexShrink: 0,
pt: 0.3,
}}
>
{!isLast ? (
<Box
aria-hidden
sx={{
position: "absolute",
top: 22,
bottom: -6,
left: "50%",
width: 2,
transform: "translateX(-50%)",
borderRadius: 2,
bgcolor: alpha(itemColor, item.status === "completed" ? 0.2 : 0.4),
}}
/>
) : null}
<Box
sx={{
position: "relative",
zIndex: 1,
width: 20,
height: 20,
borderRadius: "50%",
bgcolor: alpha(theme.palette.background.paper, 0.9),
boxShadow: `0 0 0 2px ${alpha(itemColor, 0.1)}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{phaseIcon(
item.phase,
isAborted && isLast ? "error" :
isOverallComplete && item.status === "running"
? "completed"
: item.status,
)}
</Box>
</Box>
<Box sx={{ minWidth: 0, flex: 1, pb: isLast ? 0 : 2 }}>
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
<Typography variant="caption" color="text.primary" fontWeight={600} sx={{ fontSize: "0.75rem" }}>
{item.phase === "tool" ? formatToolTitle(item) : item.title}
</Typography>
{stepElapsedMs !== undefined ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: "0.68rem", fontFamily: "var(--font-mono, monospace)" }}
>
{formatDuration(stepElapsedMs)}
</Typography>
) : null}
</Stack>
{item.detail && (
<Collapse in={expanded || isLast} timeout="auto">
<Typography
variant="caption"
component="div"
sx={{
mt: 0.5,
px: 1.25,
py: 0.75,
borderRadius: 2,
bgcolor: alpha(itemColor, 0.05),
border: `1px solid ${alpha(itemColor, 0.1)}`,
color: "text.secondary",
whiteSpace: "pre-wrap",
fontFamily: "var(--font-mono, monospace)",
fontSize: "0.7rem",
lineHeight: 1.5,
wordBreak: "break-all",
}}
>
{item.detail}
</Typography>
</Collapse>
)}
</Box>
</Stack>
);
if (isHiddenWhenCollapsed) {
return (
<Collapse key={item.id} in={expanded} timeout="auto" unmountOnExit={false}>
{content}
</Collapse>
);
}
return content;
})}
</Stack>
</Box>
</Collapse>
</Box>
);
};
export const AgentProgressTimeline = React.memo(
AgentProgressTimelineInner,
(prevProps, nextProps) =>
prevProps.progress === nextProps.progress &&
prevProps.isAborted === nextProps.isAborted,
);
AgentProgressTimeline.displayName = "AgentProgressTimeline";
@@ -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>
);
+308
View File
@@ -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>
);
};
+379
View File
@@ -0,0 +1,379 @@
"use client";
import Image from "next/image";
import React, { useMemo } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
Avatar,
Box,
IconButton,
Paper,
Stack,
Tooltip,
Typography,
alpha,
useTheme,
} from "@mui/material";
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
import { TbArrowsSplit2 } from "react-icons/tb";
import type { PermissionReply } from "@/lib/chatStream";
import {
parseAssistantMessageSections,
parseContentWithToolCalls,
type ContentSegment,
} from "./chatMessageSections";
import type { Message, SpeechState } from "./GlobalChatbox.types";
import { stripMarkdown } from "./GlobalChatbox.utils";
import { AgentProgressTimeline } from "./AgentProgressTimeline";
import { ChatInlineChart } from "./ChatInlineChart";
import type { ChatChartSeries } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock";
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
import { PermissionRequestGroup } from "./AgentPermissionRequests";
import { QuestionRequestGroup } from "./AgentQuestionRequests";
import { TodoPlanCard } from "./AgentTodoPlanCard";
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
import PauseRounded from "@mui/icons-material/PauseRounded";
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
import StopRounded from "@mui/icons-material/StopRounded";
type AgentTurnProps = {
message: Message;
isStreaming: boolean;
messageSpeechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPause: () => void;
onResume: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
onReplyQuestion: (requestId: string, answers: string[][]) => void;
onRejectQuestion: (requestId: string) => void;
};
export const AgentTurn = React.memo(
({
message,
isStreaming,
messageSpeechState,
onSpeak,
onPause,
onResume,
onStopSpeech,
isTtsSupported,
onCreateBranch,
onReplyPermission,
onReplyQuestion,
onRejectQuestion,
}: AgentTurnProps) => {
const theme = useTheme();
const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError);
const [isHovered, setIsHovered] = React.useState(false);
const isProgressComplete = message.progress?.some(
(item) => item.phase === "complete" && item.status === "completed",
) ?? false;
const isProgressRunning = !isErrorMessage && !isProgressComplete && (
message.progress?.some((item) => item.status === "running") ?? false
);
const parsedAssistantSections = useMemo(
() =>
!isUser && !isErrorMessage
? parseAssistantMessageSections(message.content)
: null,
[isErrorMessage, isUser, message.content],
);
const answerContent = parsedAssistantSections?.answer ?? message.content;
const contentSegments: ContentSegment[] = useMemo(
() =>
!isUser && !isErrorMessage
? parseContentWithToolCalls(answerContent).segments
: [{ type: "text", content: answerContent }],
[answerContent, isErrorMessage, isUser],
);
if (isUser) {
return (
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8 }}
transition={{ type: "spring", stiffness: 350, damping: 25 }}
style={{ alignSelf: "flex-end", maxWidth: "86%", position: "relative" }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Paper
elevation={4}
sx={{
p: 2,
borderRadius: 5,
borderBottomRightRadius: 2,
color: "#fff",
background: `linear-gradient(135deg, #0288d1, #00acc1)`,
boxShadow: `0 8px 24px -8px ${alpha("#00acc1", 0.5)}, inset 0 2px 4px ${alpha("#fff", 0.2)}`,
backdropFilter: "blur(10px)",
"--chat-md-text": alpha("#fff", 0.96),
"--chat-md-heading": "#fff",
"--chat-md-link": "#e0f7fa",
"--chat-md-link-hover": "#fff",
"--chat-md-inline-code-bg": "rgba(255,255,255,0.15)",
"--chat-md-inline-code-border": alpha("#fff", 0.1),
"--chat-md-inline-code-text": "#fff",
"--chat-md-pre-bg": "rgba(0, 0, 0, 0.25)",
"--chat-md-pre-border": alpha("#fff", 0.1),
"--chat-md-pre-text": "#F8FAFC",
"--chat-md-quote-border": alpha("#fff", 0.4),
"--chat-md-quote-bg": alpha("#fff", 0.05),
"--chat-md-quote-text": alpha("#fff", 0.8),
}}
>
<MarkdownBlock>{message.content}</MarkdownBlock>
</Paper>
</motion.div>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ type: "spring", stiffness: 320, damping: 26 }}
style={{ width: "100%", position: "relative" }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Stack direction="row" spacing={1.5} alignItems="flex-start">
<Avatar
sx={{
width: 34,
height: 34,
background: alpha("#ffffff", 0.9),
boxShadow: `0 4px 12px ${alpha("#00acc1", 0.25)}`,
border: `1.5px solid ${alpha("#fff", 0.8)}`,
color: "#00acc1",
mt: 0.25,
p: 0.5,
}}
>
<Image
src="/ai-agent.svg"
alt="TJWater Agent"
width={18}
height={18}
style={{ objectFit: "contain" }}
/>
</Avatar>
<Paper
elevation={0}
sx={{
flex: 1,
minWidth: 0,
p: 2,
borderRadius: 5,
bgcolor: alpha("#ffffff", 0.65),
border: `1px solid ${alpha("#fff", 0.8)}`,
boxShadow: `0 10px 30px -10px ${alpha(theme.palette.common.black, 0.08)}`,
backdropFilter: "blur(20px)",
position: "relative",
"--chat-md-text": "text.primary",
"--chat-md-heading": "text.primary",
"--chat-md-link": "#00838f",
"--chat-md-link-hover": "#00acc1",
"--chat-md-inline-code-bg": alpha("#00acc1", 0.08),
"--chat-md-inline-code-border": alpha("#00acc1", 0.15),
"--chat-md-inline-code-text": "#006064",
"--chat-md-pre-bg": "#1e293b",
"--chat-md-pre-border": "#475569",
"--chat-md-pre-text": "#f1f5f9",
"--chat-md-quote-border": "#00acc1",
"--chat-md-quote-bg": alpha("#00acc1", 0.04),
"--chat-md-quote-text": "text.secondary",
}}
>
<Stack spacing={1.5}>
{message.progress?.length ? (
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
) : null}
{message.permissions?.length ? (
<PermissionRequestGroup
permissions={message.permissions}
isRunning={isProgressRunning}
onReply={onReplyPermission}
/>
) : null}
{message.questions?.length ? (
<QuestionRequestGroup
questions={message.questions}
onReply={onReplyQuestion}
onReject={onRejectQuestion}
/>
) : null}
{message.todos ? (
<TodoPlanCard todoUpdate={message.todos} />
) : null}
<Box
sx={{
p: 1.5,
borderRadius: 4,
bgcolor: alpha("#fff", 0.4),
border: `1px solid ${alpha("#fff", 0.6)}`,
}}
>
<Stack spacing={1.2}>
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
</Typography>
{contentSegments.map((segment, segIdx) => {
if (segment.type === "text") {
const text = segment.content.trim();
if (!text && contentSegments.length > 1) return null;
return <MarkdownBlock key={segIdx}>{text || "..."}</MarkdownBlock>;
}
if (segment.type === "tool_call") {
if (
segment.toolCall.tool === "chart" ||
segment.toolCall.tool === "show_chart"
) {
const p = segment.toolCall.params;
return (
<ChatInlineChart
key={segment.toolCall.id}
title={(p.title as string) ?? undefined}
chart_type={
(p.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={(p.x_data as string[]) ?? []}
series={(p.series as ChatChartSeries[]) ?? []}
x_axis_name={(p.x_axis_name as string) ?? undefined}
y_axis_name={(p.y_axis_name as string) ?? undefined}
/>
);
}
return (
<ChatToolCallBlock
key={segment.toolCall.id}
toolCall={segment.toolCall}
/>
);
}
if (segment.type === "tool_call_pending") {
return (
<Typography key="tool-pending" variant="caption" color="text.secondary">
...
</Typography>
);
}
return null;
})}
</Stack>
</Box>
</Stack>
<AnimatePresence>
{isHovered && !isStreaming && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 5 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 5 }}
transition={{ duration: 0.15 }}
style={{ position: "absolute", top: -14, right: 12, zIndex: 10 }}
>
<Paper
elevation={4}
sx={{
display: "flex",
gap: 0.5,
p: 0.5,
borderRadius: "16px",
bgcolor: alpha("#fff", 0.8),
backdropFilter: "blur(16px)",
border: `1px solid ${alpha("#fff", 0.9)}`,
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
}}
>
<Tooltip title="复制">
<IconButton
size="small"
aria-label="复制"
onClick={() => {
navigator.clipboard.writeText(
normalizeClipboardText(message.content),
);
// Could add a toast here
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
>
<ContentCopyRounded sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Tooltip title="拆分为新会话">
<IconButton
size="small"
aria-label="拆分为新会话"
onClick={() => {
onCreateBranch(message.id);
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
>
<TbArrowsSplit2 size={16} />
</IconButton>
</Tooltip>
</Paper>
</motion.div>
)}
</AnimatePresence>
</Paper>
</Stack>
{!isErrorMessage && isTtsSupported ? (
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 0.5, ml: 6, mb: 1 }}>
<Stack direction="row" spacing={0.5} sx={{ opacity: isHovered ? 1 : 0.4, transition: "opacity 0.2s" }}>
{messageSpeechState === "idle" ? (
<IconButton
size="small"
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
aria-label="朗读消息"
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
>
<VolumeUpRounded sx={{ fontSize: 16 }} />
</IconButton>
) : null}
{messageSpeechState === "playing" ? (
<>
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
<PauseRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
) : null}
{messageSpeechState === "paused" ? (
<>
<IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}>
<PlayArrowRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
) : null}
</Stack>
</Stack>
) : null}
</motion.div>
);
},
);
AgentTurn.displayName = "AgentTurn";
@@ -0,0 +1,96 @@
/* eslint-disable @next/next/no-img-element */
import "@testing-library/jest-dom";
import React from "react";
import { render } from "@testing-library/react";
import { AgentWorkspace } from "./AgentWorkspace";
import type { Message } from "./GlobalChatbox.types";
const renderCounts = new Map<string, number>();
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, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
},
}));
jest.mock("./GlobalChatbox.parts", () => ({
TypingIndicator: () => <div>typing</div>,
}));
jest.mock("./AgentTurn", () => ({
AgentTurn: ({ message }: { message: Message }) => {
renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1);
return <div data-testid={`turn-${message.id}`}>{message.content}</div>;
},
}));
describe("AgentWorkspace", () => {
const defaultProps = {
bottomRef: { current: null },
speakingMessageId: null,
speechState: "idle" as const,
onSpeak: jest.fn(),
onPauseSpeech: jest.fn(),
onResumeSpeech: jest.fn(),
onStopSpeech: jest.fn(),
isTtsSupported: false,
onCreateBranch: jest.fn(),
onReplyPermission: jest.fn(),
onReplyQuestion: jest.fn(),
onRejectQuestion: jest.fn(),
};
beforeEach(() => {
renderCounts.clear();
});
it("keeps stable history turns from re-rendering while the last assistant message streams", () => {
const userMessage: Message = {
id: "user-1",
role: "user",
content: "question",
};
const assistantHistoryMessage: Message = {
id: "assistant-1",
role: "assistant",
content: "stable answer",
};
const streamingMessage: Message = {
id: "assistant-2",
role: "assistant",
content: "partial",
};
const { rerender } = render(
<AgentWorkspace
{...defaultProps}
isStreaming
messages={[userMessage, assistantHistoryMessage, streamingMessage]}
/>,
);
const updatedStreamingMessage: Message = {
...streamingMessage,
content: "partial with more tokens",
};
rerender(
<AgentWorkspace
{...defaultProps}
isStreaming
messages={[userMessage, assistantHistoryMessage, updatedStreamingMessage]}
/>,
);
expect(renderCounts.get("user-1")).toBe(1);
expect(renderCounts.get("assistant-1")).toBe(1);
expect(renderCounts.get("assistant-2")).toBe(2);
});
});
+339
View File
@@ -0,0 +1,339 @@
"use client";
import Image from "next/image";
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Box, Paper, Stack, Typography, alpha, useTheme, Grid } from "@mui/material";
import WaterDropRounded from "@mui/icons-material/WaterDropRounded";
import SensorsRounded from "@mui/icons-material/SensorsRounded";
import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
import MapRounded from "@mui/icons-material/MapRounded";
import { AgentTurn } from "./AgentTurn";
import { TypingIndicator } from "./GlobalChatbox.parts";
import type { PermissionReply } from "@/lib/chatStream";
import type {
Message,
SpeechState,
} from "./GlobalChatbox.types";
type AgentWorkspaceProps = {
messages: Message[];
isStreaming: boolean;
bottomRef: React.RefObject<HTMLDivElement | null>;
speakingMessageId: string | null;
speechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPauseSpeech: () => void;
onResumeSpeech: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
onReplyQuestion: (requestId: string, answers: string[][]) => void;
onRejectQuestion: (requestId: string) => void;
};
type TurnListProps = {
messages: Message[];
isStreaming: boolean;
speakingMessageId: string | null;
speechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPauseSpeech: () => void;
onResumeSpeech: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
onReplyQuestion: (requestId: string, answers: string[][]) => void;
onRejectQuestion: (requestId: string) => void;
};
const sameMessages = (left: Message[], right: Message[]) =>
left.length === right.length &&
left.every((message, index) => message === right[index]);
const TurnListInner = ({
messages,
isStreaming,
speakingMessageId,
speechState,
onSpeak,
onPauseSpeech,
onResumeSpeech,
onStopSpeech,
isTtsSupported,
onCreateBranch,
onReplyPermission,
onReplyQuestion,
onRejectQuestion,
}: TurnListProps) => {
return (
<>
{messages.map((message) => (
<AgentTurn
key={message.id}
message={message}
isStreaming={isStreaming}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
onSpeak={onSpeak}
onPause={onPauseSpeech}
onResume={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
onCreateBranch={onCreateBranch}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
))}
</>
);
};
const TurnList = React.memo(
TurnListInner,
(prevProps, nextProps) =>
sameMessages(prevProps.messages, nextProps.messages) &&
prevProps.isStreaming === nextProps.isStreaming &&
prevProps.speakingMessageId === nextProps.speakingMessageId &&
prevProps.speechState === nextProps.speechState &&
prevProps.onSpeak === nextProps.onSpeak &&
prevProps.onPauseSpeech === nextProps.onPauseSpeech &&
prevProps.onResumeSpeech === nextProps.onResumeSpeech &&
prevProps.onStopSpeech === nextProps.onStopSpeech &&
prevProps.isTtsSupported === nextProps.isTtsSupported &&
prevProps.onCreateBranch === nextProps.onCreateBranch &&
prevProps.onReplyPermission === nextProps.onReplyPermission &&
prevProps.onReplyQuestion === nextProps.onReplyQuestion &&
prevProps.onRejectQuestion === nextProps.onRejectQuestion,
);
TurnList.displayName = "TurnList";
const EmptyState = () => {
const theme = useTheme();
const capabilities = [
{ icon: <WaterDropRounded sx={{ fontSize: 20, color: "#00acc1" }} />, label: "水力瓶颈识别" },
{ icon: <SensorsRounded sx={{ fontSize: 20, color: "#0288d1" }} />, label: "异常状态预警" },
{ icon: <TroubleshootRounded sx={{ fontSize: 20, color: "#43a047" }} />, label: "调度与改造建议" },
{ icon: <MapRounded sx={{ fontSize: 20, color: "#8e24aa" }} />, label: "GIS 地图联动" },
];
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }}
>
<Paper
elevation={0}
sx={{
p: 4,
borderRadius: 4,
bgcolor: alpha("#ffffff", 0.4),
border: `1px solid ${alpha("#fff", 0.8)}`,
boxShadow: `0 16px 40px ${alpha("#000", 0.05)}`,
textAlign: "center",
backdropFilter: "blur(24px)",
position: "relative",
overflow: "hidden",
}}
>
<Box sx={{
position: "absolute",
top: -100,
right: -100,
width: 200,
height: 200,
background: "radial-gradient(circle, rgba(0, 172, 193, 0.15) 0%, rgba(255,255,255,0) 70%)",
}} />
<motion.div
animate={{
y: [-6, 4, -6],
scale: [1, 1.04, 1],
rotate: [-3, 3, -3],
}}
transition={{ duration: 4.8, repeat: Infinity, ease: "easeInOut" }}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 88,
height: 88,
marginBottom: 12,
borderRadius: "50%",
background: "radial-gradient(circle, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.45) 58%, rgba(255,255,255,0) 100%)",
boxShadow: "0 10px 28px rgba(0, 131, 143, 0.12)",
}}
>
<Image
src="/ai-agent.svg"
alt="TJWater Agent"
width={54}
height={54}
style={{
objectFit: "contain",
filter: "drop-shadow(0 4px 12px rgba(0, 131, 143, 0.2))",
}}
/>
</motion.div>
<Typography variant="h6" color="text.primary" fontWeight={800} gutterBottom>
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6, mb: 3 }}>
使
</Typography>
<Grid container spacing={1.5}>
{capabilities.map((item) => (
<Grid item xs={6} key={item.label}>
<motion.div whileHover={{ y: -2, scale: 1.02 }} transition={{ duration: 0.2 }}>
<Stack
direction="row"
spacing={1}
alignItems="center"
justifyContent="center"
sx={{
px: 1.5,
py: 1.5,
borderRadius: 3,
bgcolor: alpha("#fff", 0.5),
border: `1px solid ${alpha("#fff", 0.6)}`,
boxShadow: `0 4px 12px ${alpha("#000", 0.03)}`,
color: "text.primary",
transition: "all 0.2s",
"&:hover": {
bgcolor: alpha("#fff", 0.8),
borderColor: alpha("#00acc1", 0.4),
boxShadow: `0 6px 16px ${alpha("#00acc1", 0.15)}`,
}
}}
>
{item.icon}
<Typography variant="caption" fontWeight={700}>
{item.label}
</Typography>
</Stack>
</motion.div>
</Grid>
))}
</Grid>
</Paper>
</motion.div>
);
};
export const AgentWorkspace = ({
messages,
isStreaming,
bottomRef,
speakingMessageId,
speechState,
onSpeak,
onPauseSpeech,
onResumeSpeech,
onStopSpeech,
isTtsSupported,
onCreateBranch,
onReplyPermission,
onReplyQuestion,
onRejectQuestion,
}: AgentWorkspaceProps) => {
const theme = useTheme();
const latestAssistant = [...messages]
.reverse()
.find((message) => message.role === "assistant");
const showTypingIndicator =
isStreaming &&
(!latestAssistant ||
(latestAssistant.content.trim().length === 0 &&
!(latestAssistant.artifacts?.length)));
const streamingMessage =
isStreaming && messages.at(-1)?.role === "assistant"
? messages.at(-1)
: undefined;
const historyMessages =
streamingMessage !== undefined ? messages.slice(0, -1) : messages;
return (
<Box
sx={{
flex: 1,
overflowY: "auto",
px: 2.5,
py: 2,
display: "flex",
flexDirection: "column",
zIndex: 5,
}}
>
<AnimatePresence initial={false}>
{messages.length === 0 ? <EmptyState /> : null}
</AnimatePresence>
{messages.length > 0 ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<TurnList
messages={historyMessages}
isStreaming={isStreaming}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={onSpeak}
onPauseSpeech={onPauseSpeech}
onResumeSpeech={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
onCreateBranch={onCreateBranch}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
{streamingMessage ? (
<TurnList
messages={[streamingMessage]}
isStreaming={isStreaming}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={onSpeak}
onPauseSpeech={onPauseSpeech}
onResumeSpeech={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
onCreateBranch={onCreateBranch}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
) : null}
</Box>
) : null}
{showTypingIndicator ? (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 300 }}
style={{ alignSelf: "flex-start", display: "flex", gap: 12, marginTop: 4, marginLeft: 44 }}
>
<Paper
elevation={0}
sx={{
p: 1.3,
borderRadius: 4,
bgcolor: alpha("#fff", 0.82),
boxShadow: `0 4px 12px ${alpha(theme.palette.common.black, 0.05)}`,
}}
>
<TypingIndicator />
</Paper>
</motion.div>
) : null}
<div ref={bottomRef} style={{ height: 1 }} />
</Box>
);
};
+268 -96
View File
@@ -10,17 +10,26 @@ import {
Typography,
alpha,
useTheme,
Collapse,
IconButton,
} from "@mui/material";
import LocationOnRounded from "@mui/icons-material/LocationOnRounded";
import TimelineRounded from "@mui/icons-material/TimelineRounded";
import SensorsRounded from "@mui/icons-material/SensorsRounded";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import {
useChatToolStore,
type ChatToolAction,
} from "@/store/chatToolStore";
import type { ToolCall } from "./chatMessageSections";
import {
APPLY_LAYER_STYLE_TOOL,
describeApplyLayerStyle,
parseApplyLayerStylePayload,
} from "./toolCallStyleHelpers";
/* ------------------------------------------------------------------ */
/* Interactive card rendered inside a chat bubble for tool actions */
@@ -45,6 +54,26 @@ const LOCATE_TOOL_TO_LAYER: Record<string, string> = {
};
const LOCATE_LINE_TOOLS = new Set<string>(["locate_pipes"]);
const LOCATE_ID_PARAM_KEYS = [
"ids",
"id",
"feature_ids",
"feature_id",
"node_ids",
"node_id",
"junction_ids",
"junction_id",
"pipe_ids",
"pipe_id",
"valve_ids",
"valve_id",
"reservoir_ids",
"reservoir_id",
"pump_ids",
"pump_id",
"tank_ids",
"tank_id",
] as const;
const TOOL_META: Record<string, ToolMeta> = {
locate_features: {
@@ -89,6 +118,12 @@ const TOOL_META: Record<string, ToolMeta> = {
actionLabel: "定位到地图",
color: "#3ba272",
},
zoom_to_map: {
label: "缩放到坐标",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "缩放到地图",
color: "#0ea5e9",
},
view_history: {
label: "查看计算结果",
icon: <TimelineRounded sx={{ fontSize: 18 }} />,
@@ -107,25 +142,88 @@ const TOOL_META: Record<string, ToolMeta> = {
actionLabel: "显示",
color: "#73c0de",
},
render_junctions: {
label: "渲染节点",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "应用渲染",
color: "#3b82f6",
},
[APPLY_LAYER_STYLE_TOOL]: {
label: "图层样式",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "应用样式",
color: "#14b8a6",
},
};
/* ---------- helpers ---------- */
function getToolDescription(toolCall: ToolCall): string {
const { params } = toolCall;
const normalizeIds = (): string[] => {
const rawIds = params.ids;
if (Array.isArray(rawIds)) {
return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0);
function normalizeLocateIds(params: Record<string, unknown>): string[] {
for (const key of LOCATE_ID_PARAM_KEYS) {
const rawValue = params[key];
if (Array.isArray(rawValue)) {
const normalized = rawValue
.map((id) => String(id).trim())
.filter(Boolean);
if (normalized.length > 0) {
return normalized;
}
}
if (typeof rawIds === "string") {
return rawIds
if (typeof rawValue === "string" || typeof rawValue === "number") {
const normalized = String(rawValue)
.split(",")
.map((id) => id.trim())
.filter(Boolean);
if (normalized.length > 0) {
return normalized;
}
}
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 {
const { params } = toolCall;
const resolveScadaFeatureInfos = (): [string, string][] => {
const rawFeatureInfos = params.feature_infos;
if (Array.isArray(rawFeatureInfos)) {
@@ -189,7 +287,7 @@ function getToolDescription(toolCall: ToolCall): string {
case "locate_reservoirs":
case "locate_pumps":
case "locate_tanks": {
const ids = normalizeIds();
const ids = normalizeLocateIds(params);
const idsText =
ids.length > 3
? `${ids.slice(0, 3).join(", ")}${ids.length}`
@@ -226,6 +324,21 @@ function getToolDescription(toolCall: ToolCall): string {
case "show_chart": {
return (params.title as string | undefined) ?? "数据图表";
}
case "render_junctions": {
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: {
const payload = parseApplyLayerStylePayload(params);
return payload ? describeApplyLayerStyle(payload) : "图层样式";
}
default:
return "";
}
@@ -233,19 +346,6 @@ function getToolDescription(toolCall: ToolCall): string {
function buildAction(toolCall: ToolCall): ChatToolAction | null {
const { params } = toolCall;
const normalizeIds = (): string[] => {
const rawIds = params.ids;
if (Array.isArray(rawIds)) {
return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0);
}
if (typeof rawIds === "string") {
return rawIds
.split(",")
.map((id) => id.trim())
.filter(Boolean);
}
return [];
};
const resolveScadaFeatureInfos = (): [string, string][] => {
const rawFeatureInfos = params.feature_infos;
if (Array.isArray(rawFeatureInfos)) {
@@ -295,6 +395,8 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
(params.end as string | undefined),
});
switch (toolCall.tool) {
case "zoom_to_map":
return buildZoomTo3857Action(params);
case "locate_features": {
const featureTypeRaw = params.feature_type;
const featureType =
@@ -302,13 +404,13 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
? featureTypeRaw.trim().toLowerCase()
: "";
const config = locateFeatureTypeToConfig(featureType);
if (!config) return null;
return {
type: "locate_features",
ids: normalizeIds(),
layer: config.layer,
geometryKind: config.geometryKind,
};
if (!config) return null;
return {
type: "locate_features",
ids: normalizeLocateIds(params),
layer: config.layer,
geometryKind: config.geometryKind,
};
}
case "locate_junctions":
case "locate_pipes":
@@ -320,7 +422,7 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
if (!layer) return null;
return {
type: "locate_features",
ids: normalizeIds(),
ids: normalizeLocateIds(params),
layer,
geometryKind: LOCATE_LINE_TOOLS.has(toolCall.tool) ? "line" : "point",
};
@@ -361,6 +463,29 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
xAxisName: params.x_axis_name as string | undefined,
yAxisName: params.y_axis_name as string | undefined,
};
case "render_junctions": {
const renderRef =
typeof params.render_ref === "string" ? params.render_ref.trim() : "";
if (!renderRef) {
return null;
}
return {
type: "render_junctions",
renderRef,
};
}
case APPLY_LAYER_STYLE_TOOL: {
const payload = parseApplyLayerStylePayload(params);
if (!payload) {
return null;
}
return {
type: "apply_layer_style",
layerId: payload.layerId,
resetToDefault: payload.resetToDefault,
styleConfig: payload.styleConfig,
};
}
default:
return null;
}
@@ -378,12 +503,13 @@ export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({
const theme = useTheme();
const dispatch = useChatToolStore((s) => s.dispatch);
const [executed, setExecuted] = useState(false);
const [expanded, setExpanded] = useState(false);
const meta: ToolMeta = TOOL_META[toolCall.tool] ?? {
label: toolCall.tool,
icon: null,
icon: <TimelineRounded sx={{ fontSize: 18 }} />,
actionLabel: "执行",
color: theme.palette.primary.main,
color: "#00acc1",
};
const description = getToolDescription(toolCall);
@@ -400,97 +526,143 @@ export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({
<Paper
elevation={0}
sx={{
mt: 1.5,
mt: 1,
mb: 1,
p: 1.5,
borderRadius: 3,
border: `1px solid ${alpha(meta.color, 0.25)}`,
bgcolor: alpha(meta.color, 0.04),
overflow: "hidden",
borderRadius: 4,
border: `1px solid ${alpha(meta.color, 0.3)}`,
bgcolor: alpha(meta.color, 0.05),
backdropFilter: "blur(12px)",
transition: "all 0.3s ease",
"&:hover": {
bgcolor: alpha(meta.color, 0.08),
border: `1px solid ${alpha(meta.color, 0.4)}`,
}
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Box
onClick={() => setExpanded(!expanded)}
sx={{
p: 1.5,
display: "flex",
alignItems: "center",
cursor: "pointer",
gap: 1.5,
}}
>
{/* Icon */}
<Box
sx={{
width: 32,
height: 32,
borderRadius: 2,
bgcolor: alpha(meta.color, 0.12),
borderRadius: "50%",
bgcolor: alpha(meta.color, 0.15),
display: "flex",
alignItems: "center",
justifyContent: "center",
color: meta.color,
flexShrink: 0,
boxShadow: `0 2px 8px ${alpha(meta.color, 0.2)}`,
}}
>
{meta.icon}
</Box>
{/* Description */}
<Box sx={{ flex: 1, minWidth: 0 }}>
{/* Title */}
<Box sx={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 1 }}>
<Typography
variant="caption"
variant="body2"
sx={{
fontWeight: 600,
fontWeight: 700,
color: "text.primary",
display: "block",
}}
>
{meta.label}
</Typography>
{description && (
<Typography
variant="caption"
sx={{
color: "text.secondary",
fontSize: "0.75rem",
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{description}
</Typography>
{!expanded && description && (
<Typography
variant="caption"
sx={{
color: "text.secondary",
fontSize: "0.75rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 180,
opacity: 0.8,
}}
>
{description}
</Typography>
)}
</Box>
{/* Action */}
{executed ? (
<Chip
icon={<CheckCircleRounded sx={{ fontSize: 16 }} />}
label="已执行"
size="small"
sx={{
bgcolor: alpha("#4caf50", 0.1),
color: "#4caf50",
fontWeight: 600,
fontSize: "0.75rem",
}}
/>
) : (
<Button
size="small"
variant="outlined"
onClick={handleExecute}
sx={{
borderColor: alpha(meta.color, 0.4),
color: meta.color,
fontWeight: 600,
fontSize: "0.75rem",
borderRadius: 2,
textTransform: "none",
whiteSpace: "nowrap",
"&:hover": {
borderColor: meta.color,
bgcolor: alpha(meta.color, 0.08),
},
}}
>
{meta.actionLabel}
</Button>
)}
</Stack>
<IconButton size="small" sx={{ color: "text.secondary", width: 28, height: 28, pointerEvents: "none" }}>
{expanded ? <KeyboardArrowUpRounded fontSize="small" /> : <KeyboardArrowDownRounded fontSize="small" />}
</IconButton>
</Box>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Box sx={{ px: 1.5, pb: 1.5, pt: 0 }}>
<Stack direction="column" spacing={1.5}>
{description && (
<Box sx={{
p: 1.5,
borderRadius: 3,
bgcolor: alpha("#000", 0.03),
border: `1px solid ${alpha("#000", 0.05)}`,
}}>
<Typography variant="caption" color="text.secondary" fontWeight={700} sx={{ mb: 0.5, display: 'block' }}>
</Typography>
<Typography variant="body2" color="text.primary" sx={{ wordBreak: 'break-word', fontFamily: 'monospace', fontSize: '0.8rem' }}>
{description}
</Typography>
</Box>
)}
<Stack direction="row" justifyContent="flex-end">
{executed ? (
<Chip
icon={<CheckCircleRounded sx={{ fontSize: 16 }} />}
label="已执行"
size="small"
sx={{
bgcolor: alpha("#00e676", 0.15),
color: "#00c853",
fontWeight: 700,
fontSize: "0.75rem",
}}
/>
) : (
<Button
size="small"
variant="contained"
disableElevation
onClick={(e) => { e.stopPropagation(); handleExecute(); }}
sx={{
bgcolor: meta.color,
color: "#fff",
fontWeight: 700,
fontSize: "0.8rem",
borderRadius: 2.5,
px: 2,
textTransform: "none",
boxShadow: `0 4px 12px ${alpha(meta.color, 0.3)}`,
"&:hover": {
bgcolor: meta.color,
filter: "brightness(0.9)",
boxShadow: `0 6px 16px ${alpha(meta.color, 0.4)}`,
},
}}
>
{meta.actionLabel}
</Button>
)}
</Stack>
</Stack>
</Box>
</Collapse>
</Paper>
);
};
+1 -351
View File
@@ -1,35 +1,8 @@
"use client";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { motion } from "framer-motion";
import {
Avatar,
Box,
IconButton,
Paper,
Stack,
Typography,
alpha,
} from "@mui/material";
import type { Theme } from "@mui/material/styles";
import AutoAwesome from "@mui/icons-material/AutoAwesome";
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
import PauseRounded from "@mui/icons-material/PauseRounded";
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
import StopRounded from "@mui/icons-material/StopRounded";
import {
parseAssistantMessageSections,
parseContentWithToolCalls,
type ContentSegment,
} from "./chatMessageSections";
import { ChatInlineChart } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock";
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
import type { Message, SpeechState } from "./GlobalChatbox.types";
import { stripMarkdown } from "./GlobalChatbox.utils";
import { Box, Stack } from "@mui/material";
export const TypingIndicator = () => {
return (
@@ -101,326 +74,3 @@ export const Blob = ({
}}
/>
);
type ChatMessageItemProps = {
message: Message;
theme: Theme;
messageSpeechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPause: () => void;
onResume: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
sseChartParams?: Array<{ tool: string; params: Record<string, unknown> }>;
};
export const ChatMessageItem = React.memo(
({
message,
theme,
messageSpeechState,
onSpeak,
onPause,
onResume,
onStopSpeech,
isTtsSupported,
sseChartParams,
}: ChatMessageItemProps) => {
const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError);
const parsedAssistantSections =
!isUser && !isErrorMessage
? parseAssistantMessageSections(message.content)
: null;
const answerContent = parsedAssistantSections?.answer ?? message.content;
const contentSegments: ContentSegment[] =
!isUser && !isErrorMessage
? parseContentWithToolCalls(answerContent).segments
: [{ type: "text", content: answerContent }];
return (
<motion.div
initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 350, damping: 25 }}
style={{
alignSelf: isUser ? "flex-end" : "flex-start",
maxWidth: "85%",
display: "flex",
flexDirection: isUser ? "row-reverse" : "row",
gap: 12,
alignItems: "flex-end",
}}
>
{!isUser && (
<Avatar
sx={{
width: 28,
height: 28,
bgcolor: isErrorMessage
? alpha(theme.palette.error.main, 0.12)
: alpha(theme.palette.secondary.main, 0.1),
mb: 0.5,
}}
>
{isErrorMessage ? (
<ErrorOutlineRounded sx={{ fontSize: 16, color: "error.main" }} />
) : (
<AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} />
)}
</Avatar>
)}
<Box>
<Paper
elevation={isUser ? 8 : isErrorMessage ? 1 : 2}
sx={{
p: 2.5,
borderRadius: 4,
borderBottomRightRadius: isUser ? 4 : 24,
borderBottomLeftRadius: !isUser ? 4 : 24,
bgcolor: isUser
? "primary.main"
: isErrorMessage
? alpha(theme.palette.error.light, 0.18)
: "#fff",
color: isUser ? "#fff" : isErrorMessage ? "error.dark" : "text.primary",
background: isUser
? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`
: isErrorMessage
? `linear-gradient(135deg, ${alpha(theme.palette.error.light, 0.28)}, ${alpha(theme.palette.error.main, 0.12)})`
: undefined,
border: isErrorMessage
? `1px solid ${alpha(theme.palette.error.main, 0.35)}`
: "none",
boxShadow: isUser
? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}`
: isErrorMessage
? `0 4px 16px -4px ${alpha(theme.palette.error.main, 0.2)}`
: `0 4px 16px -4px ${alpha("#000", 0.05)}`,
"--chat-md-text": isUser
? alpha("#fff", 0.96)
: isErrorMessage
? theme.palette.error.dark
: "#1f2937",
"--chat-md-heading": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#111827",
"--chat-md-link": isUser
? "#E3F2FD"
: isErrorMessage
? theme.palette.error.main
: "#7C3AED",
"--chat-md-link-hover": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#6D28D9",
"--chat-md-inline-code-bg": isUser
? "rgba(255,255,255,0.2)"
: isErrorMessage
? alpha(theme.palette.error.main, 0.08)
: "#EEF2FF",
"--chat-md-inline-code-border": isUser
? alpha("#fff", 0.16)
: isErrorMessage
? alpha(theme.palette.error.main, 0.25)
: "#CBD5E1",
"--chat-md-inline-code-text": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#334155",
"--chat-md-pre-bg": isUser
? "rgba(11, 18, 32, 0.56)"
: isErrorMessage
? alpha(theme.palette.error.main, 0.08)
: "#111827",
"--chat-md-pre-border": isUser
? alpha("#fff", 0.12)
: isErrorMessage
? alpha(theme.palette.error.main, 0.3)
: "#64748B",
"--chat-md-pre-text": isUser
? "#F8FAFC"
: isErrorMessage
? theme.palette.error.dark
: "#E5E7EB",
"--chat-md-quote-border": isErrorMessage
? alpha(theme.palette.error.main, 0.5)
: isUser
? alpha("#fff", 0.5)
: "#7C3AED",
"--chat-md-quote-bg": isUser
? alpha("#fff", 0.08)
: isErrorMessage
? alpha(theme.palette.error.main, 0.06)
: "#F5F3FF",
"--chat-md-quote-text": isUser
? alpha("#fff", 0.9)
: isErrorMessage
? theme.palette.error.dark
: "#475569",
}}
>
{contentSegments.map((segment, segIdx) => {
if (segment.type === "text") {
const text = segment.content.trim();
if (!text && contentSegments.length > 1) return null;
return (
<div key={segIdx} className={markdownStyles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{text || "..."}
</ReactMarkdown>
</div>
);
}
if (segment.type === "tool_call") {
if (segment.toolCall.tool === "chart") {
return (
<ChatInlineChart
key={segment.toolCall.id}
{...(segment.toolCall.params as Record<string, unknown>)}
/>
);
}
if (segment.toolCall.tool === "show_chart") {
const p = segment.toolCall.params;
return (
<ChatInlineChart
key={segment.toolCall.id}
title={(p.title as string) ?? undefined}
chart_type={
(p.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={(p.x_data as string[]) ?? []}
series={
(p.series as import("./ChatInlineChart").ChatChartSeries[]) ??
[]
}
x_axis_name={(p.x_axis_name as string) ?? undefined}
y_axis_name={(p.y_axis_name as string) ?? undefined}
/>
);
}
return (
<ChatToolCallBlock
key={segment.toolCall.id}
toolCall={segment.toolCall}
/>
);
}
if (segment.type === "tool_call_pending") {
return (
<motion.div
key="tool-pending"
initial={{ opacity: 0 }}
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: "easeInOut",
}}
style={{
marginTop: 8,
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<AutoAwesome sx={{ fontSize: 14, color: "primary.main" }} />
<Typography variant="caption" color="text.secondary">
...
</Typography>
</motion.div>
);
}
return null;
})}
{sseChartParams?.map((chart, idx) => (
<ChatInlineChart
key={`sse-chart-${idx}`}
title={(chart.params.title as string) ?? undefined}
chart_type={
(chart.params.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={(chart.params.x_data as string[]) ?? []}
series={
(chart.params.series as import("./ChatInlineChart").ChatChartSeries[]) ??
[]
}
x_axis_name={(chart.params.x_axis_name as string) ?? undefined}
y_axis_name={(chart.params.y_axis_name as string) ?? undefined}
/>
))}
</Paper>
{!isUser && !isErrorMessage && isTtsSupported && (
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 0.5 }}>
{messageSpeechState === "idle" && (
<IconButton
size="small"
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
aria-label="朗读消息"
sx={{
color: "text.secondary",
opacity: 0.6,
"&:hover": { opacity: 1 },
p: 0.5,
}}
>
<VolumeUpRounded sx={{ fontSize: 16 }} />
</IconButton>
)}
{messageSpeechState === "playing" && (
<>
<IconButton
size="small"
onClick={onPause}
aria-label="暂停朗读"
sx={{ color: "primary.main", p: 0.5 }}
>
<PauseRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
onClick={onStopSpeech}
aria-label="停止朗读"
sx={{ color: "error.main", p: 0.5 }}
>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
)}
{messageSpeechState === "paused" && (
<>
<IconButton
size="small"
onClick={onResume}
aria-label="继续朗读"
sx={{ color: "primary.main", p: 0.5 }}
>
<PlayArrowRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
onClick={onStopSpeech}
aria-label="停止朗读"
sx={{ color: "error.main", p: 0.5 }}
>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
)}
</Stack>
)}
</Box>
</motion.div>
);
},
);
ChatMessageItem.displayName = "ChatMessageItem";
File diff suppressed because it is too large Load Diff
+76 -3
View File
@@ -1,8 +1,68 @@
import type {
AgentQuestionRequest,
AgentTodoUpdate,
} from "@/lib/chatStream";
export type ChatProgress = {
id: string;
phase: string;
status: "running" | "completed" | "error";
title: string;
detail?: string;
startedAt?: number;
endedAt?: number;
elapsedMs?: number;
elapsedSnapshotAt?: number;
durationMs?: number;
};
export type AgentArtifactKind = "chart" | "map" | "panel" | "tool";
export type AgentArtifact = {
id: string;
tool: string;
kind: AgentArtifactKind;
title: string;
description?: string;
params: Record<string, unknown>;
};
export type AgentPermissionStatus =
| "pending"
| "submitting"
| "approved_once"
| "approved_always"
| "rejected"
| "aborted"
| "error";
export type AgentPermissionRequest = {
requestId: string;
sessionId: string;
permission: string;
patterns: string[];
target?: string;
always: string[];
tool?: {
messageID: string;
callID: string;
};
createdAt: number;
repliedAt?: number;
status: AgentPermissionStatus;
error?: string;
};
export type Message = {
id: string;
role: "user" | "assistant";
content: string;
isError?: boolean;
progress?: ChatProgress[];
artifacts?: AgentArtifact[];
permissions?: AgentPermissionRequest[];
questions?: AgentQuestionRequest[];
todos?: AgentTodoUpdate;
};
export type Props = {
@@ -12,7 +72,20 @@ export type Props = {
export type SpeechState = "idle" | "playing" | "paused";
export type PersistedChatState = {
messages: Message[];
conversationId?: string;
export type ChatSessionSummary = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
isStreaming?: boolean;
runStatus?: string;
};
export type LoadedChatState = {
sessionId?: string;
title?: string;
isTitleManuallyEdited?: boolean;
messages: Message[];
isStreaming?: boolean;
runStatus?: string;
};
@@ -0,0 +1,35 @@
import { cloneMessage } from "./GlobalChatbox.utils";
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([]);
});
});
+69 -31
View File
@@ -1,21 +1,20 @@
import type { PersistedChatState } from "./GlobalChatbox.types";
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 CHAT_STORAGE_KEY = "tjwater_copilot_chat_state_v1";
const THINK_TAG_ALIAS_PATTERN =
/<\s*(\/?)\s*(thinking|reasoning|thought)\b[^>]*>/gi;
export const PRESET_PROMPTS = [
"分析当前管网中的水力瓶颈管道,并给出改造建议。",
"供水服务分区分析。",
"帮我分析当前管网压力异常点,并按风险等级排序。",
"帮我生成一份今日运行简报,包含问题、原因和建议。",
"查询关键 SCADA 点位最近 24 小时的异常波动。",
"排查当前管网爆管风险,并说明优先处置建议。",
];
export const normalizeThoughtTagToken = (token: string): string =>
token.replace(THINK_TAG_ALIAS_PATTERN, (_, closingSlash: string) =>
closingSlash ? "</think>" : "<think>",
);
export const stripMarkdown = (md: string): string =>
md
.replace(/```[\s\S]*?```/g, "")
@@ -34,26 +33,65 @@ export const stripMarkdown = (md: string): string =>
.replace(/<[^>]+>/g, "")
.trim();
export const getInitialChatState = (): PersistedChatState => {
if (typeof window === "undefined") {
return { messages: [], conversationId: undefined };
}
try {
const storedRaw = window.localStorage.getItem(CHAT_STORAGE_KEY);
if (!storedRaw) return { messages: [], conversationId: undefined };
const parsed = JSON.parse(storedRaw) as PersistedChatState;
if (!Array.isArray(parsed.messages)) {
console.error("[GlobalChatbox] Invalid persisted messages format.");
window.localStorage.removeItem(CHAT_STORAGE_KEY);
return { messages: [], conversationId: undefined };
}
return { messages: parsed.messages, conversationId: parsed.conversationId };
} catch (error) {
console.error(
"[GlobalChatbox] Failed to read persisted chat state:",
error,
);
window.localStorage.removeItem(CHAT_STORAGE_KEY);
return { messages: [], conversationId: undefined };
}
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);
+70
View File
@@ -0,0 +1,70 @@
import {
createEmptyChatState,
saveActiveChatState,
} from "./chatStorage";
const apiFetch = jest.fn();
jest.mock("@/lib/apiFetch", () => ({
apiFetch: (...args: unknown[]) => apiFetch(...args),
}));
describe("chatStorage backend-only persistence", () => {
beforeEach(() => {
apiFetch.mockReset();
});
it("creates an empty initial conversation state without backend calls", () => {
const loaded = createEmptyChatState();
expect(loaded).toMatchObject({
title: undefined,
messages: [],
sessionId: undefined,
});
expect(apiFetch).not.toHaveBeenCalled();
});
it("creates a backend conversation when saving the first non-empty state", async () => {
apiFetch.mockImplementation(async (url: string, init?: RequestInit) => {
if (url.endsWith("/api/v1/agent/chat/session")) {
expect(init?.method).toBe("POST");
return {
ok: true,
json: async () => ({ session_id: "chat-new-1" }),
} as Response;
}
if (url.endsWith("/api/v1/agent/chat/session/chat-new-1")) {
expect(init?.method).toBe("PUT");
expect(JSON.parse(String(init?.body))).toMatchObject({
title: "新对话",
is_title_manually_edited: false,
});
return {
ok: true,
json: async () => ({ id: "chat-new-1", session_id: "chat-new-1" }),
} as Response;
}
throw new Error(`Unexpected request ${url}`);
});
const savedSessionId = await saveActiveChatState(
{
title: "新对话",
isTitleManuallyEdited: false,
messages: [
{
id: "message-2",
role: "user",
content: "第一条消息",
},
],
sessionId: undefined,
},
);
expect(savedSessionId).toBe("chat-new-1");
});
});
+275
View File
@@ -0,0 +1,275 @@
import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config";
import type {
ChatSessionSummary,
LoadedChatState,
Message,
} from "./GlobalChatbox.types";
import { cloneMessages } from "./GlobalChatbox.utils";
type BackendSessionPayload = {
id?: string;
title?: string;
created_at?: string | number;
updated_at?: string | number;
is_streaming?: boolean;
run_status?: string;
};
export const createEmptyChatState = (): LoadedChatState => ({
title: undefined,
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
});
const sanitizeMessages = (messages: Message[] | undefined) =>
Array.isArray(messages) ? cloneMessages(messages) : [];
const hasChatContent = (state: {
messages: Message[];
sessionId?: string;
}) =>
state.messages.length > 0 ||
Boolean(state.sessionId);
const compareSessionsByAnchorTime = (
left: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
right: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
) => {
const createdAtDiff = right.createdAt - left.createdAt;
if (createdAtDiff !== 0) return createdAtDiff;
const updatedAtDiff = right.updatedAt - left.updatedAt;
if (updatedAtDiff !== 0) return updatedAtDiff;
return right.id.localeCompare(left.id);
};
const toMillis = (value: string | number | undefined) =>
typeof value === "number" ? value : value ? new Date(value).getTime() : Date.now();
const normalizeTitle = (value?: string) => value?.trim() || "新对话";
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
method: "GET",
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
});
if (!response.ok) {
throw new Error(await response.text());
}
const payload = (await response.json()) as {
sessions?: BackendSessionPayload[];
};
return (payload.sessions ?? [])
.map((session) => ({
id: session.id ?? "",
title: normalizeTitle(session.title),
createdAt: toMillis(session.created_at),
updatedAt: toMillis(session.updated_at),
isStreaming: session.is_streaming,
runStatus: session.run_status,
}))
.filter((session) => Boolean(session.id))
.sort(compareSessionsByAnchorTime);
};
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
{
method: "GET",
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
if (response.status === 404) {
return createEmptyChatState();
}
throw new Error(await response.text());
}
const payload = (await response.json()) as {
id: string;
title?: string;
is_title_manually_edited?: boolean;
session_id?: string;
messages?: Message[];
is_streaming?: boolean;
run_status?: string;
};
return {
title: normalizeTitle(payload.title),
isTitleManuallyEdited: payload.is_title_manually_edited ?? false,
messages: sanitizeMessages(payload.messages),
sessionId: payload.session_id ?? payload.id,
isStreaming: payload.is_streaming ?? false,
runStatus: payload.run_status,
};
};
const createBackendChatSession = async (payload?: {
sessionId?: string;
parentSessionId?: string;
}) => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/session`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: payload?.sessionId,
parent_session_id: payload?.parentSessionId,
}),
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
});
if (!response.ok) {
throw new Error(await response.text());
}
const body = (await response.json()) as {
session_id?: string;
};
const sessionId = body.session_id?.trim();
if (!sessionId) {
throw new Error("backend did not return session_id");
}
return sessionId;
};
const saveBackendChatState = async (
sessionId: string,
state: LoadedChatState,
): Promise<string> => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: normalizeTitle(state.title),
is_title_manually_edited: state.isTitleManuallyEdited ?? false,
messages: sanitizeMessages(state.messages),
}),
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
throw new Error(await response.text());
}
const payload = (await response.json()) as { id?: string; session_id?: string };
return payload.id ?? payload.session_id ?? sessionId;
};
const updateBackendChatSessionTitle = async (
sessionId: string,
title: string,
isTitleManuallyEdited?: boolean,
) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/title`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
is_title_manually_edited: isTitleManuallyEdited,
}),
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
throw new Error(await response.text());
}
};
const deleteBackendChatSession = async (sessionId: string) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
{
method: "DELETE",
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok && response.status !== 404) {
throw new Error(await response.text());
}
};
export const saveActiveChatState = async (
state: LoadedChatState,
): Promise<string | undefined> => {
if (typeof window === "undefined") return state.sessionId;
if (!hasChatContent(state)) {
return undefined;
}
let backendSessionId = state.sessionId;
if (!backendSessionId) {
backendSessionId = await createBackendChatSession();
}
const savedSessionId = await saveBackendChatState(backendSessionId, {
...state,
sessionId: backendSessionId,
});
return savedSessionId;
};
export const listChatSessions = async (): Promise<ChatSessionSummary[]> => {
if (typeof window === "undefined") return [];
return await fetchBackendChatSessions();
};
export const updateChatSessionTitle = async (
sessionId: string,
title: string,
options?: {
isTitleManuallyEdited?: boolean;
},
): Promise<void> => {
if (typeof window === "undefined") return;
const normalizedTitle = title.trim();
if (!normalizedTitle) return;
await updateBackendChatSessionTitle(
sessionId,
normalizedTitle,
options?.isTitleManuallyEdited,
);
};
export const loadChatSessionById = async (
sessionId: string,
): Promise<LoadedChatState> => {
if (typeof window === "undefined") return createEmptyChatState();
return await fetchBackendChatSession(sessionId);
};
export const deleteChatSession = async (
sessionId: string,
): Promise<string | undefined> => {
if (typeof window === "undefined") return undefined;
await deleteBackendChatSession(sessionId);
const nextActiveSession = (await listChatSessions())[0];
return nextActiveSession?.id;
};
@@ -0,0 +1,456 @@
import type {
AgentQuestionRequest,
AgentTodoUpdate,
PermissionReply,
StreamEvent,
} from "@/lib/chatStream";
import type {
AgentPermissionRequest,
ChatProgress,
LoadedChatState,
Message,
} from "../GlobalChatbox.types";
import { createId } from "../GlobalChatbox.utils";
export const createPersistedStateKey = (state: LoadedChatState) =>
JSON.stringify({
title: state.title ?? null,
isTitleManuallyEdited: state.isTitleManuallyEdited ?? false,
sessionId: state.sessionId ?? null,
messages: state.messages,
});
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,401 @@
"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 saveActiveChatState = 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",
})),
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
}));
describe("useAgentChatSession", () => {
beforeEach(() => {
listChatSessions.mockReset();
deleteChatSession.mockReset();
saveActiveChatState.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);
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
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);
});
});
});
@@ -0,0 +1,791 @@
"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 saveActiveChatState = 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",
})),
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
}));
describe("useAgentChatSession", () => {
beforeEach(() => {
listChatSessions.mockReset();
deleteChatSession.mockReset();
saveActiveChatState.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);
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
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 () => {
listChatSessions.mockResolvedValue([]);
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
act(() => {
void result.current.createSession();
});
await waitFor(() => expect(result.current.sessionTitle).toBe("新对话"));
expect(result.current.chatSessions).toEqual([]);
expect(result.current.activeSessionId).toBeUndefined();
expect(result.current.messages).toEqual([]);
expect(result.current.isStreaming).toBe(false);
expect(listChatSessions).toHaveBeenCalledTimes(1);
});
it("keeps existing history entries when creating a blank new session", async () => {
listChatSessions.mockResolvedValue([
{
id: "session-1",
title: "已有会话",
createdAt: 1,
updatedAt: 1,
},
]);
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
act(() => {
void result.current.createSession();
});
expect(result.current.chatSessions).toEqual([
{
id: "session-1",
title: "已有会话",
createdAt: 1,
updatedAt: 1,
},
]);
});
it("removes a deleted history entry before the backend delete finishes", async () => {
const initialSessions = [
{
id: "session-1",
title: "第一段会话",
createdAt: 2,
updatedAt: 2,
},
{
id: "session-2",
title: "第二段会话",
createdAt: 1,
updatedAt: 1,
},
];
let resolveDelete: ((nextActiveSessionId?: string) => void) | undefined;
listChatSessions.mockResolvedValue(initialSessions);
deleteChatSession.mockImplementationOnce(
() =>
new Promise<string | undefined>((resolve) => {
resolveDelete = resolve;
}),
);
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
act(() => {
void result.current.removeSession("session-2");
});
expect(result.current.chatSessions).toEqual([
expect.objectContaining({ id: "session-1" }),
]);
listChatSessions.mockResolvedValue([
{
id: "session-1",
title: "第一段会话",
createdAt: 2,
updatedAt: 2,
},
]);
await act(async () => {
resolveDelete?.();
await Promise.resolve();
});
await waitFor(() =>
expect(result.current.chatSessions).toEqual([
expect.objectContaining({ id: "session-1" }),
]),
);
});
it("persists a new conversation only after the stream is done", 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));
jest.useFakeTimers();
try {
await act(async () => {
void result.current.sendPrompt("第一条消息");
await Promise.resolve();
});
expect(result.current.isStreaming).toBe(true);
await act(async () => {
jest.advanceTimersByTime(200);
});
expect(saveActiveChatState).not.toHaveBeenCalled();
act(() => {
emitStreamEvent?.({
type: "token",
sessionId: "chat-stream-1",
content: "收到",
});
});
await act(async () => {
jest.advanceTimersByTime(200);
});
expect(saveActiveChatState).not.toHaveBeenCalled();
act(() => {
emitStreamEvent?.({
type: "done",
sessionId: "chat-stream-1",
});
});
await act(async () => {
jest.advanceTimersByTime(200);
});
await waitFor(() => expect(saveActiveChatState).toHaveBeenCalledTimes(1));
expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({
sessionId: "chat-stream-1",
messages: [
expect.objectContaining({ role: "user", content: "第一条消息" }),
expect.objectContaining({ role: "assistant", content: "收到" }),
],
});
} finally {
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 () => {
listChatSessions.mockResolvedValue([
{
id: "session-streaming",
title: "运行中",
createdAt: 1,
updatedAt: 2,
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.isStreaming).toBe(true);
expect(result.current.activeSessionId).toBe("session-loaded");
expect(resumeAgentChatStream).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "session-loaded",
}),
);
});
it("updates resumed messages from state, token, and done events", 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: "已有" },
],
isStreaming: true,
runStatus: "running",
});
onEvent({
type: "token",
sessionId: "session-loaded",
content: "输出",
});
onEvent({
type: "done",
sessionId: "session-loaded",
});
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
await waitFor(() => expect(result.current.isStreaming).toBe(false));
expect(result.current.messages).toEqual([
expect.objectContaining({ id: "u1", role: "user", content: "继续分析" }),
expect.objectContaining({ id: "a1", role: "assistant", content: "已有输出" }),
]);
});
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 () => {
listChatSessions.mockResolvedValue([
{
id: "session-streaming",
title: "运行中",
createdAt: 1,
updatedAt: 2,
isStreaming: true,
},
]);
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async () => {
await new Promise<void>(() => undefined);
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isStreaming).toBe(true));
act(() => {
result.current.abort();
});
expect(abortAgentChat).toHaveBeenCalledWith("session-loaded");
});
});
});
@@ -0,0 +1,2 @@
// Tests for useAgentChatSession are split by behavior boundary.
// See useAgentChatSession.lifecycle.test.tsx and useAgentChatSession.actions.test.tsx.
@@ -0,0 +1,949 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
import type { AgentArtifact, ChatSessionSummary, LoadedChatState, Message } from "../GlobalChatbox.types";
import { cloneMessages } from "../GlobalChatbox.utils";
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, saveActiveChatState, updateChatSessionTitle } from "../chatStorage";
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createPersistedStateKey, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
export const useAgentChatSession = ({
projectId,
onToolCall,
onBeforeSend,
getModel,
getApprovalMode,
}: UseAgentChatSessionOptions) => {
const hydrationCompletedRef = useRef(false);
const hydrationNonceRef = useRef(0);
const [messages, setMessages] = useState<Message[]>([]);
const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined);
const [isSessionTitleManuallyEdited, setIsSessionTitleManuallyEdited] = useState(false);
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [isHydrating, setIsHydrating] = useState(true);
const abortRef = useRef<AbortController | null>(null);
const sessionIdRef = useRef<string | undefined>(undefined);
const messagesRef = useRef<Message[]>([]);
const resumeStreamingSessionRef = useRef<((sessionId: string) => void) | null>(null);
const isSessionTitleManuallyEditedRef = useRef(false);
const cancelPromiseRef = useRef<Promise<void> | null>(null);
const titleUpdateNonceRef = useRef(0);
const lastPersistedStateKeyRef = useRef(
createPersistedStateKey({
sessionId: undefined,
title: undefined,
isTitleManuallyEdited: false,
messages: [],
}),
);
useEffect(() => {
sessionIdRef.current = sessionId;
}, [sessionId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => {
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
}, [isSessionTitleManuallyEdited]);
useEffect(() => {
let cancelled = false;
const hydrate = async () => {
setIsHydrating(true);
hydrationCompletedRef.current = false;
if (!projectId) {
sessionIdRef.current = undefined;
lastPersistedStateKeyRef.current = createPersistedStateKey({
title: undefined,
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
});
hydrationCompletedRef.current = true;
hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1;
setMessages([]);
setSessionTitle(undefined);
setIsSessionTitleManuallyEdited(false);
setSessionId(undefined);
setChatSessions([]);
setIsHydrating(false);
return;
}
try {
const sessions = await listChatSessions();
const streamingSession = sessions.find((session) => session.isStreaming);
const loadedState = streamingSession
? await loadChatSessionById(streamingSession.id)
: createEmptyChatState();
if (cancelled) return;
sessionIdRef.current = loadedState.sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey(loadedState);
hydrationCompletedRef.current = true;
hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1;
setMessages(
normalizeSessionTodos(dedupeQuestionsAcrossMessages(loadedState.messages)),
);
setSessionTitle(loadedState.title);
setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false);
setSessionId(loadedState.sessionId);
setChatSessions(sessions);
if (
loadedState.sessionId &&
(loadedState.isStreaming || streamingSession?.isStreaming)
) {
resumeStreamingSessionRef.current?.(loadedState.sessionId);
}
} catch (error) {
console.error("[GlobalChatbox] Failed to hydrate chat state:", error);
} finally {
if (!cancelled) {
setIsHydrating(false);
}
}
};
void hydrate();
return () => {
cancelled = true;
};
}, [projectId]);
useEffect(() => {
if (!projectId || isHydrating || !hydrationCompletedRef.current) return;
const currentHydrationNonce = hydrationNonceRef.current;
const persistTimer = window.setTimeout(() => {
if (isStreaming) {
return;
}
const state: LoadedChatState = {
title: sessionTitle,
isTitleManuallyEdited: isSessionTitleManuallyEdited,
messages,
sessionId,
};
const currentStateKey = createPersistedStateKey(state);
if (currentStateKey === lastPersistedStateKeyRef.current) {
return;
}
void saveActiveChatState(state)
.then((sessionId) => {
if (hydrationNonceRef.current !== currentHydrationNonce) return;
sessionIdRef.current = sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey({
...state,
sessionId,
});
return listChatSessions();
})
.then((sessions) => {
if (!sessions || hydrationNonceRef.current !== currentHydrationNonce) return;
setChatSessions(sessions);
})
.catch((error) => {
console.error("[GlobalChatbox] Failed to persist chat state:", error);
});
}, 150);
return () => {
window.clearTimeout(persistTimer);
};
}, [isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]);
const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => {
setMessages((prev) =>
prev.map((message) =>
message.id === messageId
? {
...message,
artifacts: [...(message.artifacts ?? []), artifact],
}
: message,
),
);
}, []);
const getLastAssistantMessageId = useCallback((fallback?: string) => {
const assistant = [...messagesRef.current]
.reverse()
.find((message) => message.role === "assistant");
return assistant?.id ?? fallback;
}, []);
const applyStreamEvent = useCallback(
(
event: StreamEvent,
options?: {
assistantMessageId?: string;
},
) => {
if (
event.type !== "session_title" &&
"sessionId" in event &&
event.sessionId &&
event.sessionId !== sessionIdRef.current
) {
sessionIdRef.current = event.sessionId;
setSessionId(event.sessionId);
}
if (event.type === "state") {
const nextMessages = normalizeSessionTodos(
dedupeQuestionsAcrossMessages(cloneMessages(event.messages as Message[])),
);
messagesRef.current = nextMessages;
setMessages(nextMessages);
setIsStreaming(event.isStreaming);
return;
}
if (event.type === "session_title") {
const nextTitle = event.title.trim();
if (nextTitle && !isSessionTitleManuallyEditedRef.current) {
const currentSessionId = sessionIdRef.current;
const targetSessionId = event.sessionId || currentSessionId;
if (targetSessionId === currentSessionId) {
setSessionTitle(nextTitle);
lastPersistedStateKeyRef.current = createPersistedStateKey({
sessionId: targetSessionId,
title: nextTitle,
isTitleManuallyEdited: false,
messages: messagesRef.current,
});
}
if (targetSessionId) {
const currentNonce = ++titleUpdateNonceRef.current;
void updateChatSessionTitle(targetSessionId, nextTitle, {
isTitleManuallyEdited: false,
})
.then(() => listChatSessions())
.then((sessions) => {
if (titleUpdateNonceRef.current !== currentNonce) return;
setChatSessions(sessions);
})
.catch((error) => {
console.error("[GlobalChatbox] Failed to persist session title:", error);
});
}
}
return;
}
const assistantMessageId = getLastAssistantMessageId(options?.assistantMessageId);
if (!assistantMessageId) {
return;
}
if (event.type === "token") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
content: message.content + event.content,
isError: false,
}
: message,
),
);
} else if (event.type === "progress") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? { ...message, progress: upsertProgress(message.progress, event) }
: message,
),
);
} else if (event.type === "tool_call") {
onToolCall(event, {
assistantMessageId,
appendArtifact,
});
} else if (event.type === "permission_request") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
permissions: upsertPermission(message.permissions, event),
}
: message,
),
);
} else if (event.type === "permission_response") {
setMessages((prev) =>
prev.map((message) => {
if (message.id !== assistantMessageId || !message.permissions?.length) {
return message;
}
return {
...message,
permissions: message.permissions.map((permission) =>
permission.requestId === event.requestId
? {
...permission,
status: toPermissionStatus(event.reply),
repliedAt: Date.now(),
error: undefined,
}
: permission,
),
};
}),
);
} 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") {
setMessages((prev) =>
prev.map((message) => {
if (message.id !== assistantMessageId) return message;
const completedProgress = completeRunningProgress(message.progress);
if (
message.content.trim().length === 0 &&
!(message.artifacts?.length)
) {
return {
...message,
content:
"Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completedProgress,
};
}
return { ...message, progress: completedProgress };
}),
);
setIsStreaming(false);
} else if (event.type === "error") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
content: message.content || `⚠️ **错误:** ${event.message}`,
isError: true,
progress: completeRunningProgress(message.progress),
todos: cancelRunningTodos(message.todos),
}
: message,
),
);
setIsStreaming(false);
}
},
[appendArtifact, getLastAssistantMessageId, onToolCall],
);
const resumeStreamingSession = useCallback(
(nextSessionId: string) => {
const controller = new AbortController();
abortRef.current?.abort();
abortRef.current = controller;
setIsStreaming(true);
void resumeAgentChatStream({
sessionId: nextSessionId,
signal: controller.signal,
onEvent: (event) => applyStreamEvent(event),
})
.catch((error) => {
if (!controller.signal.aborted) {
console.error("[GlobalChatbox] Failed to resume chat stream:", error);
setIsStreaming(false);
}
})
.finally(() => {
if (abortRef.current === controller) {
abortRef.current = null;
}
});
},
[applyStreamEvent],
);
resumeStreamingSessionRef.current = resumeStreamingSession;
const runPrompt = useCallback(
async ({
prompt: rawPrompt,
sessionIdOverride,
preparedMessages,
userMessage,
assistantMessage,
}: PromptRunOptions) => {
const prompt = rawPrompt.trim();
if (!prompt || isStreaming || isHydrating) return;
await cancelPromiseRef.current?.catch(() => undefined);
onBeforeSend?.();
const nextUserMessage = userMessage ?? createUserMessage(prompt);
const nextAssistantMessage = assistantMessage ?? createAssistantMessage();
const nextMessages =
preparedMessages ??
[...messages, nextUserMessage, nextAssistantMessage];
const clonedNextMessages = cloneMessages(nextMessages);
setIsStreaming(true);
messagesRef.current = clonedNextMessages;
setMessages(clonedNextMessages);
if (sessionIdOverride !== undefined) {
sessionIdRef.current = sessionIdOverride;
setSessionId(sessionIdOverride);
}
const controller = new AbortController();
abortRef.current = controller;
try {
await streamAgentChat({
message: prompt,
sessionId: sessionIdOverride ?? sessionIdRef.current,
model: getModel?.(),
approvalMode: getApprovalMode?.(),
signal: controller.signal,
onEvent: (event) =>
applyStreamEvent(event, {
assistantMessageId: nextAssistantMessage.id,
}),
});
} catch (error) {
if (controller.signal.aborted) {
setMessages((prev) =>
prev
.map((message) =>
message.id === nextAssistantMessage.id
? finalizeAssistantMessageAfterAbort(message)
: message,
)
.filter(
(message) =>
!(
message.id === nextAssistantMessage.id &&
message.role === "assistant" &&
message.content.trim().length === 0 &&
!(message.artifacts?.length) &&
!(message.progress?.length) &&
!message.todos
),
),
);
return;
}
setMessages((prev) =>
prev.map((message) =>
message.id === nextAssistantMessage.id
? {
...message,
content: `⚠️ **错误:** ${String(error)}`,
isError: true,
progress: completeRunningProgress(message.progress),
}
: message,
),
);
setIsStreaming(false);
} finally {
abortRef.current = null;
setIsStreaming(false);
}
},
[
applyStreamEvent,
getApprovalMode,
getModel,
isHydrating,
isStreaming,
messages,
onBeforeSend,
],
);
const abort = useCallback(() => {
const controller = abortRef.current;
controller?.abort();
setIsStreaming(false);
const assistantMessageId = getLastAssistantMessageId();
if (assistantMessageId) {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? finalizeAssistantMessageAfterAbort(message)
: message,
),
);
}
const cancelPromise = abortAgentChat(sessionIdRef.current).catch((error) => {
console.error("[GlobalChatbox] Failed to abort agent session:", error);
});
const trackedCancelPromise = cancelPromise.finally(() => {
if (cancelPromiseRef.current === trackedCancelPromise) {
cancelPromiseRef.current = null;
}
});
cancelPromiseRef.current = trackedCancelPromise;
}, [getLastAssistantMessageId]);
const replyPermission = useCallback(
async (requestId: string, reply: PermissionReply) => {
const target = messagesRef.current
.flatMap((message) => message.permissions ?? [])
.find((permission) => permission.requestId === requestId);
if (!target || target.status === "submitting") {
return;
}
setMessages((prev) =>
prev.map((message) =>
!message.permissions?.some((permission) => permission.requestId === requestId)
? message
: {
...message,
permissions: message.permissions.map((permission) =>
permission.requestId === requestId
? { ...permission, status: "submitting", error: undefined }
: permission,
),
},
),
);
try {
await replyAgentPermission(target.sessionId, requestId, reply);
setMessages((prev) =>
prev.map((message) =>
!message.permissions?.some((permission) => permission.requestId === requestId)
? message
: {
...message,
permissions: message.permissions.map((permission) =>
permission.requestId === requestId
? {
...permission,
status: toPermissionStatus(reply),
repliedAt: Date.now(),
error: undefined,
}
: permission,
),
},
),
);
} catch (error) {
setMessages((prev) =>
prev.map((message) =>
!message.permissions?.some((permission) => permission.requestId === requestId)
? message
: {
...message,
permissions: message.permissions.map((permission) =>
permission.requestId === requestId
? {
...permission,
status: "error",
error: error instanceof Error ? error.message : String(error),
}
: permission,
),
},
),
);
}
},
[],
);
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(() => {
if (isHydrating || isStreaming) return;
const controller = abortRef.current;
controller?.abort();
hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1;
sessionIdRef.current = undefined;
lastPersistedStateKeyRef.current = createPersistedStateKey({
title: "新对话",
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
});
setMessages([]);
setSessionTitle("新对话");
setIsSessionTitleManuallyEdited(false);
setSessionId(undefined);
setIsStreaming(false);
}, [isHydrating, isStreaming]);
const switchSession = useCallback(
async (nextSessionId: string) => {
if (isHydrating || isStreaming || sessionIdRef.current === nextSessionId) {
return;
}
setIsHydrating(true);
try {
const [nextState, sessions] = await Promise.all([
loadChatSessionById(nextSessionId),
listChatSessions(),
]);
hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1;
sessionIdRef.current = nextState.sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
setMessages(nextState.messages);
setSessionTitle(nextState.title);
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
setSessionId(nextState.sessionId);
setChatSessions(sessions);
if (nextState.sessionId && nextState.isStreaming) {
resumeStreamingSession(nextState.sessionId);
} else {
setIsStreaming(false);
}
} catch (error) {
console.error("[GlobalChatbox] Failed to switch chat session:", error);
} finally {
setIsHydrating(false);
}
},
[isHydrating, isStreaming, resumeStreamingSession],
);
const removeSession = useCallback(
async (targetSessionId: string) => {
if (isHydrating || isStreaming) return;
setChatSessions((prev) =>
prev.filter((session) => session.id !== targetSessionId),
);
try {
const nextActiveSessionId = await deleteChatSession(
targetSessionId,
);
const sessions = await listChatSessions();
setChatSessions(sessions);
if (sessionIdRef.current !== targetSessionId) {
return;
}
if (!nextActiveSessionId) {
hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1;
sessionIdRef.current = undefined;
lastPersistedStateKeyRef.current = createPersistedStateKey({
title: undefined,
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
});
setMessages([]);
setSessionTitle(undefined);
setIsSessionTitleManuallyEdited(false);
setSessionId(undefined);
return;
}
setIsHydrating(true);
const [nextState, sessionsAfterDelete] = await Promise.all([
loadChatSessionById(nextActiveSessionId),
listChatSessions(),
]);
hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1;
sessionIdRef.current = nextState.sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
setMessages(nextState.messages);
setSessionTitle(nextState.title);
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
setSessionId(nextState.sessionId);
setChatSessions(sessionsAfterDelete);
} catch (error) {
console.error("[GlobalChatbox] Failed to delete chat session:", error);
try {
setChatSessions(await listChatSessions());
} catch (refreshError) {
console.error("[GlobalChatbox] Failed to refresh chat sessions:", refreshError);
}
} finally {
setIsHydrating(false);
}
},
[isHydrating, isStreaming],
);
const sendPrompt = useCallback(
async (rawPrompt: string) => {
await runPrompt({ prompt: rawPrompt });
},
[runPrompt],
);
const renameSession = useCallback(
async (targetSessionId: string, nextTitle: string) => {
const normalizedTitle = nextTitle.trim();
if (!normalizedTitle || isHydrating) return;
try {
await updateChatSessionTitle(targetSessionId, normalizedTitle, {
isTitleManuallyEdited: true,
});
const sessions = await listChatSessions();
setChatSessions(sessions);
if (sessionIdRef.current === targetSessionId) {
setSessionTitle(normalizedTitle);
setIsSessionTitleManuallyEdited(true);
lastPersistedStateKeyRef.current = createPersistedStateKey({
sessionId: targetSessionId,
title: normalizedTitle,
isTitleManuallyEdited: true,
messages,
});
}
} catch (error) {
console.error("[GlobalChatbox] Failed to rename chat session:", error);
}
},
[isHydrating, messages],
);
const createBranch = useCallback(
async (messageId: string) => {
if (isHydrating || isStreaming) return;
const assistantIndex = messages.findIndex(
(message) => message.id === messageId && message.role === "assistant",
);
if (assistantIndex < 0) return;
const currentSessionId = sessionIdRef.current;
const keepMessageCount = assistantIndex + 1;
const copiedMessages = cloneMessages(messages.slice(0, keepMessageCount));
const forkedSessionId = await forkAgentChat(currentSessionId, keepMessageCount);
sessionIdRef.current = forkedSessionId;
setSessionId(forkedSessionId);
messagesRef.current = copiedMessages;
setMessages(copiedMessages);
setIsSessionTitleManuallyEdited(false);
const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本";
setSessionTitle(forkTitle);
try {
await saveActiveChatState({
title: forkTitle,
isTitleManuallyEdited: false,
messages: copiedMessages,
sessionId: forkedSessionId,
});
setChatSessions(await listChatSessions());
} catch (error) {
console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error);
}
},
[isHydrating, isStreaming, messages, sessionTitle],
);
return {
messages,
chatSessions,
activeSessionId: sessionIdRef.current,
isHydrating,
isStreaming,
sessionTitle,
sessionId,
sendPrompt,
createBranch,
abort,
replyPermission,
replyQuestion,
rejectQuestion,
createSession,
renameSession,
removeSession,
switchSession,
};
};
@@ -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;
getApprovalMode?: () => AgentApprovalMode;
};
export type PromptRunOptions = {
prompt: string;
sessionIdOverride?: string;
preparedMessages?: Message[];
userMessage?: Message;
assistantMessage?: Message;
};
@@ -0,0 +1,363 @@
"use client";
import { useCallback } from "react";
import { useChatToolStore, type ChatToolAction } from "@/store/chatToolStore";
import type { StreamEvent } from "@/lib/chatStream";
import type { AgentArtifact, AgentArtifactKind } from "../GlobalChatbox.types";
import {
APPLY_LAYER_STYLE_TOOL,
describeApplyLayerStyle,
parseApplyLayerStylePayload,
} from "../toolCallStyleHelpers";
type ToolCallEvent = StreamEvent & { type: "tool_call" };
type HandleToolCallOptions = {
assistantMessageId: string;
appendArtifact: (messageId: string, artifact: AgentArtifact) => void;
};
const FEATURE_TYPE_MAP: Record<
string,
{ layer: string; geometryKind: "point" | "line"; label: string }
> = {
junction: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
junctions: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
pipe: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
pipes: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
valve: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
valves: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
reservoir: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
reservoirs: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
pump: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
pumps: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
tank: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
};
const LOCATE_TOOL_CONFIG: Record<
string,
{ layer: string; geometryKind: "point" | "line"; label: string }
> = {
locate_pipes: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
locate_junctions: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
locate_valves: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
locate_reservoirs: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
locate_pumps: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
locate_tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
};
const LOCATE_ID_PARAM_KEYS = [
"ids",
"id",
"feature_ids",
"feature_id",
"node_ids",
"node_id",
"junction_ids",
"junction_id",
"pipe_ids",
"pipe_id",
"valve_ids",
"valve_id",
"reservoir_ids",
"reservoir_id",
"pump_ids",
"pump_id",
"tank_ids",
"tank_id",
] as const;
const normalizeIds = (params: Record<string, unknown>): string[] => {
for (const key of LOCATE_ID_PARAM_KEYS) {
const rawValue = params[key];
if (Array.isArray(rawValue)) {
const normalized = rawValue.map((id) => String(id).trim()).filter(Boolean);
if (normalized.length > 0) {
return normalized;
}
}
if (typeof rawValue === "string" || typeof rawValue === "number") {
const normalized = String(rawValue)
.split(",")
.map((id) => id.trim())
.filter(Boolean);
if (normalized.length > 0) {
return normalized;
}
}
}
return [];
};
const resolveScadaFeatureInfos = (params: Record<string, unknown>): [string, string][] => {
const rawFeatureInfos = params.feature_infos;
if (Array.isArray(rawFeatureInfos)) {
const normalizedFeatureInfos = rawFeatureInfos
.map((item) => (Array.isArray(item) ? item : null))
.filter((item): item is [unknown, unknown] => Boolean(item))
.map(
(item) =>
[String(item[0] ?? ""), String(item[1] ?? "scada")] as [
string,
string,
],
)
.filter(([id]) => id.trim().length > 0);
if (normalizedFeatureInfos.length > 0) {
return normalizedFeatureInfos;
}
}
const rawDeviceIds =
params.device_ids ??
params.deviceId ??
params.device_id ??
params.id ??
params.ids;
const deviceIds = Array.isArray(rawDeviceIds)
? rawDeviceIds.map((id) => String(id))
: typeof rawDeviceIds === "string"
? rawDeviceIds
.split(",")
.map((id) => id.trim())
.filter(Boolean)
: [];
return deviceIds.map((id) => [id, "scada"]);
};
const resolveTimeRange = (params: Record<string, unknown>) => ({
startTime:
(params.start_time as string | undefined) ??
(params.startTime as string | undefined) ??
(params.from as string | undefined) ??
(params.start as string | undefined),
endTime:
(params.end_time as string | undefined) ??
(params.endTime as string | undefined) ??
(params.to as string | undefined) ??
(params.end as string | undefined),
});
const compactNames = (names: string[]) => {
if (!names.length) return "";
return names.length > 3
? `${names.slice(0, 3).join(", ")}${names.length}`
: names.join(", ");
};
const 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;
};
const parseZoomTo3857Action = (
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,
};
};
const buildLocateArtifact = (
tool: string,
params: Record<string, unknown>,
): { artifact: Omit<AgentArtifact, "id" | "params" | "tool">; action: ChatToolAction | null } => {
const ids = normalizeIds(params);
const rawType = params.feature_type;
const featureType =
typeof rawType === "string" ? rawType.trim().toLowerCase() : "";
const config = tool === "locate_features"
? FEATURE_TYPE_MAP[featureType]
: LOCATE_TOOL_CONFIG[tool];
return {
artifact: {
kind: "map",
title: config ? `地图定位${config.label}` : "地图定位",
description: compactNames(ids),
},
action: config
? {
type: "locate_features",
ids,
layer: config.layer,
geometryKind: config.geometryKind,
}
: null,
};
};
const buildToolAction = (
tool: string,
params: Record<string, unknown>,
): { action: ChatToolAction | null; kind: AgentArtifactKind; title: string; description?: string } => {
if (tool === "show_chart") {
return {
action: null,
kind: "chart",
title: (params.title as string | undefined) ?? "生成图表",
description: "已生成可视化图表",
};
}
if (tool === "zoom_to_map") {
const action = parseZoomTo3857Action(params);
return {
action,
kind: "map",
title: "缩放到地图坐标",
description: action
? `${action.coordinate[0]}, ${action.coordinate[1]} (${action.sourceCrs})`
: "地图坐标",
};
}
if (tool === "locate_features" || LOCATE_TOOL_CONFIG[tool]) {
const locate = buildLocateArtifact(tool, params);
return {
action: locate.action,
kind: locate.artifact.kind,
title: locate.artifact.title,
description: locate.artifact.description,
};
}
if (tool === "view_history") {
const featureInfos = (params.feature_infos as [string, string][] | undefined) ?? [];
const { startTime, endTime } = resolveTimeRange(params);
return {
action: {
type: "view_history",
featureInfos,
dataType:
(params.data_type as "realtime" | "scheme" | "none" | undefined) ??
"realtime",
startTime,
endTime,
},
kind: "panel",
title: "打开计算结果曲线",
description: compactNames(featureInfos.map(([id]) => id)),
};
}
if (tool === "view_scada") {
const featureInfos = resolveScadaFeatureInfos(params);
const { startTime, endTime } = resolveTimeRange(params);
return {
action: {
type: "view_scada",
featureInfos,
startTime,
endTime,
},
kind: "panel",
title: "打开 SCADA 数据面板",
description: compactNames(featureInfos.map(([id]) => id)),
};
}
if (tool === "render_junctions") {
const renderRef =
typeof params.render_ref === "string" ? params.render_ref.trim() : "";
return {
action: renderRef
? {
type: "render_junctions",
renderRef,
sessionId: undefined,
}
: null,
kind: "map",
title: "渲染节点分区",
description: renderRef || "渲染引用",
};
}
if (tool === APPLY_LAYER_STYLE_TOOL) {
const payload = parseApplyLayerStylePayload(params);
return {
action: payload
? {
type: "apply_layer_style",
layerId: payload.layerId,
resetToDefault: payload.resetToDefault,
styleConfig: payload.styleConfig,
}
: null,
kind: "map",
title: payload?.resetToDefault ? "重置图层样式" : "应用图层样式",
description: payload ? describeApplyLayerStyle(payload) : "图层样式",
};
}
return {
action: null,
kind: "tool",
title: tool || "工具调用",
description: "Agent 已执行工具动作",
};
};
export const useAgentToolActions = () => {
const dispatchToolAction = useChatToolStore((s) => s.dispatch);
return useCallback(
(event: ToolCallEvent, options: HandleToolCallOptions) => {
const { action, kind, title, description } = buildToolAction(
event.tool,
event.params,
);
const normalizedAction =
action?.type === "render_junctions"
? { ...action, sessionId: event.sessionId }
: action;
options.appendArtifact(options.assistantMessageId, {
id: `${event.tool}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
tool: event.tool,
kind,
title,
description,
params: event.params,
});
if (normalizedAction) {
dispatchToolAction(normalizedAction);
}
},
[dispatchToolAction],
);
};
+150
View File
@@ -0,0 +1,150 @@
import type { StyleConfig, DefaultLayerStyleId } from "@components/olmap/core/Controls/styleEditorTypes";
export type ApplyLayerStyleActionPayload = {
layerId: DefaultLayerStyleId;
resetToDefault: boolean;
styleConfig?: Partial<StyleConfig>;
};
export const APPLY_LAYER_STYLE_TOOL = "apply_layer_style";
const LAYER_LABELS: Record<DefaultLayerStyleId, string> = {
junctions: "节点",
pipes: "管道",
};
const asString = (value: unknown): string | undefined =>
typeof value === "string" && value.trim() ? value.trim() : undefined;
const asNumber = (value: unknown): number | undefined =>
typeof value === "number" && Number.isFinite(value)
? value
: typeof value === "string" && value.trim() && Number.isFinite(Number(value))
? Number(value)
: undefined;
const asBoolean = (value: unknown): boolean | undefined =>
typeof value === "boolean"
? value
: typeof value === "string"
? value === "true"
? true
: value === "false"
? false
: undefined
: undefined;
const asNumberArray = (value: unknown): number[] | undefined =>
Array.isArray(value)
? value
.map((item) => asNumber(item))
.filter((item): item is number => item !== undefined)
: undefined;
const asStringArray = (value: unknown): string[] | undefined =>
Array.isArray(value)
? value
.map((item) => asString(item))
.filter((item): item is string => item !== undefined)
: undefined;
export const normalizeStyleLayerId = (value: unknown): DefaultLayerStyleId | null => {
const normalized = asString(value)?.toLowerCase();
if (normalized === "junctions" || normalized === "pipes") {
return normalized;
}
return null;
};
export const getStyleLayerLabel = (layerId: DefaultLayerStyleId): string =>
LAYER_LABELS[layerId];
export const parseApplyLayerStylePayload = (
params: Record<string, unknown>,
): ApplyLayerStyleActionPayload | null => {
const layerId = normalizeStyleLayerId(params.layer_id ?? params.layerId);
if (!layerId) {
return null;
}
const resetToDefault = Boolean(
asBoolean(params.reset_to_default ?? params.resetToDefault),
);
const rawStyleConfig =
params.style_config && typeof params.style_config === "object"
? (params.style_config as Record<string, unknown>)
: params.styleConfig && typeof params.styleConfig === "object"
? (params.styleConfig as Record<string, unknown>)
: null;
const styleConfig: Partial<StyleConfig> | undefined = rawStyleConfig
? {
property: asString(rawStyleConfig.property),
classificationMethod: asString(
rawStyleConfig.classification_method ?? rawStyleConfig.classificationMethod,
),
segments: asNumber(rawStyleConfig.segments),
minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize),
maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize),
minStrokeWidth: asNumber(
rawStyleConfig.min_stroke_width ?? rawStyleConfig.minStrokeWidth,
),
maxStrokeWidth: asNumber(
rawStyleConfig.max_stroke_width ?? rawStyleConfig.maxStrokeWidth,
),
fixedStrokeWidth: asNumber(
rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth,
),
colorType: asString(rawStyleConfig.color_type ?? rawStyleConfig.colorType),
singlePaletteIndex: asNumber(
rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex,
),
gradientPaletteIndex: asNumber(
rawStyleConfig.gradient_palette_index ?? rawStyleConfig.gradientPaletteIndex,
),
rainbowPaletteIndex: asNumber(
rawStyleConfig.rainbow_palette_index ?? rawStyleConfig.rainbowPaletteIndex,
),
showLabels: asBoolean(rawStyleConfig.show_labels ?? rawStyleConfig.showLabels),
showId: asBoolean(rawStyleConfig.show_id ?? rawStyleConfig.showId),
opacity: asNumber(rawStyleConfig.opacity),
adjustWidthByProperty: asBoolean(
rawStyleConfig.adjust_width_by_property ??
rawStyleConfig.adjustWidthByProperty,
),
customBreaks: asNumberArray(
rawStyleConfig.custom_breaks ?? rawStyleConfig.customBreaks,
),
customColors: asStringArray(
rawStyleConfig.custom_colors ?? rawStyleConfig.customColors,
),
}
: undefined;
const hasStyleOverrides =
styleConfig &&
Object.values(styleConfig).some((value) =>
Array.isArray(value) ? value.length > 0 : value !== undefined,
);
if (!resetToDefault && !hasStyleOverrides) {
return null;
}
return {
layerId,
resetToDefault,
styleConfig: hasStyleOverrides ? styleConfig : undefined,
};
};
export const describeApplyLayerStyle = (
payload: ApplyLayerStyleActionPayload,
): string => {
const layerLabel = getStyleLayerLabel(payload.layerId);
if (payload.resetToDefault) {
return `${layerLabel} · 重置默认样式`;
}
const property = payload.styleConfig?.property;
return property ? `${layerLabel} · ${property}` : `${layerLabel} · 应用样式`;
};
@@ -17,18 +17,14 @@ import {
ChevronRight,
FormatListBulleted,
} from "@mui/icons-material";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import VectorTileSource from "ol/source/VectorTile";
import { VectorTile } from "ol";
import { FlatStyleLike } from "ol/style/flat";
import { useMap } from "@components/olmap/core/MapComponent";
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
import AnalysisParameters from "./AnalysisParameters";
import SchemeQuery from "./SchemeQuery";
import RecognitionResults from "./RecognitionResults";
import { applyJunctionAreaRender } from "./applyJunctionAreaRender";
import { getAreaColor } from "./utils";
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
import { config } from "@/config/config";
const TabPanel = ({
value,
@@ -82,101 +78,26 @@ const DMALeakDetectionPanel: React.FC = () => {
useEffect(() => {
if (!map) return;
const junctionLayer = map
.getAllLayers()
.find(
(layer) =>
layer instanceof WebGLVectorTileLayer && layer.get("value") === "junctions",
) as WebGLVectorTileLayer | undefined;
if (!junctionLayer) return;
const source = junctionLayer.getSource() as VectorTileSource;
if (!source) return;
if (!loadedResult || !loadedResult.node_area_map) {
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
return;
}
const fallbackAreaIds = Array.from(
new Set(Object.values(loadedResult.node_area_map || {}).map(String)),
new Set(Object.values(loadedResult?.node_area_map ?? {}).map(String)),
);
const areaIds = (loadedResult.areas || []).length
? loadedResult.areas.map((area) => String(area.area_id))
const areaIds = (loadedResult?.areas ?? []).length
? (loadedResult?.areas ?? []).map((area) => String(area.area_id))
: fallbackAreaIds;
if (areaIds.length === 0) {
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
return;
}
const areaIdToIndex = new Map<string, number>();
areaIds.forEach((areaId, index) => {
areaIdToIndex.set(areaId, index + 1);
});
const nodeAreaIndexMap = new Map<string, number>();
Object.entries(loadedResult.node_area_map || {}).forEach(([nodeId, areaId]) => {
const idx = areaIdToIndex.get(String(areaId));
if (idx !== undefined) {
nodeAreaIndexMap.set(String(nodeId), idx);
}
});
const applyFeatureAreaIndex = (renderFeature: any) => {
const featureId = String(renderFeature.get("id") ?? "");
const areaIndex = nodeAreaIndexMap.get(featureId);
if (areaIndex !== undefined) {
renderFeature.properties_[DMA_AREA_INDEX_PROPERTY] = areaIndex;
}
};
const sourceTiles = (source as any).sourceTiles_;
if (sourceTiles) {
Object.values(sourceTiles).forEach((vectorTile: any) => {
const renderFeatures = vectorTile.getFeatures();
if (!renderFeatures || renderFeatures.length === 0) return;
renderFeatures.forEach((renderFeature: any) => {
applyFeatureAreaIndex(renderFeature);
});
});
}
const listener = (event: any) => {
try {
if (event.tile instanceof VectorTile) {
const renderFeatures = event.tile.getFeatures();
if (!renderFeatures || renderFeatures.length === 0) return;
renderFeatures.forEach((renderFeature: any) => {
applyFeatureAreaIndex(renderFeature);
});
}
} catch (error) {
console.error("Error applying DMA area mapping:", error);
}
};
source.on("tileloadend", listener);
const fillCases: any[] = [];
areaIds.forEach((areaId, index) => {
fillCases.push(
["==", ["get", DMA_AREA_INDEX_PROPERTY], index + 1],
getAreaColor(areaId),
);
});
const defaultFillColor = String(config.MAP_DEFAULT_STYLE["circle-fill-color"]);
const defaultStrokeColor = String(
config.MAP_DEFAULT_STYLE["circle-stroke-color"],
const areaColors = Object.fromEntries(
areaIds.map((areaId) => [areaId, getAreaColor(areaId)]),
);
const dmaStyle: FlatStyleLike = {
...config.MAP_DEFAULT_STYLE,
"circle-fill-color": ["case", ...fillCases, defaultFillColor],
"circle-stroke-color": ["case", ...fillCases, defaultStrokeColor],
};
junctionLayer.setStyle(dmaStyle);
return () => {
source.un("tileloadend", listener);
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
};
return applyJunctionAreaRender(
map,
{
nodeAreaMap: loadedResult?.node_area_map ?? {},
areaIds,
areaColors,
},
{ propertyKey: DMA_AREA_INDEX_PROPERTY },
);
}, [map, loadedResult]);
return (
@@ -0,0 +1,147 @@
import { Map as OlMap, VectorTile } from "ol";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import VectorTileSource from "ol/source/VectorTile";
import { FlatStyleLike } from "ol/style/flat";
import { config } from "@/config/config";
import { getAreaColor } from "./utils";
const JUNCTION_LAYER_VALUE = "junctions";
const RENDER_OWNER_KEY = "junction-area-render-owner";
export type JunctionAreaRenderPayload = {
nodeAreaMap: Record<string, string>;
areaIds?: string[];
areaColors?: Record<string, string>;
};
type ApplyJunctionAreaRenderOptions = {
propertyKey?: string;
};
const DEFAULT_PROPERTY_KEY = "junction_area_render_index";
const getJunctionLayer = (map: OlMap) =>
map
.getAllLayers()
.find(
(layer) =>
layer instanceof WebGLVectorTileLayer &&
layer.get("value") === JUNCTION_LAYER_VALUE,
) as WebGLVectorTileLayer | undefined;
export const applyJunctionAreaRender = (
map: OlMap,
payload: JunctionAreaRenderPayload,
options: ApplyJunctionAreaRenderOptions = {},
) => {
const propertyKey = options.propertyKey ?? DEFAULT_PROPERTY_KEY;
const junctionLayer = getJunctionLayer(map);
if (!junctionLayer) {
return () => {};
}
const source = junctionLayer.getSource() as VectorTileSource | null;
if (!source) {
return () => {};
}
const ownerId = `${propertyKey}-${Date.now().toString(36)}-${Math.random()
.toString(36)
.slice(2, 8)}`;
const normalizedNodeAreaMap = Object.fromEntries(
Object.entries(payload.nodeAreaMap ?? {}).map(([nodeId, areaId]) => [
String(nodeId),
String(areaId),
]),
);
const areaIds = (
payload.areaIds?.length
? payload.areaIds
: Array.from(new Set(Object.values(normalizedNodeAreaMap)))
)
.map(String)
.filter(Boolean);
if (Object.keys(normalizedNodeAreaMap).length === 0 || areaIds.length === 0) {
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
return () => {};
}
const areaIdToIndex = new Map<string, number>();
areaIds.forEach((areaId, index) => {
areaIdToIndex.set(areaId, index + 1);
});
const nodeAreaIndexMap = new Map<string, number>();
Object.entries(normalizedNodeAreaMap).forEach(([nodeId, areaId]) => {
const areaIndex = areaIdToIndex.get(areaId);
if (areaIndex !== undefined) {
nodeAreaIndexMap.set(nodeId, areaIndex);
}
});
const applyFeatureAreaIndex = (renderFeature: any) => {
const featureId = String(renderFeature.get("id") ?? "");
const areaIndex = nodeAreaIndexMap.get(featureId);
if (areaIndex !== undefined) {
renderFeature.properties_[propertyKey] = areaIndex;
}
};
const sourceTiles = (source as any).sourceTiles_;
if (sourceTiles) {
Object.values(sourceTiles).forEach((vectorTile: any) => {
const renderFeatures = vectorTile.getFeatures();
if (!renderFeatures || renderFeatures.length === 0) return;
renderFeatures.forEach((renderFeature: any) => {
applyFeatureAreaIndex(renderFeature);
});
});
}
const listener = (event: any) => {
try {
if (!(event.tile instanceof VectorTile)) return;
const renderFeatures = event.tile.getFeatures();
if (!renderFeatures || renderFeatures.length === 0) return;
renderFeatures.forEach((renderFeature: any) => {
applyFeatureAreaIndex(renderFeature);
});
} catch (error) {
console.error("Error applying junction area render:", error);
}
};
source.on("tileloadend", listener);
const fillCases: any[] = [];
areaIds.forEach((areaId, index) => {
fillCases.push(
["==", ["get", propertyKey], index + 1],
payload.areaColors?.[areaId] ?? getAreaColor(areaId),
);
});
const defaultFillColor = String(config.MAP_DEFAULT_STYLE["circle-fill-color"]);
const defaultStrokeColor = String(
config.MAP_DEFAULT_STYLE["circle-stroke-color"],
);
junctionLayer.set(RENDER_OWNER_KEY, ownerId);
junctionLayer.setStyle({
...config.MAP_DEFAULT_STYLE,
"circle-fill-color": ["case", ...fillCases, defaultFillColor],
"circle-stroke-color": ["case", ...fillCases, defaultStrokeColor],
} as FlatStyleLike);
return () => {
source.un("tileloadend", listener);
if (junctionLayer.get(RENDER_OWNER_KEY) === ownerId) {
junctionLayer.unset(RENDER_OWNER_KEY, true);
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
}
};
};
@@ -59,6 +59,23 @@ interface TimelineProps {
schemeName?: string;
}
const timelineIconButtonSx = {
width: 32,
height: 32,
borderRadius: "50%",
flexShrink: 0,
overflow: "hidden",
"&:hover": {
borderRadius: "50%",
},
"&.Mui-focusVisible": {
borderRadius: "50%",
},
"& .MuiTouchRipple-root": {
borderRadius: "50%",
},
} as const;
const Timeline: React.FC<TimelineProps> = ({
disableDateSelection = false,
}) => {
@@ -445,7 +462,7 @@ const Timeline: React.FC<TimelineProps> = ({
};
return (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 w-[920px] opacity-90 hover:opacity-100 transition-opacity duration-300">
<div className="absolute bottom-4 left-1/2 z-10 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100">
<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale="zh-cn"
@@ -481,6 +498,7 @@ const Timeline: React.FC<TimelineProps> = ({
onClick={handleDayStepBackward}
size="small"
disabled={disableDateSelection}
sx={timelineIconButtonSx}
>
<FiSkipBack />
</IconButton>
@@ -517,6 +535,7 @@ const Timeline: React.FC<TimelineProps> = ({
selectedDateTime.toDateString() ===
new Date().toDateString()
}
sx={timelineIconButtonSx}
>
<FiSkipForward />
</IconButton>
@@ -545,6 +564,7 @@ const Timeline: React.FC<TimelineProps> = ({
color="primary"
onClick={handleStepBackward}
size="small"
sx={timelineIconButtonSx}
>
<TbArrowBackUp />
</IconButton>
@@ -555,6 +575,7 @@ const Timeline: React.FC<TimelineProps> = ({
color="primary"
onClick={isPlaying ? handlePause : handlePlay}
size="small"
sx={timelineIconButtonSx}
>
{isPlaying ? <Pause /> : <PlayArrow />}
</IconButton>
@@ -565,6 +586,7 @@ const Timeline: React.FC<TimelineProps> = ({
color="primary"
onClick={handleStepForward}
size="small"
sx={timelineIconButtonSx}
>
<TbArrowForwardUp />
</IconButton>
@@ -575,6 +597,7 @@ const Timeline: React.FC<TimelineProps> = ({
color="secondary"
onClick={handleStop}
size="small"
sx={timelineIconButtonSx}
>
<Stop />
</IconButton>
+27 -2
View File
@@ -20,6 +20,7 @@ import {
ShowChart,
TableChart,
CleaningServices,
Close,
ChevronLeft,
ChevronRight,
} from "@mui/icons-material";
@@ -72,12 +73,22 @@ export interface SCADADataPanelProps {
start_time?: string;
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
end_time?: string;
/** 关闭面板 */
onClose?: () => void;
}
type PanelTab = "chart" | "table";
type LoadingState = "idle" | "loading" | "success" | "error";
const panelHeaderActionSx = {
color: "primary.contrastText",
backgroundColor: "rgba(255,255,255,0.08)",
"&:hover": {
backgroundColor: "rgba(255,255,255,0.18)",
},
};
/**
* API SCADA
*/
@@ -320,6 +331,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
onCleanData,
start_time,
end_time,
onClose,
}) => {
const { open } = useNotification();
const { data: user } = useGetIdentity<IUser>();
@@ -986,7 +998,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
<Box
className="absolute top-20 right-4 bg-white shadow-2xl rounded-lg cursor-pointer hover:shadow-xl transition-all duration-300 opacity-95 hover:opacity-100"
onClick={() => setIsExpanded(true)}
sx={{ zIndex: 1300 }}
sx={{ zIndex: 1290 }}
>
<Box className="flex flex-col items-center py-3 px-3 gap-1">
<ShowChart className="text-[#257DD4] w-5 h-5" />
@@ -1063,11 +1075,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
/>
</Stack>
<Stack direction="row" spacing={1}>
{onClose && (
<Tooltip title="关闭">
<IconButton
size="small"
onClick={onClose}
aria-label="关闭 SCADA 历史数据面板"
sx={panelHeaderActionSx}
>
<Close fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="收起">
<IconButton
size="small"
onClick={() => setIsExpanded(false)}
sx={{ color: "primary.contrastText" }}
aria-label="收起 SCADA 历史数据面板"
sx={panelHeaderActionSx}
>
<ChevronRight fontSize="small" />
</IconButton>
+142 -127
View File
@@ -1,158 +1,174 @@
import React, { useState, useEffect } from "react";
"use client";
import React, { useState, useEffect, useMemo, useRef } from "react";
import Image from "next/image";
import { useMap } from "../MapComponent";
import { useData, useMap } from "../MapComponent";
import TileLayer from "ol/layer/Tile.js";
import XYZ from "ol/source/XYZ.js";
import Group from "ol/layer/Group";
import mapboxOutdoors from "@assets/map/layers/mapbox-outdoors.png";
import mapboxLight from "@assets/map/layers/mapbox-light.png";
import mapboxSatellite from "@assets/map/layers/mapbox-satellite.png";
import mapboxSatelliteStreet from "@assets/map/layers/mapbox-satellite-streets.png";
import mapboxStreets from "@assets/map/layers/mapbox-streets.png";
import clsx from "clsx";
import Group from "ol/layer/Group";
import { MAPBOX_TOKEN } from "@config/config";
import { TIANDITU_TOKEN } from "@config/config";
import { MAPBOX_TOKEN, TIANDITU_TOKEN } from "@config/config";
import type { Map as OlMap } from "ol";
const INITIAL_LAYER = "mapbox-light";
const streetsLayer = new TileLayer({
source: new XYZ({
url: `https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
tileSize: 512,
maxZoom: 20,
projection: "EPSG:3857",
attributions:
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
}),
});
const lightMapLayer = new TileLayer({
source: new XYZ({
url: `https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
tileSize: 512,
maxZoom: 20,
projection: "EPSG:3857",
attributions:
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
}),
});
const satelliteLayer = new TileLayer({
source: new XYZ({
url: `https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
tileSize: 512,
maxZoom: 20,
projection: "EPSG:3857",
attributions:
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
}),
});
const satelliteStreetsLayer = new TileLayer({
source: new XYZ({
url: `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
tileSize: 512,
maxZoom: 20,
projection: "EPSG:3857",
attributions:
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
}),
});
const createTileLayer = (url: string, attributions: string) =>
new TileLayer({
source: new XYZ({
url,
tileSize: 512,
maxZoom: 20,
projection: "EPSG:3857",
attributions,
}),
});
const tiandituVectorLayer = new TileLayer({
source: new XYZ({
url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
const tiandituVectorAnnotationLayer = new TileLayer({
source: new XYZ({
url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
const tiandituImageLayer = new TileLayer({
source: new XYZ({
url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
const tiandituImageAnnotationLayer = new TileLayer({
source: new XYZ({
url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
const tiandituVectorLayerGroup = new Group({
layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer],
});
const tiandituImageLayerGroup = new Group({
layers: [tiandituImageLayer, tiandituImageAnnotationLayer],
});
const baseLayers = [
{
id: "mapbox-light",
name: "默认地图",
layer: lightMapLayer,
// layer: tiandituVectorLayerGroup,
img: mapboxLight.src,
},
{
id: "mapbox-satellite",
name: "卫星地图",
layer: satelliteLayer,
// layer: tiandituImageLayerGroup,
img: mapboxSatellite.src,
},
{
id: "mapbox-satellite-streets",
name: "卫星街道地图",
layer: satelliteStreetsLayer,
img: mapboxSatelliteStreet.src,
},
{
id: "mapbox-streets",
name: "街道地图",
layer: streetsLayer,
img: mapboxStreets.src,
},
];
const createBaseLayerEntries = () => {
const streetsLayer = createTileLayer(
`https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
);
const lightMapLayer = createTileLayer(
`https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
);
const satelliteLayer = createTileLayer(
`https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
);
const satelliteStreetsLayer = createTileLayer(
`https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
);
const tiandituVectorLayer = new TileLayer({
source: new XYZ({
url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
const tiandituVectorAnnotationLayer = new TileLayer({
source: new XYZ({
url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
const tiandituImageLayer = new TileLayer({
source: new XYZ({
url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
const tiandituImageAnnotationLayer = new TileLayer({
source: new XYZ({
url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
return [
{
id: "mapbox-light",
name: "默认地图",
layer: lightMapLayer,
img: mapboxLight.src,
},
{
id: "mapbox-satellite",
name: "卫星地图",
layer: satelliteLayer,
img: mapboxSatellite.src,
},
{
id: "mapbox-satellite-streets",
name: "卫星街道地图",
layer: satelliteStreetsLayer,
img: mapboxSatelliteStreet.src,
},
{
id: "mapbox-streets",
name: "街道地图",
layer: streetsLayer,
img: mapboxStreets.src,
},
{
id: "tianditu-vector",
name: "天地图矢量",
layer: new Group({
layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer],
}),
img: mapboxOutdoors.src,
},
{
id: "tianditu-image",
name: "天地图影像",
layer: new Group({
layers: [tiandituImageLayer, tiandituImageAnnotationLayer],
}),
img: mapboxSatellite.src,
},
];
};
const BaseLayers: React.FC = () => {
const map = useMap();
// 切换底图选项展开,控制显示和卸载
const data = useData();
const maps = useMemo(() => {
if (data?.maps?.length) return data.maps;
return map ? [map] : [];
}, [data?.maps, map]);
const layerSetsRef = useRef(new WeakMap<OlMap, ReturnType<typeof createBaseLayerEntries>>());
const [isShow, setShow] = useState(false);
const [isExpanded, setExpanded] = useState(false);
// 快速切换底图
const [activeId, setActiveId] = useState(INITIAL_LAYER);
// 初始化默认底图
useEffect(() => {
if (!map) return;
// 添加所有底图至地图并根据 activeId 控制可见性
baseLayers.forEach((layerInfo) => {
const layers = map.getLayers().getArray();
if (!layers.includes(layerInfo.layer)) {
map.getLayers().insertAt(0, layerInfo.layer);
maps.forEach((targetMap) => {
let layerEntries = layerSetsRef.current.get(targetMap);
if (!layerEntries) {
layerEntries = createBaseLayerEntries();
layerSetsRef.current.set(targetMap, layerEntries);
}
layerInfo.layer.setVisible(layerInfo.id === activeId);
layerEntries.forEach((layerInfo) => {
const layers = targetMap.getLayers().getArray();
if (!layers.includes(layerInfo.layer)) {
targetMap.getLayers().insertAt(0, layerInfo.layer);
}
layerInfo.layer.setVisible(layerInfo.id === activeId);
});
});
}, [map, activeId]);
}, [activeId, maps]);
const changeMapLayers = (id: string) => {
if (map) {
// 根据 id 设置每个图层的可见性
baseLayers.forEach(({ id: lid, layer }) => {
layer.setVisible(lid === id);
maps.forEach((targetMap) => {
const layerEntries = layerSetsRef.current.get(targetMap);
layerEntries?.forEach(({ id: layerId, layer }) => {
layer.setVisible(layerId === id);
});
}
});
};
const baseLayers = useMemo(() => createBaseLayerEntries().map(({ id, name, img }) => ({
id,
name,
img,
})), []);
const handleQuickSwitch = () => {
const nextId =
activeId === baseLayers[0].id ? baseLayers[1].id : baseLayers[0].id;
setActiveId(nextId);
handleMapLayers(nextId);
changeMapLayers(nextId);
};
const handleMapLayers = (id: string) => {
@@ -160,7 +176,6 @@ const BaseLayers: React.FC = () => {
changeMapLayers(id);
};
// 记录定时器,避免多次触发
const hideTimer = React.useRef<NodeJS.Timeout | null>(null);
const handleEnter = () => {
@@ -217,7 +232,7 @@ const BaseLayers: React.FC = () => {
{isExpanded && (
<div
className={clsx(
"absolute flex right-24 bottom-0 w-90 h-25 bg-white rounded-xl drop-shadow-xl shadow-black transition-all duration-300",
"absolute flex right-24 bottom-0 w-132 h-25 bg-white rounded-xl drop-shadow-xl shadow-black transition-all duration-300",
isShow ? "opacity-100" : "opacity-0"
)}
onMouseEnter={handleEnter}
@@ -226,7 +241,7 @@ const BaseLayers: React.FC = () => {
{baseLayers.map((item) => (
<button
key={item.id}
className="flex flex-auto flex-col justify-center items-center text-gray-500 text-xs"
className="flex flex-auto flex-col justify-center items-center text-gray-500 text-xs"
onClick={() => handleMapLayers(item.id)}
>
<Image
@@ -15,13 +15,14 @@ import {
Chip,
CircularProgress,
Divider,
IconButton,
Stack,
Tab,
Tabs,
Tooltip,
Typography,
} from "@mui/material";
import { Refresh, ShowChart, TableChart } from "@mui/icons-material";
import { Close, Refresh, ShowChart, TableChart } from "@mui/icons-material";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { zhCN } from "@mui/x-data-grid/locales";
import ReactECharts from "echarts-for-react";
@@ -63,12 +64,22 @@ export interface SCADADataPanelProps {
start_time?: string;
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
end_time?: string;
/** 关闭面板 */
onClose: () => void;
}
type PanelTab = "chart" | "table";
type LoadingState = "idle" | "loading" | "success" | "error";
const panelHeaderActionSx = {
color: "primary.contrastText",
backgroundColor: "rgba(255,255,255,0.08)",
"&:hover": {
backgroundColor: "rgba(255,255,255,0.18)",
},
};
/**
* API SCADA
*/
@@ -129,14 +140,17 @@ const fetchFromBackend = async (
"raw"
);
} else if (type === "scheme") {
// 查询策略模拟值、清洗值和监测值
const [cleanedRes, rawRes, schemeSimRes] = await Promise.all([
// 查询策略模拟值、实时模拟值、清洗值和监测值
const [cleanedRes, rawRes, simulationRes, schemeSimRes] = await Promise.all([
apiFetch(cleanedDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(simulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(schemeSimulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
@@ -146,40 +160,18 @@ const fetchFromBackend = async (
// 如果清洗数据有值,则不显示原始监测值
const rawData =
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds);
const simulationData = transformBackendData(simulationRes, featureIds);
const schemeSimData = transformBackendData(schemeSimRes, featureIds);
// 合并三组数据
const timeMap = new Map<string, Record<string, number | null>>();
[cleanedData, rawData, schemeSimData].forEach((data, index) => {
const suffix = ["clean", "raw", "scheme_sim"][index];
data.forEach((point) => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {});
}
const values = timeMap.get(point.timestamp)!;
featureIds.forEach((deviceId) => {
const value = point.values[deviceId];
if (value !== undefined) {
values[`${deviceId}_${suffix}`] = value;
}
});
});
});
const result = Array.from(timeMap.entries()).map(
([timestamp, values]) => ({
timestamp,
values,
})
return mergeMultipleTimeSeriesData(
[
{ data: cleanedData, suffix: "clean" },
{ data: rawData, suffix: "raw" },
{ data: simulationData, suffix: "sim" },
{ data: schemeSimData, suffix: "scheme_sim" },
],
featureIds
);
result.sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return result;
} else {
// realtime: 查询模拟值、清洗值和监测值
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
@@ -336,6 +328,42 @@ const mergeTimeSeriesData = (
return result;
};
const mergeMultipleTimeSeriesData = (
datasets: Array<{
data: TimeSeriesPoint[];
suffix: string;
}>,
deviceIds: string[]
): TimeSeriesPoint[] => {
const timeMap = new Map<string, Record<string, number | null>>();
datasets.forEach(({ data, suffix }) => {
data.forEach((point) => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {});
}
const values = timeMap.get(point.timestamp)!;
deviceIds.forEach((deviceId) => {
const value = point.values[deviceId];
if (value !== undefined) {
values[`${deviceId}_${suffix}`] = value;
}
});
});
});
const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({
timestamp,
values,
}));
result.sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return result;
};
const formatTimestamp = (timestamp: string) =>
dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm");
@@ -402,6 +430,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
fractionDigits = 2,
start_time,
end_time,
onClose,
}) => {
// 从 featureInfos 中提取设备 ID 列表
const deviceIds = useMemo(
@@ -537,7 +566,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const suffixes = [
{ key: "clean", name: "清洗值" },
{ key: "raw", name: "监测值" },
{ key: "sim", name: "模拟值" },
{ key: "sim", name: "实时模拟值" },
{ key: "scheme_sim", name: "方案模拟值" },
];
@@ -643,32 +672,46 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
: suffix === "raw"
? "监测值"
: suffix === "sim"
? "模拟"
? "实时模拟"
: "方案模拟";
series.push({
name: `${id} (${displayName})`,
type: "line",
symbol: "none",
symbol:
suffix === "clean"
? "circle"
: suffix === "raw"
? "diamond"
: "none",
symbolSize: suffix === "clean" || suffix === "raw" ? 7 : 0,
showSymbol: suffix === "clean" || suffix === "raw",
sampling: "lttb",
connectNulls: true,
connectNulls: suffix !== "clean" && suffix !== "raw",
itemStyle: {
color: colors[(index * 4 + sIndex) % colors.length],
},
data: dataset.map((item) => item[key]),
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: colors[(index * 4 + sIndex) % colors.length],
},
{
offset: 1,
color: "rgba(255, 255, 255, 0)",
},
]),
opacity: 0.3,
},
lineStyle:
suffix === "clean" || suffix === "raw"
? { width: 0 }
: undefined,
areaStyle:
suffix === "clean" || suffix === "raw"
? undefined
: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: colors[(index * 4 + sIndex) % colors.length],
},
{
offset: 1,
color: "rgba(255, 255, 255, 0)",
},
]),
opacity: 0.3,
},
});
}
});
@@ -819,7 +862,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
return (
<>
{/* 主面板 */}
<Draggable nodeRef={draggableRef} handle=".drag-handle">
<Draggable
nodeRef={draggableRef}
handle=".drag-handle"
cancel=".panel-close-button"
>
<Box
ref={draggableRef}
sx={{
@@ -840,7 +887,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
border: "none",
display: "flex",
flexDirection: "column",
zIndex: 1300,
zIndex: 1290,
backgroundColor: "white",
overflow: "hidden",
"&:hover": {
@@ -884,6 +931,17 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
}}
/>
</Stack>
<Tooltip title="关闭">
<IconButton
className="panel-close-button"
size="small"
onClick={onClose}
aria-label="关闭历史数据面板"
sx={panelHeaderActionSx}
>
<Close fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Box>
@@ -5,6 +5,7 @@ import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import VectorLayer from "ol/layer/Vector";
import VectorTileLayer from "ol/layer/VectorTile";
import { DeckLayer } from "@utils/layers";
import type { Map as OlMap } from "ol";
// 定义统一的图层项接口
interface LayerItem {
@@ -30,10 +31,14 @@ const LAYER_ORDER = [
const LayerControl: React.FC = () => {
const map = useMap();
const data = useData();
const maps: OlMap[] = data?.maps?.length ? data.maps : map ? [map] : [];
const [refreshKey, setRefreshKey] = useState(0);
const deckLayer = data?.deckLayer;
const deckLayers = data?.deckLayers ?? (deckLayer ? [deckLayer] : []);
const isContourLayerAvailable = data?.isContourLayerAvailable;
const isWaterflowLayerAvailable = data?.isWaterflowLayerAvailable;
const showContourLayer = data?.showContourLayer;
const showWaterflowLayer = data?.showWaterflowLayer;
const setShowWaterflowLayer = data?.setShowWaterflowLayer;
const setShowContourLayer = data?.setShowContourLayer;
@@ -43,6 +48,14 @@ const LayerControl: React.FC = () => {
if (!map || !data) return [];
const items: LayerItem[] = [];
const upsertLayerItem = (nextItem: LayerItem) => {
const index = items.findIndex((item) => item.id === nextItem.id);
if (index >= 0) {
items[index] = nextItem;
return;
}
items.push(nextItem);
};
map.getLayers().getArray().forEach((layer) => {
if (
@@ -53,7 +66,7 @@ const LayerControl: React.FC = () => {
const value = layer.get("value");
const name = layer.get("name");
if (value) {
items.push({
upsertLayerItem({
id: value,
name: name || value,
visible: layer.getVisible(),
@@ -77,7 +90,7 @@ const LayerControl: React.FC = () => {
return;
}
items.push({
upsertLayerItem({
id: layer.props.id,
name: layer.props.name,
visible:
@@ -88,6 +101,30 @@ const LayerControl: React.FC = () => {
});
}
if (isWaterflowLayerAvailable) {
upsertLayerItem({
id: "waterflowLayer",
name: "水流",
visible:
deckLayer?.getDeckLayerVisible("waterflowLayer") ?? showWaterflowLayer ?? false,
type: "deck",
layerRef: deckLayer?.getDeckLayerById("waterflowLayer") ?? null,
});
}
if (isContourLayerAvailable) {
upsertLayerItem({
id: "junctionContourLayer",
name: "等值线",
visible:
deckLayer?.getDeckLayerVisible("junctionContourLayer") ??
showContourLayer ??
false,
type: "deck",
layerRef: deckLayer?.getDeckLayerById("junctionContourLayer") ?? null,
});
}
return items
.filter((item) => LAYER_ORDER.includes(item.id))
.sort((a, b) => LAYER_ORDER.indexOf(a.id) - LAYER_ORDER.indexOf(b.id));
@@ -97,6 +134,8 @@ const LayerControl: React.FC = () => {
deckLayer,
isContourLayerAvailable,
isWaterflowLayerAvailable,
showContourLayer,
showWaterflowLayer,
refreshKey,
]);
@@ -117,8 +156,16 @@ const LayerControl: React.FC = () => {
const handleVisibilityChange = (item: LayerItem, checked: boolean) => {
if (item.type === "ol") {
item.layerRef.setVisible(checked);
} else if (item.type === "deck" && deckLayer) {
maps.forEach((targetMap) => {
targetMap
.getAllLayers()
.filter((layer) => layer.get("value") === item.id)
.forEach((layer) => layer.setVisible(checked));
});
} else if (item.type === "deck") {
deckLayers.forEach((targetDeckLayer) => {
targetDeckLayer.setDeckLayerVisible(item.id, checked);
});
if (item.id === "junctionContourLayer") {
setShowContourLayer && setShowContourLayer(checked);
}
@@ -1,4 +1,9 @@
import React from "react";
"use client";
import React, { useRef } from "react";
import Draggable from "react-draggable";
import { Close } from "@mui/icons-material";
import { IconButton, Tooltip } from "@mui/material";
interface BaseProperty {
label: string;
@@ -21,13 +26,24 @@ interface PropertyPanelProps {
id?: string;
type?: string;
properties?: PropertyItem[];
onClose: () => void;
}
const PropertyPanel: React.FC<PropertyPanelProps> = ({
id,
type = "未知类型",
properties = [],
onClose,
}) => {
const draggableRef = useRef<HTMLDivElement>(null);
const headerActionSx = {
color: "common.white",
backgroundColor: "rgba(255,255,255,0.08)",
"&:hover": {
backgroundColor: "rgba(255,255,255,0.18)",
},
};
const formatValue = (property: BaseProperty) => {
if (property.formatter) {
return property.formatter(property.value);
@@ -50,162 +66,20 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
: 0;
return (
<div className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm z-1300 opacity-95 hover:opacity-100 transition-all duration-300 ">
{/* 头部 */}
<div className="flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 className="text-lg font-semibold"></h3>
</div>
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-y-auto px-4 py-3">
{!id ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<Draggable
nodeRef={draggableRef}
handle=".drag-handle"
cancel=".panel-close-button"
>
<div
ref={draggableRef}
className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm z-1300 opacity-95 hover:opacity-100"
>
{/* 头部 */}
<div className="drag-handle flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white cursor-move select-none">
<div className="flex items-center gap-2">
<svg
className="w-16 h-16 mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div>
) : (
<div className="space-y-2">
{/* ID 属性 */}
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
<div className="flex justify-between items-start gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
ID
</span>
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
{id}
</span>
</div>
</div>
{/* 类型属性 */}
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
<div className="flex justify-between items-start gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
</span>
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
{type}
</span>
</div>
</div>
{/* 其他属性(包含二级表格) */}
{properties.map((property, index) => {
// 二级表格
if ("type" in property && property.type === "table") {
return (
<div
key={`table-${index}`}
className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100"
>
<div className="flex justify-between items-start gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-gray-600">
{property.label}
</span>
</div>
<div className="ml-4 mt-2 border border-gray-300 rounded-md overflow-hidden shadow-sm">
<table className="w-full text-xs">
<thead className="bg-gray-200 text-gray-700">
<tr>
{property.columns.map((col, ci) => (
<th
key={ci}
className="px-3 py-2 text-left font-semibold"
>
{col}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-300">
{property.rows.map((row, ri) => (
<tr key={ri} className="bg-white hover:bg-gray-50">
{row.map((cell, cci) => (
<td
key={cci}
className="px-3 py-2 text-gray-800"
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// 普通属性
const base = property as BaseProperty;
const isImportant = isImportantKeys.includes(base.label);
return (
<div
key={`prop-${index}`}
className={`group rounded-lg p-3 transition-all duration-200 ${
isImportant
? "bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500"
: "bg-gray-50 hover:bg-gray-100"
}`}
>
<div className="flex justify-between items-start gap-3">
<span
className={`font-medium text-xs uppercase tracking-wide ${
isImportant ? "text-blue-700" : "text-gray-600"
}`}
>
{base.label}
</span>
<span
className={`text-sm font-semibold text-right flex-1 ${
isImportant ? "text-blue-900" : "text-gray-800"
}`}
>
{formatValue(base)}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* 底部统计区域 */}
<div className="px-5 py-3 bg-gray-50 border-t border-gray-200">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-600 flex items-center gap-1">
<svg
className="w-4 h-4"
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -214,20 +88,183 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
{totalProps}
</span>
{id && (
<span className="text-green-600 flex items-center gap-1 font-medium">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
</span>
<h3 className="text-lg font-semibold"></h3>
</div>
<Tooltip title="关闭">
<IconButton
className="panel-close-button"
size="small"
onClick={onClose}
aria-label="关闭属性面板"
sx={headerActionSx}
>
<Close fontSize="small" />
</IconButton>
</Tooltip>
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-y-auto px-4 py-3">
{!id ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg
className="w-16 h-16 mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div>
) : (
<div className="space-y-2">
{/* ID 属性 */}
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
<div className="flex justify-between items-start gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
ID
</span>
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
{id}
</span>
</div>
</div>
{/* 类型属性 */}
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
<div className="flex justify-between items-start gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
</span>
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
{type}
</span>
</div>
</div>
{/* 其他属性(包含二级表格) */}
{properties.map((property, index) => {
// 二级表格
if ("type" in property && property.type === "table") {
return (
<div
key={`table-${index}`}
className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100"
>
<div className="flex justify-between items-start gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-gray-600">
{property.label}
</span>
</div>
<div className="ml-4 mt-2 border border-gray-300 rounded-md overflow-hidden shadow-sm">
<table className="w-full text-xs">
<thead className="bg-gray-200 text-gray-700">
<tr>
{property.columns.map((col, ci) => (
<th
key={ci}
className="px-3 py-2 text-left font-semibold"
>
{col}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-300">
{property.rows.map((row, ri) => (
<tr key={ri} className="bg-white hover:bg-gray-50">
{row.map((cell, cci) => (
<td
key={cci}
className="px-3 py-2 text-gray-800"
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// 普通属性
const base = property as BaseProperty;
const isImportant = isImportantKeys.includes(base.label);
return (
<div
key={`prop-${index}`}
className={`group rounded-lg p-3 transition-all duration-200 ${
isImportant
? "bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500"
: "bg-gray-50 hover:bg-gray-100"
}`}
>
<div className="flex justify-between items-start gap-3">
<span
className={`font-medium text-xs uppercase tracking-wide ${
isImportant ? "text-blue-700" : "text-gray-600"
}`}
>
{base.label}
</span>
<span
className={`text-sm font-semibold text-right flex-1 ${
isImportant ? "text-blue-900" : "text-gray-800"
}`}
>
{formatValue(base)}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* 底部统计区域 */}
<div className="px-5 py-3 bg-gray-50 border-t border-gray-200">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-600 flex items-center gap-1">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
{totalProps}
</span>
{id && (
<span className="text-green-600 flex items-center gap-1 font-medium">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
</span>
)}
</div>
</div>
</div>
</div>
</Draggable>
);
};
@@ -0,0 +1,590 @@
import ApplyIcon from "@mui/icons-material/Check";
import ColorLensIcon from "@mui/icons-material/ColorLens";
import ResetIcon from "@mui/icons-material/Refresh";
import {
Box,
Button,
Checkbox,
FormControl,
FormControlLabel,
InputLabel,
MenuItem,
Select,
Slider,
TextField,
Typography,
} from "@mui/material";
import React from "react";
import {
CLASSIFICATION_METHODS,
COLOR_TYPE_OPTIONS,
GRADIENT_PALETTES,
RAINBOW_PALETTES,
SINGLE_COLOR_PALETTES,
} from "./styleEditorPresets";
import { StyleEditorFormProps } from "./styleEditorTypes";
import {
getSizePreviewColors,
hexToRgba,
resolveStyleColors,
rgbaToHex,
} from "./styleEditorUtils";
const StyleEditorForm: React.FC<StyleEditorFormProps> = ({
renderLayers,
selectedRenderLayer,
styleConfig,
setStyleConfig,
availableProperties,
onLayerChange,
onPropertyChange,
onClassificationMethodChange,
onSegmentsChange,
onCustomBreakChange,
onCustomBreakBlur,
onColorTypeChange,
onApply,
onReset,
}) => {
const renderColorSetting = () => {
if (styleConfig.colorType === "single") {
return (
<FormControl variant="standard" fullWidth margin="dense" className="mt-3">
<InputLabel></InputLabel>
<Select
value={styleConfig.singlePaletteIndex}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
singlePaletteIndex: Number(e.target.value),
}))
}
>
{SINGLE_COLOR_PALETTES.map((palette, index) => (
<MenuItem key={index} value={index}>
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
<Box
sx={{
width: "80%",
height: 16,
borderRadius: 2,
background: palette.color,
marginRight: 1,
border: "1px solid #ccc",
}}
/>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
);
}
if (styleConfig.colorType === "gradient") {
return (
<FormControl variant="standard" fullWidth margin="dense" className="mt-3">
<InputLabel></InputLabel>
<Select
value={styleConfig.gradientPaletteIndex}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
gradientPaletteIndex: Number(e.target.value),
}))
}
>
{GRADIENT_PALETTES.map((palette, index) => {
const previewColors = resolveStyleColors(
{ ...styleConfig, colorType: "gradient", gradientPaletteIndex: index },
styleConfig.segments + 1
);
return (
<MenuItem key={index} value={index}>
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
<Box
sx={{
width: "80%",
height: 16,
borderRadius: 2,
display: "flex",
overflow: "hidden",
marginRight: 1,
border: "1px solid #ccc",
}}
>
{previewColors.map((color, colorIndex) => (
<Box
key={colorIndex}
sx={{ flex: 1, backgroundColor: color }}
/>
))}
</Box>
</Box>
</MenuItem>
);
})}
</Select>
</FormControl>
);
}
if (styleConfig.colorType === "rainbow") {
return (
<FormControl variant="standard" fullWidth margin="dense" className="mt-3">
<InputLabel></InputLabel>
<Select
value={styleConfig.rainbowPaletteIndex}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
rainbowPaletteIndex: Number(e.target.value),
}))
}
>
{RAINBOW_PALETTES.map((palette, index) => {
const previewColors = Array.from(
{ length: styleConfig.segments + 1 },
(_, colorIndex) => palette.colors[colorIndex % palette.colors.length]
);
return (
<MenuItem key={index} value={index}>
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
<Typography sx={{ marginRight: 1 }}>{palette.name}</Typography>
<Box
sx={{
width: "60%",
height: 16,
borderRadius: 2,
display: "flex",
border: "1px solid #ccc",
overflow: "hidden",
}}
>
{previewColors.map((color, colorIndex) => (
<Box
key={colorIndex}
sx={{ flex: 1, backgroundColor: color }}
/>
))}
</Box>
</Box>
</MenuItem>
);
})}
</Select>
</FormControl>
);
}
if (styleConfig.colorType === "custom") {
return (
<Box className="mt-3">
<Typography variant="subtitle2" gutterBottom>
</Typography>
<Box
className="flex flex-col gap-2"
sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "4px" }}
>
{Array.from({ length: styleConfig.segments }).map((_, index) => {
const color = styleConfig.customColors?.[index] || "rgba(0,0,0,1)";
return (
<Box key={index} className="flex items-center gap-2">
<Typography variant="caption" sx={{ width: 40 }}>
{index + 1}
</Typography>
<input
type="color"
value={rgbaToHex(color)}
onChange={(e) => {
const nextColor = hexToRgba(e.target.value);
setStyleConfig((prev) => {
const nextColors = [...(prev.customColors || [])];
while (nextColors.length < prev.segments) {
nextColors.push("rgba(0,0,0,1)");
}
nextColors[index] = nextColor;
return { ...prev, customColors: nextColors };
});
}}
style={{
width: "100%",
height: "32px",
cursor: "pointer",
border: "1px solid #ccc",
borderRadius: "4px",
}}
/>
</Box>
);
})}
</Box>
</Box>
);
}
return null;
};
const renderSizeSetting = () => {
const previewColors = getSizePreviewColors(styleConfig);
if (selectedRenderLayer?.get("type") === "point") {
return (
<Box className="mt-3">
<Typography gutterBottom>
: {styleConfig.minSize} - {styleConfig.maxSize}
</Typography>
<Box className="flex items-center gap-4">
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.minSize}
onChange={(_, value) =>
setStyleConfig((prev) => ({ ...prev, minSize: value as number }))
}
min={2}
max={8}
step={1}
size="small"
/>
</Box>
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.maxSize}
onChange={(_, value) =>
setStyleConfig((prev) => ({ ...prev, maxSize: value as number }))
}
min={10}
max={16}
step={1}
size="small"
/>
</Box>
</Box>
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
<Typography variant="caption">:</Typography>
<Box
sx={{
width: styleConfig.minSize,
height: styleConfig.minSize,
borderRadius: "50%",
backgroundColor: previewColors[0],
}}
/>
<Typography variant="caption"></Typography>
<Box
sx={{
width: styleConfig.maxSize,
height: styleConfig.maxSize,
borderRadius: "50%",
backgroundColor: previewColors[previewColors.length - 1],
}}
/>
</Box>
</Box>
);
}
if (selectedRenderLayer?.get("type") === "linestring") {
return (
<Box className="mt-3">
<FormControlLabel
control={
<Checkbox
checked={styleConfig.adjustWidthByProperty}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
adjustWidthByProperty: e.target.checked,
}))
}
disabled={styleConfig.colorType === "single"}
/>
}
label="根据数值分段调整线条宽度"
/>
{styleConfig.adjustWidthByProperty ? (
<>
<Typography gutterBottom>
线: {styleConfig.minStrokeWidth} - {styleConfig.maxStrokeWidth}
px
</Typography>
<Box className="flex items-center gap-4">
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.minStrokeWidth}
onChange={(_, value) =>
setStyleConfig((prev) => ({
...prev,
minStrokeWidth: value as number,
}))
}
min={1}
max={4}
step={0.5}
size="small"
/>
</Box>
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.maxStrokeWidth}
onChange={(_, value) =>
setStyleConfig((prev) => ({
...prev,
maxStrokeWidth: value as number,
}))
}
min={6}
max={12}
step={0.5}
size="small"
/>
</Box>
</Box>
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
<Typography variant="caption">:</Typography>
<Box
sx={{
width: 50,
height: styleConfig.minStrokeWidth,
backgroundColor: previewColors[0],
border: `1px solid ${previewColors[0]}`,
borderRadius: 1,
}}
/>
<Typography variant="caption"></Typography>
<Box
sx={{
width: 50,
height: styleConfig.maxStrokeWidth,
backgroundColor: previewColors[previewColors.length - 1],
border: `1px solid ${previewColors[previewColors.length - 1]}`,
borderRadius: 1,
}}
/>
</Box>
</>
) : (
<>
<Typography gutterBottom>
线: {styleConfig.fixedStrokeWidth}px
</Typography>
<Slider
value={styleConfig.fixedStrokeWidth}
onChange={(_, value) =>
setStyleConfig((prev) => ({
...prev,
fixedStrokeWidth: value as number,
}))
}
min={1}
max={10}
step={0.5}
size="small"
/>
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
<Typography variant="caption">:</Typography>
<Box
sx={{
width: 50,
height: styleConfig.fixedStrokeWidth,
backgroundColor: previewColors[0],
border: `1px solid ${previewColors[0]}`,
borderRadius: 1,
}}
/>
</Box>
</>
)}
</Box>
);
}
return null;
};
return (
<div className="absolute top-20 left-4 bg-white p-4 rounded-xl shadow-lg opacity-95 hover:opacity-100 transition-opacity w-80 z-1300">
<FormControl variant="standard" fullWidth margin="dense">
<InputLabel></InputLabel>
<Select
value={selectedRenderLayer ? renderLayers.indexOf(selectedRenderLayer) : ""}
onChange={(e) => onLayerChange(e.target.value as number)}
>
{renderLayers.map((layer, index) => (
<MenuItem key={index} value={index}>
{layer.get("name")}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl variant="standard" fullWidth margin="dense">
<InputLabel></InputLabel>
<Select
value={styleConfig.property}
onChange={(e) => onPropertyChange(e.target.value)}
disabled={!selectedRenderLayer}
>
{availableProperties.map((property) => (
<MenuItem key={property.name} value={property.value}>
{property.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl variant="standard" fullWidth margin="dense">
<InputLabel></InputLabel>
<Select
value={styleConfig.classificationMethod}
onChange={(e) => onClassificationMethodChange(e.target.value)}
>
{CLASSIFICATION_METHODS.map((method) => (
<MenuItem key={method.value} value={method.value}>
{method.name}
</MenuItem>
))}
</Select>
</FormControl>
<Box className="mt-3">
<Typography gutterBottom>: {styleConfig.segments}</Typography>
<Slider
value={styleConfig.segments}
onChange={(_, value) => onSegmentsChange(value as number)}
min={2}
max={10}
step={1}
marks
size="small"
/>
</Box>
{styleConfig.classificationMethod === "custom_breaks" && (
<Box className="mt-3 p-2 bg-gray-50 rounded">
<Typography variant="subtitle2" gutterBottom>
{">="} 0
</Typography>
<Box
className="flex flex-col gap-2"
sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "12px" }}
>
{Array.from({ length: styleConfig.segments }).map((_, index) => (
<TextField
key={index}
label={`阈值 ${index + 1}`}
type="number"
size="small"
slotProps={{ input: { inputProps: { min: 0, step: 0.1 } } }}
value={styleConfig.customBreaks?.[index] ?? ""}
onChange={(e) => onCustomBreakChange(index, e.target.value)}
onBlur={onCustomBreakBlur}
/>
))}
</Box>
</Box>
)}
<FormControl variant="standard" fullWidth margin="dense">
<InputLabel>
<ColorLensIcon className="mr-1" />
</InputLabel>
<Select
value={styleConfig.colorType}
onChange={(e) => onColorTypeChange(e.target.value)}
>
{COLOR_TYPE_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
{renderColorSetting()}
</FormControl>
{renderSizeSetting()}
<Box className="mt-3">
<Typography gutterBottom>
: {(styleConfig.opacity * 100).toFixed(0)}%
</Typography>
<Slider
value={styleConfig.opacity}
onChange={(_, value) =>
setStyleConfig((prev) => ({ ...prev, opacity: value as number }))
}
min={0.1}
max={1}
step={0.05}
size="small"
/>
</Box>
<FormControlLabel
control={
<Checkbox
checked={styleConfig.showId}
onChange={(e) =>
setStyleConfig((prev) => ({ ...prev, showId: e.target.checked }))
}
/>
}
label="显示 ID(缩放 >=15 级时显示)"
/>
<FormControlLabel
control={
<Checkbox
checked={styleConfig.showLabels}
onChange={(e) =>
setStyleConfig((prev) => ({ ...prev, showLabels: e.target.checked }))
}
/>
}
label="显示属性(缩放 >=15 级时显示)"
/>
<div className="my-3"></div>
<Box className="flex gap-2">
<Button
variant="contained"
color="primary"
onClick={onApply}
disabled={!selectedRenderLayer || !styleConfig.property}
startIcon={<ApplyIcon />}
fullWidth
>
</Button>
<Button
variant="outlined"
onClick={onReset}
disabled={!selectedRenderLayer}
startIcon={<ResetIcon />}
fullWidth
>
</Button>
</Box>
</div>
);
};
export default StyleEditorForm;
File diff suppressed because it is too large Load Diff
+240 -97
View File
@@ -39,6 +39,23 @@ interface TimelineProps {
schemeType?: string;
}
const timelineIconButtonSx = {
width: 32,
height: 32,
borderRadius: "50%",
flexShrink: 0,
overflow: "hidden",
"&:hover": {
borderRadius: "50%",
},
"&.Mui-focusVisible": {
borderRadius: "50%",
},
"& .MuiTouchRipple-root": {
borderRadius: "50%",
},
} as const;
const NOOP_SET_CURRENT_TIME = (_: any) => undefined;
const NOOP_SET_SELECTED_DATE = (_: any) => undefined;
@@ -63,8 +80,12 @@ const Timeline: React.FC<TimelineProps> = ({
const setSelectedDate = data?.setSelectedDate ?? NOOP_SET_SELECTED_DATE;
const setCurrentJunctionCalData = data?.setCurrentJunctionCalData;
const setCurrentPipeCalData = data?.setCurrentPipeCalData;
const setCompareJunctionCalData = data?.setCompareJunctionCalData;
const setComparePipeCalData = data?.setComparePipeCalData;
const isCompareMode = data?.isCompareMode ?? false;
const junctionText = data?.junctionText ?? "";
const pipeText = data?.pipeText ?? "";
const setForceStyleAutoApplyVersion = data?.setForceStyleAutoApplyVersion;
const { open } = useNotification();
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒
@@ -94,100 +115,209 @@ const Timeline: React.FC<TimelineProps> = ({
// 添加防抖引用
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const updateDataStates = useCallback((nodeResults: any[], linkResults: any[]) => {
if (setCurrentJunctionCalData) {
setCurrentJunctionCalData(nodeResults);
} else {
console.log("setCurrentJunctionCalData is undefined");
}
if (setCurrentPipeCalData) {
setCurrentPipeCalData(linkResults);
} else {
console.log("setCurrentPipeCalData is undefined");
}
}, [setCurrentJunctionCalData, setCurrentPipeCalData]);
const updateDataStates = useCallback(
(
nodeResults: any[],
linkResults: any[],
target: "primary" | "compare" = "primary"
) => {
const setNodeData =
target === "compare"
? setCompareJunctionCalData
: setCurrentJunctionCalData;
const setLinkData =
target === "compare" ? setComparePipeCalData : setCurrentPipeCalData;
const fetchFrameData = useCallback(async (
queryTime: Date,
junctionProperties: string,
pipeProperties: string,
schemeName: string,
schemeType: string,
) => {
const query_time = queryTime.toISOString();
let nodeRecords: any = { results: [] };
let linkRecords: any = { results: [] };
const requests: Promise<Response>[] = [];
let nodePromise: Promise<any> | null = null;
let linkPromise: Promise<any> | null = null;
// 检查node缓存
if (junctionProperties !== "" && junctionProperties !== "elevation") {
const nodeCacheKey = `${query_time}_${junctionProperties}_${schemeName}_${schemeType}`;
if (nodeCacheRef.current.has(nodeCacheKey)) {
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
} else {
disableDateSelection && schemeName
? (nodePromise = apiFetch(
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}&schemename=${schemeName}`
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`,
))
: (nodePromise = apiFetch(
// `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}`
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`,
));
requests.push(nodePromise);
setNodeData?.(nodeResults);
setLinkData?.(linkResults);
},
[
setCompareJunctionCalData,
setComparePipeCalData,
setCurrentJunctionCalData,
setCurrentPipeCalData,
]
);
const buildCacheKey = useCallback(
(
queryTime: string,
property: string,
sourceType: "scheme" | "realtime",
resultType: "node" | "link",
targetSchemeName: string,
targetSchemeType: string
) =>
[
queryTime,
sourceType,
resultType,
property,
targetSchemeName || "default",
targetSchemeType || "default",
].join("::"),
[]
);
const fetchDataBySource = useCallback(
async ({
queryTime,
junctionProperties,
pipeProperties,
sourceType,
target,
schemeName,
schemeType,
}: {
queryTime: Date;
junctionProperties: string;
pipeProperties: string;
sourceType: "scheme" | "realtime";
target: "primary" | "compare";
schemeName?: string;
schemeType?: string;
}) => {
const query_time = queryTime.toISOString();
let nodeRecords: any = { results: [] };
let linkRecords: any = { results: [] };
const requests: Promise<Response>[] = [];
let nodePromise: Promise<Response> | null = null;
let linkPromise: Promise<Response> | null = null;
if (junctionProperties !== "" && junctionProperties !== "elevation") {
const nodeCacheKey = buildCacheKey(
query_time,
junctionProperties,
sourceType,
"node",
schemeName || "",
schemeType || ""
);
if (nodeCacheRef.current.has(nodeCacheKey)) {
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
} else {
nodePromise =
sourceType === "scheme" && schemeName
? apiFetch(
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`
)
: apiFetch(
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`
);
requests.push(nodePromise);
}
}
}
// 处理特殊属性名称
if (pipeProperties === "unit_headloss") pipeProperties = "headloss";
// 检查link缓存
if (pipeProperties !== "" && pipeProperties !== "diameter") {
const linkCacheKey = `${query_time}_${pipeProperties}_${schemeName}_${schemeType}`;
if (linkCacheRef.current.has(linkCacheKey)) {
linkRecords = linkCacheRef.current.get(linkCacheKey)!;
} else {
disableDateSelection && schemeName
? (linkPromise = apiFetch(
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}&schemename=${schemeName}`
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${pipeProperties}`,
))
: (linkPromise = apiFetch(
// `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}`
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${pipeProperties}`,
));
requests.push(linkPromise);
const normalizedPipeProperties =
pipeProperties === "unit_headloss" ? "headloss" : pipeProperties;
if (normalizedPipeProperties !== "" && normalizedPipeProperties !== "diameter") {
const linkCacheKey = buildCacheKey(
query_time,
normalizedPipeProperties,
sourceType,
"link",
schemeName || "",
schemeType || ""
);
if (linkCacheRef.current.has(linkCacheKey)) {
linkRecords = linkCacheRef.current.get(linkCacheKey)!;
} else {
linkPromise =
sourceType === "scheme" && schemeName
? apiFetch(
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`
)
: apiFetch(
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`
);
requests.push(linkPromise);
}
}
}
// 等待所有有效请求
const responses = await Promise.all(requests);
const responses = await Promise.all(requests);
if (nodePromise) {
const nodeResponse = responses.shift()!;
if (!nodeResponse.ok)
throw new Error(`Node fetch failed: ${nodeResponse.status}`);
nodeRecords = await nodeResponse.json();
// 缓存数据(修复键以包含 schemeName
nodeCacheRef.current.set(
`${query_time}_${junctionProperties}_${schemeName}_${schemeType}`,
nodeRecords || [],
);
}
if (linkPromise) {
const linkResponse = responses.shift()!;
if (!linkResponse.ok)
throw new Error(`Link fetch failed: ${linkResponse.status}`);
linkRecords = await linkResponse.json();
// 缓存数据(修复键以包含 schemeName
linkCacheRef.current.set(
`${query_time}_${pipeProperties}_${schemeName}_${schemeType}`,
linkRecords || [],
);
}
// 更新状态
updateDataStates(nodeRecords.results || [], linkRecords.results || []);
}, [disableDateSelection, updateDataStates]);
if (nodePromise) {
const nodeResponse = responses.shift()!;
if (!nodeResponse.ok) {
throw new Error(`Node fetch failed: ${nodeResponse.status}`);
}
nodeRecords = await nodeResponse.json();
nodeCacheRef.current.set(
buildCacheKey(
query_time,
junctionProperties,
sourceType,
"node",
schemeName || "",
schemeType || ""
),
nodeRecords || []
);
}
if (linkPromise) {
const linkResponse = responses.shift()!;
if (!linkResponse.ok) {
throw new Error(`Link fetch failed: ${linkResponse.status}`);
}
linkRecords = await linkResponse.json();
linkCacheRef.current.set(
buildCacheKey(
query_time,
normalizedPipeProperties,
sourceType,
"link",
schemeName || "",
schemeType || ""
),
linkRecords || []
);
}
updateDataStates(nodeRecords.results || [], linkRecords.results || [], target);
},
[buildCacheKey, updateDataStates]
);
const fetchFrameData = useCallback(
async (
queryTime: Date,
junctionProperties: string,
pipeProperties: string,
schemeName: string,
schemeType: string
) => {
const primarySourceType =
disableDateSelection && schemeName ? "scheme" : "realtime";
const tasks = [
fetchDataBySource({
queryTime,
junctionProperties,
pipeProperties,
sourceType: primarySourceType,
target: "primary",
schemeName,
schemeType,
}),
];
if (isCompareMode && disableDateSelection && schemeName) {
tasks.push(
fetchDataBySource({
queryTime,
junctionProperties,
pipeProperties,
sourceType: "realtime",
target: "compare",
})
);
}
await Promise.all(tasks);
},
[disableDateSelection, fetchDataBySource, isCompareMode]
);
// 时间刻度数组 (每5分钟一个刻度)
const timeMarks = Array.from({ length: 288 }, (_, i) => ({
@@ -453,9 +583,9 @@ const Timeline: React.FC<TimelineProps> = ({
if (!cacheRef.current) return;
const cacheKeys = Array.from(cacheRef.current.keys());
cacheKeys.forEach((key) => {
const keyParts = key.split("_");
const cacheDate = keyParts[0].split("T")[0];
const cacheTimeStr = keyParts[0].split("T")[1];
const cacheTimeKey = key.split("::")[0];
const cacheDate = cacheTimeKey.split("T")[0];
const cacheTimeStr = cacheTimeKey.split("T")[1];
if (cacheDate === dateStr && cacheTimeStr) {
const [hours, minutes] = cacheTimeStr.split(":");
@@ -493,7 +623,10 @@ const Timeline: React.FC<TimelineProps> = ({
// 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变
const calculationDate = selectedDate;
const calculationTime = currentTime;
const calculationDateStr = calculationDate.toISOString().split("T")[0];
const calculationDateTime = currentTimeToDate(
calculationDate,
calculationTime
);
setIsCalculating(true);
// 显示处理中的通知
@@ -505,8 +638,7 @@ const Timeline: React.FC<TimelineProps> = ({
try {
const body = {
name: NETWORK_NAME,
simulation_date: calculationDateStr, // YYYY-MM-DD
start_time: `${formatTime(calculationTime)}:00`, // HH:MM:00
start_time: dayjs(calculationDateTime).format("YYYY-MM-DDTHH:mm:ssZ"),
duration: calculatedInterval,
};
@@ -521,17 +653,22 @@ const Timeline: React.FC<TimelineProps> = ({
},
);
if (response.ok) {
const result = await response.json().catch(() => null);
if (response.ok && result?.status === "success") {
open?.({
type: "success",
message: "重新计算成功",
});
// 清空当天当前时刻及之后的缓存并重新获取数据
clearCacheAndRefetch(calculationDate, calculationTime);
setForceStyleAutoApplyVersion?.((prev) => prev + 1);
} else {
const errorMessage =
result?.detail || result?.message || "重新计算失败";
open?.({
type: "error",
message: "重新计算失败",
message: errorMessage,
});
}
} catch (error) {
@@ -553,7 +690,7 @@ const Timeline: React.FC<TimelineProps> = ({
<Draggable nodeRef={draggableRef} handle=".drag-handle">
<div
ref={draggableRef}
className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 w-[920px] opacity-90 hover:opacity-100 transition-opacity duration-300"
className="absolute bottom-4 left-1/2 z-10 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100"
>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<Paper
@@ -611,6 +748,7 @@ const Timeline: React.FC<TimelineProps> = ({
onClick={handleDayStepBackward}
size="small"
disabled={disableDateSelection}
sx={timelineIconButtonSx}
>
<FiSkipBack />
</IconButton>
@@ -645,6 +783,7 @@ const Timeline: React.FC<TimelineProps> = ({
selectedDate.toDateString() ===
new Date().toDateString()
}
sx={timelineIconButtonSx}
>
<FiSkipForward />
</IconButton>
@@ -673,6 +812,7 @@ const Timeline: React.FC<TimelineProps> = ({
color="primary"
onClick={handleStepBackward}
size="small"
sx={timelineIconButtonSx}
>
<TbRewindBackward15 />
</IconButton>
@@ -683,6 +823,7 @@ const Timeline: React.FC<TimelineProps> = ({
color="primary"
onClick={isPlaying ? handlePause : handlePlay}
size="small"
sx={timelineIconButtonSx}
>
{isPlaying ? <Pause /> : <PlayArrow />}
</IconButton>
@@ -693,6 +834,7 @@ const Timeline: React.FC<TimelineProps> = ({
color="primary"
onClick={handleStepForward}
size="small"
sx={timelineIconButtonSx}
>
<TbRewindForward15 />
</IconButton>
@@ -703,6 +845,7 @@ const Timeline: React.FC<TimelineProps> = ({
color="secondary"
onClick={handleStop}
size="small"
sx={timelineIconButtonSx}
>
<Stop />
</IconButton>
+102 -591
View File
@@ -1,32 +1,33 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { useData, useMap } from "../MapComponent";
import ToolbarButton from "@/components/olmap/common/ToolbarButton";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import PaletteOutlinedIcon from "@mui/icons-material/PaletteOutlined";
import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined";
import CompareArrowsOutlinedIcon from "@mui/icons-material/CompareArrowsOutlined";
import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件
import DrawPanel from "./DrawPanel"; // 引入绘图面板组件
import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
import VectorSource from "ol/source/Vector";
import VectorLayer from "ol/layer/Vector";
import { Style, Stroke, Fill, Circle } from "ol/style";
import Feature from "ol/Feature";
import { GeoJSON } from "ol/format";
import Point from "ol/geom/Point";
import { bbox, featureCollection } from "@turf/turf";
import StyleEditorPanel from "./StyleEditorPanel";
import { LayerStyleState } from "./StyleEditorPanel";
import { createDefaultLayerStyleStates } from "./styleEditorPresets";
import { LayerStyleState } from "./styleEditorTypes";
import StyleLegend from "./StyleLegend"; // 引入图例组件
import { handleMapClickSelectFeatures as mapClickSelectFeatures, queryFeaturesByIds } from "@/utils/mapQueryService";
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
import { useNotification } from "@refinedev/core";
import { useChatToolActionHandler } from "@/hooks/useChatToolActionHandler";
import ToolbarHistoryPanel from "./ToolbarHistoryPanel";
import {
buildFeatureProperties,
} from "./toolbarFeatureHelpers";
import { useToolbarChatActions } from "./useToolbarChatActions";
import { useStyleEditor } from "./useStyleEditor";
import { config } from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
// 添加接口定义隐藏按钮的props
interface ToolbarProps {
@@ -34,12 +35,14 @@ interface ToolbarProps {
queryType?: string; // 可选的查询类型参数
schemeType?: string; // 可选的方案类型参数
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
enableCompare?: boolean;
}
const Toolbar: React.FC<ToolbarProps> = ({
hiddenButtons,
queryType,
schemeType,
HistoryPanel,
enableCompare = false,
}) => {
const map = useMap();
const data = useData();
@@ -55,6 +58,17 @@ const Toolbar: React.FC<ToolbarProps> = ({
const currentTime = data?.currentTime;
const selectedDate = data?.selectedDate;
const schemeName = data?.schemeName;
const isCompareMode = data?.isCompareMode ?? false;
const toggleCompareMode = data?.toggleCompareMode;
const canToggleCompare = Boolean(
enableCompare && (isCompareMode || (queryType === "scheme" && schemeName)),
);
useEffect(() => {
if (!enableCompare && isCompareMode) {
toggleCompareMode?.();
}
}, [enableCompare, isCompareMode, toggleCompareMode]);
// Chat tool action → direct featureInfos override (bypasses OL Feature lookup)
const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState<
@@ -68,167 +82,26 @@ const Toolbar: React.FC<ToolbarProps> = ({
endTime?: string;
} | null>(null);
// Wire up chat tool actions (locate, view_history, view_scada)
useChatToolActionHandler(
useCallback(
(action) => {
const geojsonFormat = new GeoJSON();
const zoomToFeatures = (
features: Feature[],
geometryKind: "point" | "line",
) => {
if (features.length === 0) return;
if (geometryKind === "point" && features.length === 1) {
const geometry = features[0].getGeometry();
if (geometry instanceof Point) {
map?.getView().animate({
center: geometry.getCoordinates(),
zoom: 18,
duration: 1000,
});
return;
}
}
const geojsonFeatures = features.map((f) =>
geojsonFormat.writeFeatureObject(f),
);
const extent = bbox(featureCollection(geojsonFeatures as any));
if (extent) {
map?.getView().fit(extent, {
maxZoom: 18,
duration: 1000,
padding: geometryKind === "line" ? [60, 60, 60, 60] : [40, 40, 40, 40],
});
}
};
const locateFeatures = (
ids: string[],
layer: string,
geometryKind: "point" | "line",
) => {
queryFeaturesByIds(ids, layer).then((features) => {
if (features.length > 0) {
setHighlightFeatures(features);
zoomToFeatures(features, geometryKind);
}
});
};
switch (action.type) {
case "locate_features": {
locateFeatures(action.ids, action.layer, action.geometryKind);
break;
}
case "view_history": {
setChatPanelFeatureInfos(action.featureInfos);
setChatPanelType(action.dataType);
setChatPanelTimeRange({
startTime: action.startTime,
endTime: action.endTime,
});
setShowHistoryPanel(true);
break;
}
case "view_scada": {
setChatPanelFeatureInfos(action.featureInfos);
setChatPanelType("none");
setChatPanelTimeRange({
startTime: action.startTime,
endTime: action.endTime,
});
setShowHistoryPanel(true);
setActiveTools((prev) => {
if (prev.includes("history")) {
return prev;
}
return [...prev, "history"];
});
break;
}
}
},
[map],
),
);
// 样式状态管理 - 在 Toolbar 中管理,带有默认样式
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>([
{
isActive: false, // 默认不激活,不显示图例
layerId: "junctions",
layerName: "节点",
styleConfig: {
property: "pressure",
classificationMethod: "custom_breaks",
customBreaks: [16, 18, 20, 22, 24, 26],
customColors: [
"rgba(255, 0, 0, 1)",
"rgba(255, 127, 0, 1)",
"rgba(255, 215, 0, 1)",
"rgba(199, 224, 0, 1)",
"rgba(76, 175, 80, 1)",
"rgba(0, 158, 115, 1)",
],
segments: 6,
minSize: 4,
maxSize: 12,
minStrokeWidth: 2,
maxStrokeWidth: 8,
fixedStrokeWidth: 3,
colorType: "rainbow",
singlePaletteIndex: 0,
gradientPaletteIndex: 0,
rainbowPaletteIndex: 0,
showLabels: false,
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
},
legendConfig: {
layerId: "junctions",
layerName: "节点",
property: "压力", // 暂时为空,等计算后更新
colors: [],
type: "point",
dimensions: [],
breaks: [],
},
},
{
isActive: false, // 默认不激活,不显示图例
layerId: "pipes",
layerName: "管道",
styleConfig: {
property: "flow",
classificationMethod: "pretty_breaks",
segments: 6,
minSize: 4,
maxSize: 12,
minStrokeWidth: 2,
maxStrokeWidth: 8,
fixedStrokeWidth: 3,
colorType: "gradient",
singlePaletteIndex: 0,
gradientPaletteIndex: 0,
rainbowPaletteIndex: 0,
showLabels: false,
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
},
legendConfig: {
layerId: "pipes",
layerName: "管道",
property: "流量", // 暂时为空,等计算后更新
colors: [],
type: "linestring",
dimensions: [],
breaks: [],
},
},
]);
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>(
() => createDefaultLayerStyleStates()
);
const styleEditor = useStyleEditor({
layerStyleStates,
setLayerStyleStates,
});
useToolbarChatActions({
setHighlightFeatures,
setChatPanelFeatureInfos,
setChatPanelType,
setChatPanelTimeRange,
setShowHistoryPanel,
setShowStyleEditor,
setActiveTools,
applyExternalStyle: styleEditor.applyExternalStyle,
resetExternalStyle: styleEditor.resetExternalStyle,
});
// 计算激活的图例配置
const activeLegendConfigs = layerStyleStates
@@ -402,15 +275,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
deactivateTool(tool);
setActiveTools((prev) => prev.filter((t) => t !== tool));
} else {
// 如果当前工具未激活,先关闭所有其他工具,然后激活当前工具
// 关闭所有面板(但保持样式编辑器状态)
closeAllPanelsExceptStyle();
// 取消激活所有非样式工具
setActiveTools((prev) => {
const styleActive = prev.includes("style");
return styleActive ? ["style", tool] : [tool];
});
// 如果当前工具未激活,保留其他已打开工具,仅新增当前工具
setActiveTools((prev) => [...prev, tool]);
// 激活当前工具并打开对应面板
activateTool(tool);
@@ -422,14 +288,18 @@ const Toolbar: React.FC<ToolbarProps> = ({
switch (tool) {
case "info":
setShowPropertyPanel(false);
setHighlightFeatures([]);
if (!activeTools.includes("history")) {
setHighlightFeatures([]);
}
break;
case "draw":
setShowDrawPanel(false);
break;
case "history":
setShowHistoryPanel(false);
setHighlightFeatures([]);
if (!activeTools.includes("info")) {
setHighlightFeatures([]);
}
setChatPanelFeatureInfos(null);
setChatPanelTimeRange(null);
break;
@@ -452,16 +322,6 @@ const Toolbar: React.FC<ToolbarProps> = ({
}
};
// 关闭所有面板(除了样式编辑器)
const closeAllPanelsExceptStyle = () => {
setShowPropertyPanel(false);
setHighlightFeatures([]);
setShowDrawPanel(false);
setShowHistoryPanel(false);
setChatPanelFeatureInfos(null);
setChatPanelTimeRange(null);
// 样式编辑器保持其当前状态,不自动关闭
};
const [computedProperties, setComputedProperties] = useState<
Record<string, any>
>({});
@@ -526,306 +386,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
if (currentTime !== -1 && queryType) queryComputedProperties();
}, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]);
// 从要素属性中提取属性面板需要的数据
const getFeatureProperties = useCallback(() => {
if (highlightFeatures.length === 0) return {};
const highlightFeature = highlightFeatures[0];
const layer = highlightFeature?.getId()?.toString().split(".")[0];
const properties = highlightFeature.getProperties();
// 计算属性字段,增加 key 字段
const pipeComputedFields = [
{ key: "flow", label: "流量", unit: `${FLOW_DISPLAY_UNIT}` },
{ key: "friction", label: "摩阻", unit: "" },
{ key: "headloss", label: "水头损失", unit: "m" },
{ key: "unit_headloss", label: "单位水头损失", unit: "m/km" },
{ key: "quality", label: "水质", unit: "mg/L" },
{ key: "reaction", label: "反应", unit: "1/d" },
{ key: "setting", label: "设置", unit: "" },
{ key: "status", label: "状态", unit: "" },
{ key: "velocity", label: "流速", unit: "m/s" },
];
const nodeComputedFields = [
{ key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` },
{ key: "total_head", label: "水头", unit: "m" },
{ key: "pressure", label: "压力", unit: "m" },
{ key: "quality", label: "水质", unit: "mg/L" },
];
if (layer === "geo_pipes_mat" || layer === "geo_pipes") {
let result = {
id: properties.id,
type: "管道",
properties: [
{ label: "起始节点ID", value: properties.node1 },
{ label: "终点节点ID", value: properties.node2 },
{ label: "长度", value: properties.length?.toFixed?.(1), unit: "m" },
{
label: "管径",
value: properties.diameter?.toFixed?.(1),
unit: "mm",
},
{ label: "粗糙度", value: properties.roughness },
{ label: "局部损失", value: properties.minor_loss },
{ label: "初始状态", value: "开" },
],
};
// 追加计算属性
if (computedProperties) {
pipeComputedFields.forEach(({ key, label, unit }) => {
let value = computedProperties[key];
if (key === "flow" && value !== undefined) {
value = toM3h(value, "lps");
}
// 如果是单位水头损失且后端未返回,则通过水头损失/长度计算 (单位 m/km)
if (
key === "unit_headloss" &&
value === undefined &&
computedProperties.headloss !== undefined &&
properties.length
) {
value = (computedProperties.headloss / properties.length) * 1000;
}
if (value !== undefined) {
result.properties.push({
label,
value: typeof value === "number" ? value.toFixed(3) : value,
unit,
});
}
});
}
return result;
}
if (layer === "geo_junctions_mat" || layer === "geo_junctions") {
let result = {
id: properties.id,
type: "节点",
properties: [
{
label: "高程",
value: properties.elevation?.toFixed?.(1),
unit: "m",
},
// 将 demand1~demand5 与 pattern1~pattern5 作为二级表格展示
{
type: "table",
label: "基本需水量",
columns: ["demand", "pattern"],
rows: Array.from({ length: 5 }, (_, i) => i + 1)
.map((idx) => {
let d = properties?.[`demand${idx}`];
const p = properties?.[`pattern${idx}`];
// 仅当 demand 有效时展示该行
if (d !== undefined && d !== null && d !== "") {
d = toM3h(Number(d), "lps");
return [typeof d === "number" ? d.toFixed(3) : d, p ?? "-"];
}
})
.filter(Boolean) as (string | number)[][],
} as any,
],
};
// 追加计算属性
if (computedProperties) {
nodeComputedFields.forEach(({ key, label, unit }) => {
if (computedProperties[key] !== undefined) {
let value = computedProperties[key];
if (key === "actual_demand") {
value = toM3h(value, "lps");
}
result.properties.push({
label,
value:
value?.toFixed?.(3) || value,
unit,
});
}
});
}
return result;
}
if (layer === "geo_tanks_mat" || layer === "geo_tanks") {
return {
id: properties.id,
type: "水池",
properties: [
{
label: "高程",
value: properties.elevation?.toFixed?.(1),
unit: "m",
},
{
label: "初始水位",
value: properties.init_level?.toFixed?.(1),
unit: "m",
},
{
label: "最低水位",
value: properties.min_level?.toFixed?.(1),
unit: "m",
},
{
label: "最高水位",
value: properties.max_level?.toFixed?.(1),
unit: "m",
},
{
label: "直径",
value: properties.diameter?.toFixed?.(1),
unit: "m",
},
{
label: "最小容积",
value: properties.min_vol?.toFixed?.(1),
unit: "m³",
},
// {
// label: "容积曲线",
// value: properties.vol_curve,
// },
{
label: "溢出",
value: properties.overflow ? "是" : "否",
},
],
};
}
if (layer === "geo_reservoirs_mat" || layer === "geo_reservoirs") {
return {
id: properties.id,
type: "水库",
properties: [
{
label: "水头",
value: properties.head?.toFixed?.(1),
unit: "m",
},
// {
// label: "模式",
// value: properties.pattern,
// },
],
};
}
if (layer === "geo_pumps_mat" || layer === "geo_pumps") {
return {
id: properties.id,
type: "水泵",
properties: [
{ label: "起始节点 ID", value: properties.node1 },
{ label: "终点节点 ID", value: properties.node2 },
{
label: "功率",
value: properties.power?.toFixed?.(1),
unit: "kW",
},
{
label: "扬程",
value: properties.head?.toFixed?.(1),
unit: "m",
},
{
label: "转速",
value: properties.speed?.toFixed?.(1),
unit: "rpm",
},
{
label: "模式",
value: properties.pattern,
},
],
};
}
if (layer === "geo_valves_mat" || layer === "geo_valves") {
return {
id: properties.id,
type: "阀门",
properties: [
{ label: "起始节点 ID", value: properties.node1 },
{ label: "终点节点 ID", value: properties.node2 },
{
label: "直径",
value: properties.diameter?.toFixed?.(1),
unit: "mm",
},
{
label: "阀门类型",
value: properties.v_type,
},
// {
// label: "设置",
// value: properties.setting?.toFixed?.(2),
// },
{
label: "局部损失",
value: properties.minor_loss?.toFixed?.(2),
},
],
};
}
// 传输频率文字对应
const getTransmissionFrequency = (transmission_frequency: string) => {
// 传输频率文本:00:01:0000:05:0000:10:0000:30:0001:00:00,转换为分钟数
const parts = transmission_frequency.split(":");
if (parts.length !== 3) return transmission_frequency;
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
const seconds = parseInt(parts[2], 10);
const totalMinutes = hours * 60 + minutes + (seconds >= 30 ? 1 : 0);
return totalMinutes;
};
// 可靠度文字映射
const getReliability = (reliability: number) => {
switch (reliability) {
case 1:
return "高";
case 2:
return "中";
case 3:
return "低";
default:
return "未知";
}
};
if (layer === "geo_scada_mat" || layer === "geo_scada") {
let result = {
id: properties.id,
type: "SCADA设备",
properties: [
{
label: "类型",
value:
properties.type === "pipe_flow" ? "流量传感器" : "压力传感器",
},
{
label: "关联节点 ID",
value: properties.associated_element_id,
},
{
label: "传输模式",
value:
properties.transmission_mode === "non_realtime"
? "定时传输"
: "实时传输",
},
{
label: "传输频率",
value: getTransmissionFrequency(properties.transmission_frequency),
unit: "分钟",
},
{
label: "可靠性",
value: getReliability(properties.reliability),
},
],
};
return result;
}
return {};
}, [highlightFeatures, computedProperties]);
const propertyPanelData = useMemo(
() => buildFeatureProperties(highlightFeatures[0], computedProperties),
[highlightFeatures, computedProperties],
);
if (!data) {
return null;
@@ -866,112 +430,59 @@ const Toolbar: React.FC<ToolbarProps> = ({
onClick={() => handleToolClick("style")}
/>
)}
{enableCompare && (
<ToolbarButton
icon={<CompareArrowsOutlinedIcon />}
name={isCompareMode ? "关闭对比" : "双屏对比"}
isActive={isCompareMode}
onClick={() => toggleCompareMode?.()}
disabled={!canToggleCompare}
/>
)}
</div>
{showPropertyPanel && <PropertyPanel {...getFeatureProperties()} />}
{showPropertyPanel && (
<PropertyPanel
{...propertyPanelData}
onClose={() => {
deactivateTool("info");
setActiveTools((prev) => prev.filter((t) => t !== "info"));
}}
/>
)}
{showDrawPanel && map && <DrawPanel />}
<div style={{ display: showStyleEditor ? "block" : "none" }}>
<StyleEditorPanel
layerStyleStates={layerStyleStates}
setLayerStyleStates={setLayerStyleStates}
isReady={styleEditor.isReady}
renderLayers={styleEditor.renderLayers}
selectedRenderLayer={styleEditor.selectedRenderLayer}
styleConfig={styleEditor.styleConfig}
setStyleConfig={styleEditor.setStyleConfig}
availableProperties={styleEditor.availableProperties}
onLayerChange={styleEditor.handleLayerChange}
onPropertyChange={styleEditor.handlePropertyChange}
onClassificationMethodChange={styleEditor.handleClassificationMethodChange}
onSegmentsChange={styleEditor.handleSegmentsChange}
onCustomBreakChange={styleEditor.handleCustomBreakChange}
onCustomBreakBlur={styleEditor.handleCustomBreakBlur}
onColorTypeChange={styleEditor.handleColorTypeChange}
onApply={styleEditor.handleApply}
onReset={styleEditor.handleReset}
/>
</div>
{showHistoryPanel &&
(chatPanelType === "none" && chatPanelFeatureInfos ? (
<SCADADataPanel
deviceIds={chatPanelFeatureInfos.map(([id]) => id)}
visible={showHistoryPanel}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
/>
) : HistoryPanel ? (
<HistoryPanel
featureInfos={chatPanelFeatureInfos ?? (() => {
if (highlightFeatures.length === 0 || !showHistoryPanel)
return [];
return highlightFeatures
.map((feature) => {
const properties = feature.getProperties();
const id = properties.id;
if (!id) return null;
// 从图层名称推断类型
const layerId =
feature.getId()?.toString().split(".")[0] || "";
let type = "unknown";
if (layerId.includes("pipe")) {
type = "pipe";
} else if (layerId.includes("junction")) {
type = "junction";
} else if (layerId.includes("tank")) {
type = "tank";
} else if (layerId.includes("reservoir")) {
type = "reservoir";
} else if (layerId.includes("pump")) {
type = "pump";
} else if (layerId.includes("valve")) {
type = "valve";
}
// 仅处理 type 为 pipe 或 junction 的情况
if (type !== "pipe" && type !== "junction") {
return null;
}
return [id, type];
})
.filter(Boolean) as [string, string][];
})()}
scheme_type="burst_analysis"
scheme_name={schemeName}
type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
/>
) : (
<HistoryDataPanel
featureInfos={chatPanelFeatureInfos ?? (() => {
if (highlightFeatures.length === 0 || !showHistoryPanel)
return [];
return highlightFeatures
.map((feature) => {
const properties = feature.getProperties();
const id = properties.id;
if (!id) return null;
// 从图层名称推断类型
const layerId =
feature.getId()?.toString().split(".")[0] || "";
let type = "unknown";
if (layerId.includes("pipe")) {
type = "pipe";
} else if (layerId.includes("junction")) {
type = "junction";
} else if (layerId.includes("tank")) {
type = "tank";
} else if (layerId.includes("reservoir")) {
type = "reservoir";
} else if (layerId.includes("pump")) {
type = "pump";
} else if (layerId.includes("valve")) {
type = "valve";
}
// 仅处理 type 为 pipe 或 junction 的情况
if (type !== "pipe" && type !== "junction") {
return null;
}
return [id, type];
})
.filter(Boolean) as [string, string][];
})()}
scheme_type="burst_analysis"
scheme_name={schemeName}
type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
/>
))}
<ToolbarHistoryPanel
showHistoryPanel={showHistoryPanel}
chatPanelType={chatPanelType}
chatPanelFeatureInfos={chatPanelFeatureInfos}
chatPanelTimeRange={chatPanelTimeRange}
highlightFeatures={highlightFeatures}
HistoryPanel={HistoryPanel}
schemeName={schemeName}
queryType={queryType}
onClose={() => {
deactivateTool("history");
setActiveTools((prev) => prev.filter((t) => t !== "history"));
}}
/>
{/* 图例显示 */}
{activeLegendConfigs.length > 0 && (
@@ -0,0 +1,92 @@
"use client";
import React, { useMemo } from "react";
import Feature from "ol/Feature";
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
import { inferHistoryFeatureInfos } from "./toolbarFeatureHelpers";
import HistoryDataPanel from "./HistoryDataPanel";
type ToolbarHistoryPanelProps = {
showHistoryPanel: boolean;
chatPanelType: "realtime" | "scheme" | "none";
chatPanelFeatureInfos: [string, string][] | null;
chatPanelTimeRange: {
startTime?: string;
endTime?: string;
} | null;
highlightFeatures: Feature[];
HistoryPanel?: React.FC<any>;
schemeName?: string;
queryType?: string;
onClose: () => void;
};
const ToolbarHistoryPanel: React.FC<ToolbarHistoryPanelProps> = ({
showHistoryPanel,
chatPanelType,
chatPanelFeatureInfos,
chatPanelTimeRange,
highlightFeatures,
HistoryPanel,
schemeName,
queryType,
onClose,
}) => {
const featureInfos = useMemo(
() => chatPanelFeatureInfos ?? inferHistoryFeatureInfos(highlightFeatures),
[chatPanelFeatureInfos, highlightFeatures],
);
if (!showHistoryPanel) {
return null;
}
if (chatPanelType === "none" && chatPanelFeatureInfos) {
return (
<SCADADataPanel
deviceIds={chatPanelFeatureInfos.map(([id]) => id)}
visible={showHistoryPanel}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
onClose={onClose}
/>
);
}
if (HistoryPanel) {
return (
<HistoryPanel
featureInfos={featureInfos}
scheme_type="burst_analysis"
scheme_name={schemeName}
type={
chatPanelFeatureInfos
? chatPanelType
: (queryType as "realtime" | "scheme" | "none")
}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
onClose={onClose}
/>
);
}
return (
<HistoryDataPanel
featureInfos={featureInfos}
scheme_type="burst_analysis"
scheme_name={schemeName}
type={
chatPanelFeatureInfos
? chatPanelType
: (queryType as "realtime" | "scheme" | "none")
}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
onClose={onClose}
/>
);
};
export default ToolbarHistoryPanel;
@@ -0,0 +1,200 @@
import { LayerStyleState, StyleConfig, DefaultLayerStyleId } from "./styleEditorTypes";
export const SINGLE_COLOR_PALETTES = [
{ color: "rgba(51, 153, 204, 1)" },
{ color: "rgba(255, 138, 92, 1)" },
{ color: "rgba(204, 51, 51, 1)" },
{ color: "rgba(255, 235, 59, 1)" },
{ color: "rgba(44, 160, 44, 1)" },
{ color: "rgba(227, 119, 194, 1)" },
{ color: "rgba(148, 103, 189, 1)" },
];
export const GRADIENT_PALETTES = [
{
name: "蓝-红",
start: "rgba(51, 153, 204, 1)",
end: "rgba(204, 51, 51, 1)",
},
{
name: "黄-绿",
start: "rgba(255, 235, 59, 1)",
end: "rgba(44, 160, 44, 1)",
},
{
name: "粉-紫",
start: "rgba(227, 119, 194, 1)",
end: "rgba(148, 103, 189, 1)",
},
];
export const RAINBOW_PALETTES = [
{
name: "正向彩虹",
colors: [
"rgba(255, 0, 0, 1)",
"rgba(255, 127, 0, 1)",
"rgba(255, 215, 0, 1)",
"rgba(199, 224, 0, 1)",
"rgba(76, 175, 80, 1)",
"rgba(0, 158, 115, 1)",
"rgba(0, 188, 212, 1)",
"rgba(33, 150, 243, 1)",
"rgba(63, 81, 181, 1)",
"rgba(142, 68, 173, 1)",
],
},
{
name: "反向彩虹",
colors: [
"rgba(142, 68, 173, 1)",
"rgba(63, 81, 181, 1)",
"rgba(33, 150, 243, 1)",
"rgba(0, 188, 212, 1)",
"rgba(0, 158, 115, 1)",
"rgba(76, 175, 80, 1)",
"rgba(199, 224, 0, 1)",
"rgba(255, 215, 0, 1)",
"rgba(255, 127, 0, 1)",
"rgba(255, 0, 0, 1)",
],
},
];
export const CLASSIFICATION_METHODS = [
{ name: "优雅分段", value: "pretty_breaks" },
{ name: "自定义", value: "custom_breaks" },
];
export const COLOR_TYPE_OPTIONS = [
{ label: "单一色", value: "single" },
{ label: "渐进色", value: "gradient" },
{ label: "离散彩虹", value: "rainbow" },
{ label: "自定义", value: "custom" },
];
const DEFAULT_LAYER_STYLE_PRESETS: Record<
DefaultLayerStyleId,
Omit<LayerStyleState, "isActive">
> = {
junctions: {
layerId: "junctions",
layerName: "节点",
styleConfig: {
property: "pressure",
classificationMethod: "custom_breaks",
customBreaks: [16, 18, 20, 22, 24, 26],
customColors: [
"rgba(255, 0, 0, 1)",
"rgba(255, 127, 0, 1)",
"rgba(255, 215, 0, 1)",
"rgba(199, 224, 0, 1)",
"rgba(76, 175, 80, 1)",
"rgba(0, 158, 115, 1)",
],
segments: 6,
minSize: 4,
maxSize: 12,
minStrokeWidth: 2,
maxStrokeWidth: 8,
fixedStrokeWidth: 3,
colorType: "rainbow",
singlePaletteIndex: 0,
gradientPaletteIndex: 0,
rainbowPaletteIndex: 0,
showLabels: true,
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
},
legendConfig: {
layerId: "junctions",
layerName: "节点",
property: "压力",
colors: [],
type: "point",
dimensions: [],
breaks: [],
},
},
pipes: {
layerId: "pipes",
layerName: "管道",
styleConfig: {
property: "velocity",
classificationMethod: "custom_breaks",
segments: 6,
minSize: 4,
maxSize: 12,
minStrokeWidth: 2,
maxStrokeWidth: 8,
fixedStrokeWidth: 3,
colorType: "gradient",
singlePaletteIndex: 0,
gradientPaletteIndex: 0,
rainbowPaletteIndex: 0,
showLabels: true,
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
customBreaks: [0.2, 0.4, 0.6, 0.8, 1.0, 1.2],
customColors: [],
},
legendConfig: {
layerId: "pipes",
layerName: "管道",
property: "流速",
colors: [],
type: "linestring",
dimensions: [],
breaks: [],
},
},
};
export const createEmptyStyleConfig = (): StyleConfig => ({
property: "",
classificationMethod: "pretty_breaks",
segments: 5,
minSize: 4,
maxSize: 12,
minStrokeWidth: 2,
maxStrokeWidth: 6,
fixedStrokeWidth: 3,
colorType: "single",
singlePaletteIndex: 0,
gradientPaletteIndex: 0,
rainbowPaletteIndex: 0,
showLabels: false,
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
customBreaks: [],
customColors: [],
});
export const createDefaultLayerStyleState = (
layerId: DefaultLayerStyleId
): LayerStyleState => {
const preset = DEFAULT_LAYER_STYLE_PRESETS[layerId];
return {
...preset,
styleConfig: {
...preset.styleConfig,
customBreaks: [...(preset.styleConfig.customBreaks || [])],
customColors: [...(preset.styleConfig.customColors || [])],
},
legendConfig: {
...preset.legendConfig,
colors: [...preset.legendConfig.colors],
dimensions: [...preset.legendConfig.dimensions],
breaks: [...preset.legendConfig.breaks],
},
isActive: false,
};
};
export const createDefaultLayerStyleStates = (): LayerStyleState[] => [
createDefaultLayerStyleState("junctions"),
createDefaultLayerStyleState("pipes"),
];
@@ -0,0 +1,66 @@
import React from "react";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import { LegendStyleConfig } from "./StyleLegend";
export interface StyleConfig {
property: string;
classificationMethod: string;
segments: number;
minSize: number;
maxSize: number;
minStrokeWidth: number;
maxStrokeWidth: number;
fixedStrokeWidth: number;
colorType: string;
singlePaletteIndex: number;
gradientPaletteIndex: number;
rainbowPaletteIndex: number;
showLabels: boolean;
showId: boolean;
opacity: number;
adjustWidthByProperty: boolean;
customBreaks?: number[];
customColors?: string[];
}
export interface LayerStyleState {
layerId: string;
layerName: string;
styleConfig: StyleConfig;
legendConfig: LegendStyleConfig;
isActive: boolean;
}
export type DefaultLayerStyleId = "junctions" | "pipes";
export interface StyleEditorStateProps {
layerStyleStates: LayerStyleState[];
setLayerStyleStates: React.Dispatch<React.SetStateAction<LayerStyleState[]>>;
}
export interface AvailableProperty {
name: string;
value: string;
}
export interface StyleEditorFormProps {
renderLayers: WebGLVectorTileLayer[];
selectedRenderLayer?: WebGLVectorTileLayer;
styleConfig: StyleConfig;
setStyleConfig: React.Dispatch<React.SetStateAction<StyleConfig>>;
availableProperties: AvailableProperty[];
onLayerChange: (index: number) => void;
onPropertyChange: (property: string) => void;
onClassificationMethodChange: (method: string) => void;
onSegmentsChange: (segments: number) => void;
onCustomBreakChange: (index: number, value: string) => void;
onCustomBreakBlur: () => void;
onColorTypeChange: (colorType: string) => void;
onApply: () => void;
onReset: () => void;
}
export interface StyleEditorPanelProps extends StyleEditorFormProps {
isReady: boolean;
}
@@ -0,0 +1,348 @@
import { FlatStyleLike } from "ol/style/flat";
import { calculateClassification } from "@utils/breaks_classification";
import { parseColor } from "@utils/parseColor";
import {
GRADIENT_PALETTES,
RAINBOW_PALETTES,
SINGLE_COLOR_PALETTES,
} from "./styleEditorPresets";
import { StyleConfig } from "./styleEditorTypes";
export const rgbaToHex = (rgba: string) => {
try {
const c = parseColor(rgba);
const toHex = (n: number) => {
const hex = Math.round(n).toString(16);
return hex.length === 1 ? `0${hex}` : hex;
};
return `#${toHex(c.r)}${toHex(c.g)}${toHex(c.b)}`;
} catch {
return "#000000";
}
};
export const hexToRgba = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(
result[3],
16
)}, 1)`
: "rgba(0, 0, 0, 1)";
};
export const getDefaultCustomColors = (
segments: number,
existingColors: string[] = []
) => {
const nextColors = [...existingColors];
const baseColors = RAINBOW_PALETTES[0].colors;
while (nextColors.length < segments) {
nextColors.push(baseColors[nextColors.length % baseColors.length]);
}
return nextColors.slice(0, segments);
};
export const getDefaultCustomBreaks = ({
segments,
property,
layerId,
elevationRange,
diameterRange,
currentJunctionCalData,
currentPipeCalData,
}: {
segments: number;
property: string;
layerId?: string;
elevationRange?: [number, number];
diameterRange?: [number, number];
currentJunctionCalData?: any[];
currentPipeCalData?: any[];
}) => {
if (!layerId || !property) {
return Array.from({ length: segments }, () => 0);
}
let dataArr: number[] = [];
const isElevation = layerId === "junctions" && property === "elevation";
const isDiameter = layerId === "pipes" && property === "diameter";
if (isElevation && elevationRange) {
dataArr = [elevationRange[0], elevationRange[1]];
} else if (isDiameter && diameterRange) {
dataArr = [diameterRange[0], diameterRange[1]];
} else if (layerId === "junctions" && currentJunctionCalData) {
dataArr = currentJunctionCalData.map((d: any) => d.value);
} else if (layerId === "pipes" && currentPipeCalData) {
dataArr = currentPipeCalData.map((d: any) => d.value);
}
if (dataArr.length === 0) {
return Array.from({ length: segments }, () => 0);
}
const defaultBreaks = calculateClassification(
dataArr,
segments,
"pretty_breaks"
).slice(0, segments);
while (defaultBreaks.length < segments) {
defaultBreaks.push(defaultBreaks[defaultBreaks.length - 1] ?? 0);
}
return defaultBreaks;
};
export const normalizeCustomBreaks = (breaks: number[], desired: number) => {
const nextBreaks = [...breaks]
.slice(0, desired)
.filter((value) => value >= 0)
.sort((a, b) => a - b);
while (nextBreaks.length < desired) {
nextBreaks.push(nextBreaks[nextBreaks.length - 1] ?? 0);
}
return nextBreaks;
};
export const addBreakExtrema = (breaks: number[], dataValues: number[]) => {
const nextBreaks = [...breaks];
const minValue = Math.max(
dataValues.reduce((min, value) => Math.min(min, value), Infinity),
0
);
const maxValue = dataValues.reduce(
(max, value) => Math.max(max, value),
-Infinity
);
if (!nextBreaks.includes(minValue)) {
nextBreaks.push(minValue);
}
if (!nextBreaks.includes(maxValue)) {
nextBreaks.push(maxValue);
}
nextBreaks.sort((a, b) => a - b);
return nextBreaks;
};
export const resolveStyleColors = (
styleConfig: StyleConfig,
breaksLength: number
): string[] => {
if (styleConfig.colorType === "single") {
return Array.from(
{ length: breaksLength },
() => SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color
);
}
if (styleConfig.colorType === "gradient") {
const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex];
const startColor = parseColor(start);
const endColor = parseColor(end);
return Array.from({ length: breaksLength }, (_, index) => {
const ratio = breaksLength > 1 ? index / (breaksLength - 1) : 1;
const r = Math.round(startColor.r + (endColor.r - startColor.r) * ratio);
const g = Math.round(startColor.g + (endColor.g - startColor.g) * ratio);
const b = Math.round(startColor.b + (endColor.b - startColor.b) * ratio);
return `rgba(${r}, ${g}, ${b}, 1)`;
});
}
if (styleConfig.colorType === "rainbow") {
const baseColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors;
return Array.from(
{ length: breaksLength },
(_, index) => baseColors[index % baseColors.length]
);
}
const customColors = styleConfig.customColors || [];
const reverseRainbowColors = RAINBOW_PALETTES[1].colors;
const result = [...customColors];
while (result.length < breaksLength) {
result.push(
reverseRainbowColors[
(result.length - customColors.length) % reverseRainbowColors.length
]
);
}
return result.slice(0, breaksLength);
};
export const getSizePreviewColors = (styleConfig: StyleConfig) => {
if (styleConfig.colorType === "single") {
const color = SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color;
return [color, color];
}
if (styleConfig.colorType === "gradient") {
const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex];
return [start, end];
}
if (styleConfig.colorType === "rainbow") {
const rainbowColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors;
return [rainbowColors[0], rainbowColors[rainbowColors.length - 1]];
}
const customColors = styleConfig.customColors || [];
return [
customColors[0] || "rgba(0,0,0,1)",
customColors[customColors.length - 1] || "rgba(0,0,0,1)",
];
};
export const resolveDimensions = ({
layerType,
styleConfig,
breaksLength,
}: {
layerType: string;
styleConfig: StyleConfig;
breaksLength: number;
}) => {
if (layerType === "linestring") {
if (styleConfig.adjustWidthByProperty) {
return Array.from({ length: breaksLength }, (_, index) => {
const ratio = index / (breaksLength - 1);
return (
styleConfig.minStrokeWidth +
(styleConfig.maxStrokeWidth - styleConfig.minStrokeWidth) * ratio
);
});
}
return Array.from(
{ length: breaksLength },
() => styleConfig.fixedStrokeWidth
);
}
return Array.from({ length: breaksLength }, (_, index) => {
const ratio = index / (breaksLength - 1);
return styleConfig.minSize + (styleConfig.maxSize - styleConfig.minSize) * ratio;
});
};
export const buildDynamicStyle = ({
layerType,
styleConfig,
breaks,
colors,
dimensions,
}: {
layerType: string;
styleConfig: StyleConfig;
breaks: number[];
colors: string[];
dimensions: number[];
}): FlatStyleLike => {
const generateColorConditions = (property: string): any[] => {
const conditions: any[] = ["case"];
for (let index = 1; index < breaks.length; index++) {
if (property === "unit_headloss") {
conditions.push([
"<=",
["/", ["get", "unit_headloss"], ["/", ["get", "length"], 1000]],
breaks[index],
]);
} else {
conditions.push(["<=", ["get", property], breaks[index]]);
}
const colorObj = parseColor(colors[index - 1]);
conditions.push(
`rgba(${colorObj.r}, ${colorObj.g}, ${colorObj.b}, ${styleConfig.opacity})`
);
}
const defaultColor = parseColor(colors[0]);
conditions.push(
`rgba(${defaultColor.r}, ${defaultColor.g}, ${defaultColor.b}, ${styleConfig.opacity})`
);
return conditions;
};
const generateDimensionConditions = (property: string): any[] => {
const conditions: any[] = ["case"];
for (let index = 0; index < breaks.length; index++) {
if (property === "unit_headloss") {
conditions.push([
"<=",
["/", ["get", "headloss"], ["get", "length"]],
breaks[index],
]);
} else {
conditions.push(["<=", ["get", property], breaks[index]]);
}
conditions.push(dimensions[index]);
}
conditions.push(dimensions[dimensions.length - 1]);
return conditions;
};
const generatePointDimensionConditions = (property: string): any[] => {
const conditions: any[] = ["case"];
for (let index = 0; index < breaks.length; index++) {
conditions.push(["<=", ["get", property], breaks[index]]);
conditions.push(["interpolate", ["linear"], ["zoom"], 12, 1, 24, dimensions[index]]);
}
conditions.push(dimensions[dimensions.length - 1]);
return conditions;
};
const dynamicStyle: FlatStyleLike = {};
if (layerType === "linestring") {
dynamicStyle["stroke-color"] = generateColorConditions(styleConfig.property);
dynamicStyle["stroke-width"] = generateDimensionConditions(styleConfig.property);
} else if (layerType === "point") {
dynamicStyle["circle-fill-color"] = generateColorConditions(styleConfig.property);
dynamicStyle["circle-radius"] = generatePointDimensionConditions(
styleConfig.property
);
dynamicStyle["circle-stroke-color"] = generateColorConditions(styleConfig.property);
dynamicStyle["circle-stroke-width"] = 2;
}
return dynamicStyle;
};
export const buildContourDefinitions = ({
styleConfig,
breaks,
colors,
}: {
styleConfig: StyleConfig;
breaks: number[];
colors: string[];
}) => {
const contours = [];
for (let index = 0; index < breaks.length - 1; index++) {
const colorObj = parseColor(colors[index]);
contours.push({
threshold: [breaks[index], breaks[index + 1]],
color: [
colorObj.r,
colorObj.g,
colorObj.b,
Math.round(styleConfig.opacity * 255),
],
strokeWidth: 0,
});
}
return contours;
};
@@ -0,0 +1,350 @@
import Feature from "ol/Feature";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
type ToolbarBaseProperty = {
label: string;
value: string | number;
unit?: string;
formatter?: (value: string | number) => string;
};
type ToolbarTableProperty = {
type: "table";
label: string;
columns: string[];
rows: (string | number)[][];
};
export type ToolbarPropertyItem = ToolbarBaseProperty | ToolbarTableProperty;
export type ToolbarPropertyPanelData = {
id?: string;
type?: string;
properties?: ToolbarPropertyItem[];
};
const getFeatureHistoryType = (feature: Feature): string | null => {
const layerId = feature.getId()?.toString().split(".")[0] || "";
if (layerId.includes("pipe")) return "pipe";
if (layerId.includes("junction")) return "junction";
if (layerId.includes("tank")) return "tank";
if (layerId.includes("reservoir")) return "reservoir";
if (layerId.includes("pump")) return "pump";
if (layerId.includes("valve")) return "valve";
return null;
};
export const inferHistoryFeatureInfos = (
highlightFeatures: Feature[],
): [string, string][] =>
highlightFeatures
.map((feature) => {
const properties = feature.getProperties();
const id = properties.id;
if (!id) return null;
const type = getFeatureHistoryType(feature);
if (type !== "pipe" && type !== "junction") {
return null;
}
return [id, type] as [string, string];
})
.filter(Boolean) as [string, string][];
export const buildFeatureProperties = (
highlightFeature: Feature | undefined,
computedProperties: Record<string, any>,
): ToolbarPropertyPanelData => {
if (!highlightFeature) return {};
const layer = highlightFeature.getId()?.toString().split(".")[0];
const properties = highlightFeature.getProperties();
const pipeComputedFields = [
{ key: "flow", label: "流量", unit: `${FLOW_DISPLAY_UNIT}` },
{ key: "friction", label: "摩阻", unit: "" },
{ key: "headloss", label: "水头损失", unit: "m" },
{ key: "unit_headloss", label: "单位水头损失", unit: "m/km" },
{ key: "quality", label: "水质", unit: "mg/L" },
{ key: "reaction", label: "反应", unit: "1/d" },
{ key: "setting", label: "设置", unit: "" },
{ key: "status", label: "状态", unit: "" },
{ key: "velocity", label: "流速", unit: "m/s" },
];
const nodeComputedFields = [
{ key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` },
{ key: "total_head", label: "水头", unit: "m" },
{ key: "pressure", label: "压力", unit: "m" },
{ key: "quality", label: "水质", unit: "mg/L" },
];
if (layer === "geo_pipes_mat" || layer === "geo_pipes") {
const result: ToolbarPropertyPanelData = {
id: properties.id,
type: "管道",
properties: [
{ label: "起始节点ID", value: properties.node1 },
{ label: "终点节点ID", value: properties.node2 },
{ label: "长度", value: properties.length?.toFixed?.(1), unit: "m" },
{
label: "管径",
value: properties.diameter?.toFixed?.(1),
unit: "mm",
},
{ label: "粗糙度", value: properties.roughness },
{ label: "局部损失", value: properties.minor_loss },
{ label: "初始状态", value: "开" },
],
};
pipeComputedFields.forEach(({ key, label, unit }) => {
let value = computedProperties[key];
if (key === "flow" && value !== undefined) {
value = toM3h(value, "lps");
}
if (
key === "unit_headloss" &&
value === undefined &&
computedProperties.headloss !== undefined &&
properties.length
) {
value = (computedProperties.headloss / properties.length) * 1000;
}
if (value !== undefined) {
result.properties?.push({
label,
value: typeof value === "number" ? value.toFixed(3) : value,
unit,
});
}
});
return result;
}
if (layer === "geo_junctions_mat" || layer === "geo_junctions") {
const result: ToolbarPropertyPanelData = {
id: properties.id,
type: "节点",
properties: [
{
label: "高程",
value: properties.elevation?.toFixed?.(1),
unit: "m",
},
{
type: "table",
label: "基本需水量",
columns: ["demand", "pattern"],
rows: Array.from({ length: 5 }, (_, i) => i + 1)
.map((idx) => {
let demand = properties?.[`demand${idx}`];
const pattern = properties?.[`pattern${idx}`];
if (
demand !== undefined &&
demand !== null &&
demand !== ""
) {
demand = toM3h(Number(demand), "lps");
return [
typeof demand === "number" ? demand.toFixed(3) : demand,
pattern ?? "-",
];
}
return null;
})
.filter(Boolean) as (string | number)[][],
},
],
};
nodeComputedFields.forEach(({ key, label, unit }) => {
if (computedProperties[key] !== undefined) {
let value = computedProperties[key];
if (key === "actual_demand") {
value = toM3h(value, "lps");
}
result.properties?.push({
label,
value: value?.toFixed?.(3) || value,
unit,
});
}
});
return result;
}
if (layer === "geo_tanks_mat" || layer === "geo_tanks") {
return {
id: properties.id,
type: "水池",
properties: [
{
label: "高程",
value: properties.elevation?.toFixed?.(1),
unit: "m",
},
{
label: "初始水位",
value: properties.init_level?.toFixed?.(1),
unit: "m",
},
{
label: "最低水位",
value: properties.min_level?.toFixed?.(1),
unit: "m",
},
{
label: "最高水位",
value: properties.max_level?.toFixed?.(1),
unit: "m",
},
{
label: "直径",
value: properties.diameter?.toFixed?.(1),
unit: "m",
},
{
label: "最小容积",
value: properties.min_vol?.toFixed?.(1),
unit: "m³",
},
{
label: "溢出",
value: properties.overflow ? "是" : "否",
},
],
};
}
if (layer === "geo_reservoirs_mat" || layer === "geo_reservoirs") {
return {
id: properties.id,
type: "水库",
properties: [
{
label: "水头",
value: properties.head?.toFixed?.(1),
unit: "m",
},
],
};
}
if (layer === "geo_pumps_mat" || layer === "geo_pumps") {
return {
id: properties.id,
type: "水泵",
properties: [
{ label: "起始节点 ID", value: properties.node1 },
{ label: "终点节点 ID", value: properties.node2 },
{
label: "功率",
value: properties.power?.toFixed?.(1),
unit: "kW",
},
{
label: "扬程",
value: properties.head?.toFixed?.(1),
unit: "m",
},
{
label: "转速",
value: properties.speed?.toFixed?.(1),
unit: "rpm",
},
{
label: "模式",
value: properties.pattern,
},
],
};
}
if (layer === "geo_valves_mat" || layer === "geo_valves") {
return {
id: properties.id,
type: "阀门",
properties: [
{ label: "起始节点 ID", value: properties.node1 },
{ label: "终点节点 ID", value: properties.node2 },
{
label: "直径",
value: properties.diameter?.toFixed?.(1),
unit: "mm",
},
{
label: "阀门类型",
value: properties.v_type,
},
{
label: "局部损失",
value: properties.minor_loss?.toFixed?.(2),
},
],
};
}
const getTransmissionFrequency = (transmissionFrequency: string) => {
const parts = transmissionFrequency.split(":");
if (parts.length !== 3) return transmissionFrequency;
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
const seconds = parseInt(parts[2], 10);
return hours * 60 + minutes + (seconds >= 30 ? 1 : 0);
};
const getReliability = (reliability: number) => {
switch (reliability) {
case 1:
return "高";
case 2:
return "中";
case 3:
return "低";
default:
return "未知";
}
};
if (layer === "geo_scada_mat" || layer === "geo_scada") {
return {
id: properties.id,
type: "SCADA设备",
properties: [
{
label: "类型",
value:
properties.type === "pipe_flow" ? "流量传感器" : "压力传感器",
},
{
label: "关联节点 ID",
value: properties.associated_element_id,
},
{
label: "传输模式",
value:
properties.transmission_mode === "non_realtime"
? "定时传输"
: "实时传输",
},
{
label: "传输频率",
value: getTransmissionFrequency(properties.transmission_frequency),
unit: "分钟",
},
{
label: "可靠性",
value: getReliability(properties.reliability),
},
],
};
}
return {};
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,259 @@
import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from "react";
import Feature from "ol/Feature";
import { GeoJSON } from "ol/format";
import Point from "ol/geom/Point";
import { transform } from "ol/proj";
import { bbox, featureCollection } from "@turf/turf";
import { useChatToolActionHandler } from "@/hooks/useChatToolActionHandler";
import {
applyJunctionAreaRender,
type JunctionAreaRenderPayload,
} from "@components/olmap/DMALeakDetection/applyJunctionAreaRender";
import { apiFetch } from "@/lib/apiFetch";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { config } from "@/config/config";
import { useMap } from "../MapComponent";
import type { DefaultLayerStyleId, StyleConfig } from "./styleEditorTypes";
type UseToolbarChatActionsParams = {
setHighlightFeatures: Dispatch<SetStateAction<Feature[]>>;
setChatPanelFeatureInfos: Dispatch<SetStateAction<[string, string][] | null>>;
setChatPanelType: Dispatch<SetStateAction<"realtime" | "scheme" | "none">>;
setChatPanelTimeRange: Dispatch<
SetStateAction<{ startTime?: string; endTime?: string } | null>
>;
setShowHistoryPanel: Dispatch<SetStateAction<boolean>>;
setShowStyleEditor: Dispatch<SetStateAction<boolean>>;
setActiveTools: Dispatch<SetStateAction<string[]>>;
applyExternalStyle: (
layerId: DefaultLayerStyleId,
styleConfig?: Partial<StyleConfig>
) => void;
resetExternalStyle: (layerId: DefaultLayerStyleId) => void;
};
export const useToolbarChatActions = ({
setHighlightFeatures,
setChatPanelFeatureInfos,
setChatPanelType,
setChatPanelTimeRange,
setShowHistoryPanel,
setShowStyleEditor,
setActiveTools,
applyExternalStyle,
resetExternalStyle,
}: UseToolbarChatActionsParams) => {
const map = useMap();
const chatJunctionRenderCleanupRef = useRef<(() => void) | null>(null);
const renderRequestSeqRef = useRef(0);
const disposeChatJunctionRender = useCallback(() => {
chatJunctionRenderCleanupRef.current?.();
chatJunctionRenderCleanupRef.current = null;
}, []);
useEffect(() => () => disposeChatJunctionRender(), [disposeChatJunctionRender]);
useChatToolActionHandler(
useCallback(
(action) => {
const geojsonFormat = new GeoJSON();
const zoomToFeatures = (
features: Feature[],
geometryKind: "point" | "line",
) => {
if (features.length === 0) return;
if (geometryKind === "point" && features.length === 1) {
const geometry = features[0].getGeometry();
if (geometry instanceof Point) {
map?.getView().animate({
center: geometry.getCoordinates(),
zoom: 18,
duration: 1000,
});
return;
}
}
const geojsonFeatures = features.map((feature) =>
geojsonFormat.writeFeatureObject(feature),
);
const extent = bbox(featureCollection(geojsonFeatures as any));
if (extent) {
map?.getView().fit(extent, {
maxZoom: 18,
duration: 1000,
padding:
geometryKind === "line"
? [60, 60, 60, 60]
: [40, 40, 40, 40],
});
}
};
const locateFeatures = (
ids: string[],
layer: string,
geometryKind: "point" | "line",
) => {
queryFeaturesByIds(ids, layer).then((features) => {
if (features.length > 0) {
setHighlightFeatures(features);
zoomToFeatures(features, geometryKind);
}
});
};
switch (action.type) {
case "locate_features": {
locateFeatures(action.ids, action.layer, action.geometryKind);
break;
}
case "zoom_to_map": {
const center =
action.sourceCrs === "EPSG:4326"
? transform(action.coordinate, "EPSG:4326", "EPSG:3857")
: action.coordinate;
map?.getView().animate({
center,
zoom: action.zoom ?? map.getView().getZoom() ?? 18,
duration: action.durationMs ?? 1000,
});
break;
}
case "view_history": {
setChatPanelFeatureInfos(action.featureInfos);
setChatPanelType(action.dataType);
setChatPanelTimeRange({
startTime: action.startTime,
endTime: action.endTime,
});
setShowHistoryPanel(true);
break;
}
case "view_scada": {
setChatPanelFeatureInfos(action.featureInfos);
setChatPanelType("none");
setChatPanelTimeRange({
startTime: action.startTime,
endTime: action.endTime,
});
setShowHistoryPanel(true);
setActiveTools((prev) => {
if (prev.includes("history")) {
return prev;
}
return [...prev, "history"];
});
break;
}
case "render_junctions": {
disposeChatJunctionRender();
renderRequestSeqRef.current += 1;
const requestSeq = renderRequestSeqRef.current;
if (!action.renderRef || !map) {
break;
}
void (async () => {
try {
const query = action.sessionId
? `?session_id=${encodeURIComponent(action.sessionId)}`
: "";
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/render-ref/${encodeURIComponent(action.renderRef)}${query}`,
{
method: "GET",
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
throw new Error(`render ref request failed: ${response.status}`);
}
const payload = (await response.json()) as {
data?: {
node_area_map?: Record<string, unknown>;
area_ids?: unknown[];
area_colors?: Record<string, unknown>;
};
};
const data = payload.data;
if (!data?.node_area_map) {
throw new Error("render ref payload missing node_area_map");
}
const renderPayload: JunctionAreaRenderPayload = {
nodeAreaMap: Object.fromEntries(
Object.entries(data.node_area_map).map(([key, value]) => [
String(key),
String(value ?? ""),
]),
),
areaIds: Array.isArray(data.area_ids)
? data.area_ids.map((item) => String(item).trim()).filter(Boolean)
: [],
areaColors:
data.area_colors && typeof data.area_colors === "object"
? Object.fromEntries(
Object.entries(data.area_colors).map(([key, value]) => [
String(key),
String(value ?? ""),
]),
)
: {},
};
if (
requestSeq !== renderRequestSeqRef.current ||
Object.keys(renderPayload.nodeAreaMap).length === 0
) {
return;
}
chatJunctionRenderCleanupRef.current = applyJunctionAreaRender(
map,
renderPayload,
{ propertyKey: "chat_junction_render_index" },
);
} catch (error) {
console.error("Failed to resolve render_ref for junction render:", error);
}
})();
break;
}
case "apply_layer_style": {
setShowStyleEditor(true);
setActiveTools((prev) => (prev.includes("style") ? prev : [...prev, "style"]));
if (action.resetToDefault) {
resetExternalStyle(action.layerId);
} else {
applyExternalStyle(action.layerId, action.styleConfig);
}
break;
}
}
},
[
applyExternalStyle,
disposeChatJunctionRender,
map,
resetExternalStyle,
setActiveTools,
setChatPanelFeatureInfos,
setChatPanelTimeRange,
setChatPanelType,
setHighlightFeatures,
setShowHistoryPanel,
setShowStyleEditor,
],
),
);
};
+611 -167
View File
@@ -7,6 +7,7 @@ import React, {
useState,
useEffect,
useMemo,
useCallback,
useRef,
} from "react";
import { Map as OlMap, VectorTile } from "ol";
@@ -49,6 +50,13 @@ interface DataContextType {
setCurrentJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>;
currentPipeCalData?: any[]; // 当前计算结果
setCurrentPipeCalData?: React.Dispatch<React.SetStateAction<any[]>>;
compareJunctionCalData?: any[];
setCompareJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>;
comparePipeCalData?: any[];
setComparePipeCalData?: React.Dispatch<React.SetStateAction<any[]>>;
isCompareMode?: boolean;
setCompareMode?: React.Dispatch<React.SetStateAction<boolean>>;
toggleCompareMode?: () => void;
showJunctionText?: boolean; // 是否显示节点文本
showPipeText?: boolean; // 是否显示管道文本
showJunctionId?: boolean; // 是否显示节点ID
@@ -57,8 +65,10 @@ interface DataContextType {
setShowPipeTextLayer?: React.Dispatch<React.SetStateAction<boolean>>;
setShowJunctionId?: React.Dispatch<React.SetStateAction<boolean>>;
setShowPipeId?: React.Dispatch<React.SetStateAction<boolean>>;
showContourLayer?: boolean;
setShowContourLayer?: React.Dispatch<React.SetStateAction<boolean>>;
isContourLayerAvailable?: boolean;
showWaterflowLayer?: boolean;
setShowWaterflowLayer?: React.Dispatch<React.SetStateAction<boolean>>;
setContourLayerAvailable?: React.Dispatch<React.SetStateAction<boolean>>;
isWaterflowLayerAvailable?: boolean;
@@ -69,8 +79,14 @@ interface DataContextType {
setPipeText?: React.Dispatch<React.SetStateAction<string>>;
setContours?: React.Dispatch<React.SetStateAction<any[]>>;
deckLayer?: DeckLayer;
compareDeckLayer?: DeckLayer;
deckLayers?: DeckLayer[];
compareMap?: OlMap;
maps?: OlMap[];
diameterRange?: [number, number];
elevationRange?: [number, number];
forceStyleAutoApplyVersion?: number;
setForceStyleAutoApplyVersion?: React.Dispatch<React.SetStateAction<number>>;
}
// 跨组件传递
@@ -128,12 +144,18 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const mapRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const compareMapRef = useRef<HTMLDivElement | null>(null);
const compareCanvasRef = useRef<HTMLCanvasElement | null>(null);
const deckLayerRef = useRef<DeckLayer | null>(null);
const compareDeckLayerRef = useRef<DeckLayer | null>(null);
const isDisposingRef = useRef(false);
const isCompareDisposingRef = useRef(false);
const pendingTimeoutsRef = useRef<number[]>([]);
const [map, setMap] = useState<OlMap>();
const [deckLayer, setDeckLayer] = useState<DeckLayer>();
const [compareMap, setCompareMap] = useState<OlMap>();
const [compareDeckLayer, setCompareDeckLayer] = useState<DeckLayer>();
// currentCalData 用于存储当前计算结果
const [currentTime, setCurrentTime] = useState<number>(-1); // 默认选择当前时间
// const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17"));
@@ -144,6 +166,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
[],
);
const [currentPipeCalData, setCurrentPipeCalData] = useState<any[]>([]);
const [compareJunctionCalData, setCompareJunctionCalData] = useState<any[]>(
[],
);
const [comparePipeCalData, setComparePipeCalData] = useState<any[]>([]);
const [isCompareMode, setCompareMode] = useState(false);
// junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染
// currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值
const [junctionData, setJunctionDataState] = useState<any[]>([]);
@@ -159,7 +186,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const [showPipeId, setShowPipeId] = useState(false); // 控制管道ID显示
const [showContourLayer, setShowContourLayer] = useState(false); // 控制等高线图层显示
const [junctionText, setJunctionText] = useState("pressure");
const [pipeText, setPipeText] = useState("flow");
const [pipeText, setPipeText] = useState("velocity");
const [contours, setContours] = useState<any[]>([]);
const flowAnimation = useRef(false); // 添加动画控制标志
const [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示
@@ -201,12 +228,63 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
});
}, [pipeData, currentPipeCalData, pipeText]);
const mergedCompareJunctionData = useMemo(() => {
const nodeMap = new Map(compareJunctionCalData.map((r: any) => [r.ID, r]));
return junctionData.map((j) => {
const record = nodeMap.get(j.id);
let val = record ? record.value : undefined;
if (val !== undefined && junctionText === "actualdemand") {
val = toM3h(val, "lps");
}
return record ? { ...j, [junctionText]: val } : j;
});
}, [junctionData, compareJunctionCalData, junctionText]);
const mergedComparePipeData = useMemo(() => {
const linkMap = new Map(comparePipeCalData.map((r: any) => [r.ID, r]));
return pipeData.map((p) => {
const record = linkMap.get(p.id);
if (!record) return p;
const isFlow = pipeText === "flow";
let val = record.value;
if (val !== undefined && isFlow) {
val = toM3h(val, "lps");
}
return {
...p,
[pipeText]: isFlow ? Math.abs(val) : val,
flowFlag: isFlow && record.value < 0 ? -1 : 1,
path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path,
};
});
}, [pipeData, comparePipeCalData, pipeText]);
const [diameterRange, setDiameterRange] = useState<
[number, number] | undefined
>();
const [elevationRange, setElevationRange] = useState<
[number, number] | undefined
>();
const [forceStyleAutoApplyVersion, setForceStyleAutoApplyVersion] =
useState(0);
const toggleCompareMode = useCallback(() => {
setCompareMode((prev) => !prev);
}, []);
const maps = useMemo(
() =>
[map, isCompareMode ? compareMap : undefined].filter(Boolean) as OlMap[],
[compareMap, isCompareMode, map],
);
const deckLayers = useMemo(
() =>
[deckLayer, isCompareMode ? compareDeckLayer : undefined].filter(
Boolean,
) as DeckLayer[],
[compareDeckLayer, deckLayer, isCompareMode],
);
const setJunctionData = (newData: any[]) => {
const uniqueNewData = newData.filter((item) => {
@@ -518,6 +596,178 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
},
});
const createOperationalLayers = () => {
const nextJunctionSource = new VectorTileSource({
url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_junctions@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`,
format: new MVT(),
projection: "EPSG:3857",
});
const nextPipeSource = new VectorTileSource({
url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_pipes@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`,
format: new MVT(),
projection: "EPSG:3857",
});
const nextJunctionsLayer = new WebGLVectorTileLayer({
source: nextJunctionSource as any,
style: defaultFlatStyle,
extent: MAP_EXTENT,
maxZoom: 24,
minZoom: 11,
properties: {
name: "节点",
value: "junctions",
type: "point",
properties: [
{ name: "高程", value: "elevation" },
{ name: "实际需水量", value: "actual_demand" },
{ name: "水头", value: "total_head" },
{ name: "压力", value: "pressure" },
{ name: "水质", value: "quality" },
],
},
});
const nextPipesLayer = new WebGLVectorTileLayer({
source: nextPipeSource as any,
style: defaultFlatStyle,
extent: MAP_EXTENT,
maxZoom: 24,
minZoom: 11,
properties: {
name: "管道",
value: "pipes",
type: "linestring",
properties: [
{ name: "管径", value: "diameter" },
{ name: "流量", value: "flow" },
{ name: "摩阻系数", value: "friction" },
{ name: "水头损失", value: "headloss" },
{ name: "单位水头损失", value: "unit_headloss" },
{ name: "水质", value: "quality" },
{ name: "反应速率", value: "reaction" },
{ name: "设置值", value: "setting" },
{ name: "状态", value: "status" },
{ name: "流速", value: "velocity" },
],
},
});
const nextValvesLayer = new WebGLVectorTileLayer({
source: valveSource as any,
style: valveStyle,
extent: MAP_EXTENT,
maxZoom: 24,
minZoom: 16,
properties: {
name: "阀门",
value: "valves",
type: "linestring",
properties: [],
},
});
const nextReservoirsLayer = new VectorLayer({
source: reservoirSource,
style: reservoirStyle,
extent: MAP_EXTENT,
maxZoom: 24,
minZoom: 11,
properties: {
name: "水库",
value: "reservoirs",
type: "point",
properties: [],
},
});
const nextPumpsLayer = new VectorLayer({
source: pumpSource,
style: pumpStyle,
extent: MAP_EXTENT,
maxZoom: 24,
minZoom: 11,
properties: {
name: "水泵",
value: "pumps",
type: "linestring",
properties: [],
},
});
const nextTanksLayer = new VectorLayer({
source: tankSource,
style: tankStyle,
extent: MAP_EXTENT,
maxZoom: 24,
minZoom: 11,
properties: {
name: "水箱",
value: "tanks",
type: "point",
properties: [],
},
});
const nextScadaLayer = new VectorLayer({
source: scadaSource,
style: scadaStyle,
extent: MAP_EXTENT,
maxZoom: 24,
minZoom: 11,
properties: {
name: "SCADA",
value: "scada",
type: "point",
properties: [],
},
});
const availableLayers: any[] = [];
config.MAP_AVAILABLE_LAYERS.forEach((layerValue) => {
switch (layerValue) {
case "junctions":
availableLayers.push(nextJunctionsLayer);
break;
case "pipes":
availableLayers.push(nextPipesLayer);
break;
case "valves":
availableLayers.push(nextValvesLayer);
break;
case "reservoirs":
availableLayers.push(nextReservoirsLayer);
break;
case "pumps":
availableLayers.push(nextPumpsLayer);
break;
case "tanks":
availableLayers.push(nextTanksLayer);
break;
case "scada":
availableLayers.push(nextScadaLayer);
break;
}
});
availableLayers.sort((a, b) => {
const order = [
"valves",
"junctions",
"scada",
"reservoirs",
"pumps",
"tanks",
"pipes",
].reverse();
const getValue = (layer: any) => {
const props = layer.get ? layer.get("properties") : undefined;
return (props && props.value) || layer.get?.("value") || "";
};
const aVal = getValue(a);
const bVal = getValue(b);
let ia = order.indexOf(aVal);
let ib = order.indexOf(bVal);
if (ia === -1) ia = order.length;
if (ib === -1) ib = order.length;
return ia - ib;
});
return availableLayers;
};
// The map and layer instances are intentionally rebuilt only when workspace or extent changes.
useEffect(() => {
if (!mapRef.current) return;
@@ -857,148 +1107,284 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [MAP_WORKSPACE, MAP_EXTENT]);
useEffect(() => {
if (!isCompareMode) {
isCompareDisposingRef.current = true;
setCompareJunctionCalData([]);
setComparePipeCalData([]);
return;
}
if (!map || !compareMapRef.current || !compareCanvasRef.current) return;
isCompareDisposingRef.current = false;
const availableLayers = createOperationalLayers();
const nextCompareMap = new OlMap({
target: compareMapRef.current,
view: map.getView(),
layers: availableLayers.slice(),
controls: [],
});
nextCompareMap.getAllLayers().forEach((layer) => {
const layerId = layer.get("value");
if (!layerId) return;
const primaryLayer = map
.getAllLayers()
.find((currentLayer) => currentLayer.get("value") === layerId);
if (primaryLayer) {
layer.setVisible(primaryLayer.getVisible());
}
});
setCompareMap(nextCompareMap);
const compareDeck = new Deck({
initialViewState: {
longitude: 0,
latitude: 0,
zoom: 1,
},
canvas: compareCanvasRef.current,
controller: false,
layers: [],
});
const nextCompareDeckLayer = new DeckLayer(
compareDeck,
compareCanvasRef.current,
{
name: "compareDeckLayer",
value: "deckLayer",
},
);
compareDeckLayerRef.current = nextCompareDeckLayer;
setCompareDeckLayer(nextCompareDeckLayer);
nextCompareMap.addLayer(nextCompareDeckLayer);
const resizeTimerId = window.setTimeout(() => {
map.updateSize();
nextCompareMap.updateSize();
}, 0);
return () => {
isCompareDisposingRef.current = true;
window.clearTimeout(resizeTimerId);
if (
compareDeckLayerRef.current &&
!compareDeckLayerRef.current.isDisposedLayer()
) {
try {
nextCompareMap.removeLayer(compareDeckLayerRef.current);
} catch {
// Layer may have already been removed during teardown.
}
compareDeckLayerRef.current.disposeDeck();
}
compareDeckLayerRef.current = null;
setCompareDeckLayer(undefined);
setCompareMap(undefined);
nextCompareMap.setTarget(undefined);
nextCompareMap.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCompareMode, map]);
useEffect(() => {
const resizeTimerId = window.setTimeout(() => {
map?.updateSize();
compareMap?.updateSize();
}, 0);
return () => {
window.clearTimeout(resizeTimerId);
};
}, [compareMap, isCompareMode, map]);
// 当数据变化时,更新 deck.gl 图层
useEffect(() => {
if (isDisposingRef.current) return;
const deckLayer = deckLayerRef.current;
if (!deckLayer) return; // 如果 deck 实例还未创建,则退出
if (deckLayer.isDisposedLayer()) return;
if (!mergedJunctionData.length) return;
if (!mergedPipeData.length) return;
const junctionTextLayer = new TextLayer({
id: "junctionTextLayer",
name: "节点文字",
zIndex: 10,
data: mergedJunctionData,
getPosition: (d: any) => d.position,
fontFamily: "Monaco, monospace",
getText: (d: any) => {
let idPart = showJunctionId ? d.id : "";
let propPart = "";
if (showJunctionTextLayer && d[junctionText] !== undefined) {
const value = (d[junctionText] as number).toFixed(3);
propPart = `${value}`;
}
if (idPart && propPart) return `${idPart} - ${propPart}`;
return idPart || propPart;
},
getSize: 14,
fontWeight: "bold",
getColor: [33, 37, 41], // 深灰色,在灰白背景上清晰可见
getAngle: 0,
getTextAnchor: "middle",
getAlignmentBaseline: "center",
getPixelOffset: [0, -10],
visible:
const syncDeckOverlay = (
targetDeckLayer: DeckLayer | null,
targetJunctionData: any[],
targetPipeData: any[],
disposing: boolean,
) => {
if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) {
return;
}
const shouldShowJunctionText =
(showJunctionTextLayer || showJunctionId) &&
currentZoom >= 15 &&
currentZoom <= 24,
updateTriggers: {
getText: [showJunctionId, showJunctionTextLayer, junctionText],
},
extensions: [new CollisionFilterExtension()],
collisionTestProps: {
sizeScale: 3,
},
characterSet: "auto",
fontSettings: {
sdf: true,
fontSize: 64,
buffer: 6,
},
// outlineWidth: 3,
// outlineColor: [255, 255, 255, 220],
});
const pipeTextLayer = new TextLayer({
id: "pipeTextLayer",
name: "管道文字",
zIndex: 10,
data: mergedPipeData,
getPosition: (d: any) => d.position,
fontFamily: "Monaco, monospace",
getText: (d: any) => {
let idPart = showPipeId ? d.id : "";
let propPart = "";
if (showPipeTextLayer && d[pipeText] !== undefined) {
let value;
if (pipeText === "unit_headloss") {
value = (
(d["unit_headloss"] / (d["length"] / 1000)) as number
).toFixed(3);
} else {
value = Math.abs(d[pipeText] as number).toFixed(3);
}
propPart = `${value}`;
}
if (idPart && propPart) return `${idPart} - ${propPart}`;
return idPart || propPart;
},
getSize: 14,
fontWeight: "bold",
getColor: [33, 37, 41], // 深灰色
getAngle: (d: any) => d.angle || 0,
getPixelOffset: [0, -8],
getTextAnchor: "middle",
getAlignmentBaseline: "bottom",
visible:
currentZoom <= 24 &&
targetJunctionData.length > 0;
const shouldShowPipeText =
(showPipeTextLayer || showPipeId) &&
currentZoom >= 15 &&
currentZoom <= 24,
updateTriggers: {
getText: [showPipeId, showPipeTextLayer, pipeText],
},
extensions: [new CollisionFilterExtension()],
collisionTestProps: {
sizeScale: 3,
},
characterSet: "auto",
fontSettings: {
sdf: true,
fontSize: 64,
buffer: 6,
},
// outlineWidth: 3,
// outlineColor: [255, 255, 255, 220],
});
currentZoom <= 24 &&
targetPipeData.length > 0;
const shouldShowContour =
showContourLayer &&
currentZoom >= 11 &&
currentZoom <= 24 &&
targetJunctionData.length > 0;
const contourLayer = new ContourLayer({
id: "junctionContourLayer",
name: "等值线",
data: mergedJunctionData,
aggregation: "MEAN",
cellSize: 600,
strokeWidth: 0,
contours: contours,
getPosition: (d) => d.position,
getWeight: (d: any) =>
(d[junctionText] as number) < 0 ? 0 : (d[junctionText] as number),
opacity: 1,
visible: showContourLayer && currentZoom >= 11 && currentZoom <= 24,
updateTriggers: {
// 当 mergedJunctionData 内部数据更新时,通知 getWeight 重新计算
getWeight: [mergedJunctionData, junctionText],
},
});
if (deckLayer.getDeckLayerById("junctionTextLayer")) {
// 传入完整 layer 实例以保证 clone/替换时保留 layer 类型和方法
deckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer);
} else {
deckLayer.addDeckLayer(junctionTextLayer);
}
if (deckLayer.getDeckLayerById("pipeTextLayer")) {
deckLayer.updateDeckLayer("pipeTextLayer", pipeTextLayer);
} else {
deckLayer.addDeckLayer(pipeTextLayer);
}
if (deckLayer.getDeckLayerById("junctionContourLayer")) {
deckLayer.updateDeckLayer("junctionContourLayer", contourLayer);
} else {
deckLayer.addDeckLayer(contourLayer);
if (!shouldShowJunctionText) {
targetDeckLayer.removeDeckLayer("junctionTextLayer");
}
if (!shouldShowPipeText) {
targetDeckLayer.removeDeckLayer("pipeTextLayer");
}
if (!shouldShowContour) {
targetDeckLayer.removeDeckLayer("junctionContourLayer");
}
if (!shouldShowJunctionText && !shouldShowPipeText && !shouldShowContour) {
return;
}
const junctionTextLayer = shouldShowJunctionText
? new TextLayer({
id: "junctionTextLayer",
name: "节点文字",
zIndex: 10,
data: targetJunctionData,
getPosition: (d: any) => d.position,
fontFamily: "Monaco, monospace",
getText: (d: any) => {
let idPart = showJunctionId ? d.id : "";
let propPart = "";
if (showJunctionTextLayer && d[junctionText] !== undefined) {
const value = (d[junctionText] as number).toFixed(3);
propPart = `${value}`;
}
if (idPart && propPart) return `${idPart} - ${propPart}`;
return idPart || propPart;
},
getSize: 14,
fontWeight: "bold",
getColor: [33, 37, 41],
getAngle: 0,
getTextAnchor: "middle",
getAlignmentBaseline: "center",
getPixelOffset: [0, -10],
visible: true,
updateTriggers: {
getText: [showJunctionId, showJunctionTextLayer, junctionText],
},
extensions: [new CollisionFilterExtension()],
collisionTestProps: {
sizeScale: 3,
},
characterSet: "auto",
fontSettings: {
sdf: true,
fontSize: 64,
buffer: 6,
},
})
: null;
const pipeTextLayer = shouldShowPipeText
? new TextLayer({
id: "pipeTextLayer",
name: "管道文字",
zIndex: 10,
data: targetPipeData,
getPosition: (d: any) => d.position,
fontFamily: "Monaco, monospace",
getText: (d: any) => {
let idPart = showPipeId ? d.id : "";
let propPart = "";
if (showPipeTextLayer && d[pipeText] !== undefined) {
let value;
if (pipeText === "unit_headloss") {
value = (
(d["unit_headloss"] / (d["length"] / 1000)) as number
).toFixed(3);
} else {
value = Math.abs(d[pipeText] as number).toFixed(3);
}
propPart = `${value}`;
}
if (idPart && propPart) return `${idPart} - ${propPart}`;
return idPart || propPart;
},
getSize: 14,
fontWeight: "bold",
getColor: [33, 37, 41],
getAngle: (d: any) => d.angle || 0,
getPixelOffset: [0, -8],
getTextAnchor: "middle",
getAlignmentBaseline: "bottom",
visible: true,
updateTriggers: {
getText: [showPipeId, showPipeTextLayer, pipeText],
},
extensions: [new CollisionFilterExtension()],
collisionTestProps: {
sizeScale: 3,
},
characterSet: "auto",
fontSettings: {
sdf: true,
fontSize: 64,
buffer: 6,
},
})
: null;
const contourLayer = shouldShowContour
? new ContourLayer({
id: "junctionContourLayer",
name: "等值线",
data: targetJunctionData,
aggregation: "MEAN",
cellSize: 600,
strokeWidth: 0,
contours: contours,
getPosition: (d) => d.position,
getWeight: (d: any) =>
(d[junctionText] as number) < 0 ? 0 : (d[junctionText] as number),
opacity: 1,
visible: true,
updateTriggers: {
getWeight: [targetJunctionData, junctionText],
},
})
: null;
if (junctionTextLayer && targetDeckLayer.getDeckLayerById("junctionTextLayer")) {
targetDeckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer);
} else if (junctionTextLayer) {
targetDeckLayer.addDeckLayer(junctionTextLayer);
}
if (pipeTextLayer && targetDeckLayer.getDeckLayerById("pipeTextLayer")) {
targetDeckLayer.updateDeckLayer("pipeTextLayer", pipeTextLayer);
} else if (pipeTextLayer) {
targetDeckLayer.addDeckLayer(pipeTextLayer);
}
if (contourLayer && targetDeckLayer.getDeckLayerById("junctionContourLayer")) {
targetDeckLayer.updateDeckLayer("junctionContourLayer", contourLayer);
} else if (contourLayer) {
targetDeckLayer.addDeckLayer(contourLayer);
}
};
syncDeckOverlay(
deckLayerRef.current,
mergedJunctionData,
mergedPipeData,
isDisposingRef.current,
);
if (isCompareMode) {
syncDeckOverlay(
compareDeckLayerRef.current,
mergedCompareJunctionData,
mergedComparePipeData,
isCompareDisposingRef.current,
);
}
}, [
mergedJunctionData,
mergedPipeData,
mergedCompareJunctionData,
mergedComparePipeData,
isCompareMode,
junctionText,
pipeText,
currentZoom,
@@ -1012,57 +1398,69 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// 控制流动动画开关
useEffect(() => {
if (isDisposingRef.current) return;
if (pipeText === "flow" && currentPipeCalData.length > 0) {
flowAnimation.current = true;
} else {
flowAnimation.current = false;
}
const deckLayer = deckLayerRef.current;
if (!deckLayer) return; // 如果 deck 实例还未创建,则退出
flowAnimation.current = pipeText === "flow" && currentPipeCalData.length > 0;
const shouldShowWaterflow =
isWaterflowLayerAvailable &&
showWaterflowLayer &&
flowAnimation.current &&
currentZoom >= 12 &&
currentZoom <= 24;
let animationFrameId: number; // 保存 requestAnimationFrame 的 ID
let animationFrameId: number;
// 动画循环
const animate = () => {
if (isDisposingRef.current || deckLayer.isDisposedLayer()) return;
// 动画总时长(秒)
const syncWaterflowLayer = (
targetDeckLayer: DeckLayer | null,
targetPipeData: any[],
disposing: boolean,
) => {
if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) {
return;
}
if (!shouldShowWaterflow || targetPipeData.length === 0) {
targetDeckLayer.removeDeckLayer("waterflowLayer");
return;
}
const animationDuration = 10;
const bufferTime = 2;
const loopLength = animationDuration + bufferTime;
const currentTime = (Date.now() / 1000) % loopLength;
const currentFrameTime = (Date.now() / 1000) % loopLength;
const waterflowLayer = new TripsLayer({
id: "waterflowLayer",
name: "水流",
data: mergedPipeData,
data: targetPipeData,
getPath: (d) => d.path,
getTimestamps: (d) => {
return d.timestamps; // 这些应该是与 currentTime 匹配的数值
},
getTimestamps: (d) => d.timestamps,
getColor: [0, 220, 255],
opacity: 0.8,
visible:
isWaterflowLayerAvailable &&
showWaterflowLayer &&
flowAnimation.current && // 保持动画标志作为可见性的一部分
currentZoom >= 12 &&
currentZoom <= 24,
visible: true,
widthMinPixels: 5,
jointRounded: true, // 拐角变圆
// capRounded: true, // 端点变圆
trailLength: 2, // 水流尾迹淡出时间
currentTime: currentTime,
jointRounded: true,
trailLength: 2,
currentTime: currentFrameTime,
});
if (deckLayer.getDeckLayerById("waterflowLayer")) {
deckLayer.updateDeckLayer("waterflowLayer", waterflowLayer);
if (targetDeckLayer.getDeckLayerById("waterflowLayer")) {
targetDeckLayer.updateDeckLayer("waterflowLayer", waterflowLayer);
} else {
deckLayer.addDeckLayer(waterflowLayer);
targetDeckLayer.addDeckLayer(waterflowLayer);
}
};
// 只有在需要动画时才请求下一帧,但图层已经添加到了 deckLayer 中
if (flowAnimation.current) {
const animate = () => {
syncWaterflowLayer(
deckLayerRef.current,
mergedPipeData,
isDisposingRef.current,
);
if (isCompareMode) {
syncWaterflowLayer(
compareDeckLayerRef.current,
mergedComparePipeData,
isCompareDisposingRef.current,
);
}
if (shouldShowWaterflow) {
animationFrameId = requestAnimationFrame(animate);
}
};
@@ -1078,6 +1476,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
currentPipeCalData,
currentZoom,
mergedPipeData,
mergedComparePipeData,
isCompareMode,
pipeText,
isWaterflowLayerAvailable,
showWaterflowLayer,
@@ -1097,14 +1497,23 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
setCurrentJunctionCalData,
currentPipeCalData,
setCurrentPipeCalData,
compareJunctionCalData,
setCompareJunctionCalData,
comparePipeCalData,
setComparePipeCalData,
isCompareMode,
setCompareMode,
toggleCompareMode,
setShowJunctionTextLayer,
setShowPipeTextLayer,
setShowJunctionId,
setShowPipeId,
showJunctionId,
showPipeId,
showContourLayer,
setShowContourLayer,
isContourLayerAvailable,
showWaterflowLayer,
setContourLayerAvailable,
isWaterflowLayerAvailable,
setWaterflowLayerAvailable,
@@ -1115,17 +1524,52 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
pipeText,
setContours,
deckLayer,
compareDeckLayer,
deckLayers,
compareMap,
maps,
diameterRange,
elevationRange,
forceStyleAutoApplyVersion,
setForceStyleAutoApplyVersion,
}}
>
<MapContext.Provider value={map}>
<div className="relative w-full h-full">
<div ref={mapRef} className="w-full h-full"></div>
<div className="flex w-full h-full">
<div
className={`relative h-full ${isCompareMode ? "w-1/2" : "w-full"}`}
>
<div ref={mapRef} className="w-full h-full"></div>
<canvas
ref={canvasRef}
className="pointer-events-none absolute inset-0"
/>
{isCompareMode && (
<div className="pointer-events-none absolute right-4 top-4 rounded-md bg-black/55 px-3 py-1 text-sm font-medium text-white">
</div>
)}
</div>
{isCompareMode && (
<div className="relative h-full w-1/2 border-l border-white/40">
<div ref={compareMapRef} className="w-full h-full"></div>
<canvas
ref={compareCanvasRef}
className="pointer-events-none absolute inset-0"
/>
<div className="pointer-events-none absolute left-4 top-4 rounded-md bg-black/55 px-3 py-1 text-sm font-medium text-white">
</div>
</div>
)}
</div>
{isCompareMode && (
<div className="pointer-events-none absolute inset-y-0 left-1/2 z-10 w-px -translate-x-1/2 bg-white/85 shadow-[0_0_0_1px_rgba(15,23,42,0.18)]" />
)}
<MapTools />
{children}
</div>
<canvas ref={canvasRef} />
</MapContext.Provider>
</DataContext.Provider>
</>
+1 -1
View File
@@ -1,6 +1,6 @@
export const config = {
BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:8000",
COPILOT_URL: process.env.NEXT_PUBLIC_COPILOT_URL || "http://127.0.0.1:8787",
AGENT_URL: process.env.NEXT_PUBLIC_AGENT_URL || "http://127.0.0.1:8788",
AUDIO_SERVICE_URL:
process.env.NEXT_PUBLIC_AUDIO_SERVICE_URL || "http://127.0.0.1:18083",
MAP_URL: process.env.NEXT_PUBLIC_MAP_URL || "http://127.0.0.1:8080/geoserver",
+16 -1
View File
@@ -22,18 +22,33 @@ export function useChatToolActionHandler(
handler: (action: ChatToolAction) => void,
) {
const handlerRef = useRef(handler);
const lastHandledSeqRef = useRef(0);
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
useEffect(() => {
const initialState = useChatToolStore.getState();
if (
initialState.lastAction &&
initialState.actionSeq > lastHandledSeqRef.current &&
Date.now() - initialState.lastActionAt < 5000
) {
lastHandledSeqRef.current = initialState.actionSeq;
handlerRef.current(initialState.lastAction);
} else {
lastHandledSeqRef.current = initialState.actionSeq;
}
const unsubscribe = useChatToolStore.subscribe(
(state, prevState) => {
if (
state.actionSeq !== prevState.actionSeq &&
state.lastAction
state.lastAction &&
state.actionSeq > lastHandledSeqRef.current
) {
lastHandledSeqRef.current = state.actionSeq;
handlerRef.current(state.lastAction);
}
},
+27
View File
@@ -49,3 +49,30 @@ export const getAccessToken = async () => {
}
return null;
};
export const getUserId = async () => {
const session = await getSession();
const sessionUserId = typeof session?.user?.id === "string" ? session.user.id : null;
if (sessionUserId) {
return sessionUserId;
}
const accessToken = await getAccessToken();
if (!accessToken) {
return null;
}
const payload = decodeJwtPayload(accessToken);
if (!payload || typeof payload !== "object") {
return null;
}
const candidate =
typeof payload.sub === "string"
? payload.sub
: typeof payload.user_id === "string"
? payload.user_id
: null;
return candidate;
};
+329 -14
View File
@@ -1,4 +1,13 @@
import { streamCopilotChat } from "./chatStream";
import {
abortAgentChat,
forkAgentChat,
rejectAgentQuestion,
replyAgentPermission,
replyAgentQuestion,
type StreamEvent,
resumeAgentChatStream,
streamAgentChat,
} from "./chatStream";
import { ReadableStream } from "stream/web";
import { TextEncoder, TextDecoder } from "util";
@@ -32,7 +41,7 @@ const makeStream = (chunks: string[]) =>
},
});
describe("streamCopilotChat", () => {
describe("streamAgentChat", () => {
beforeEach(() => {
apiFetch.mockReset();
});
@@ -41,32 +50,235 @@ describe("streamCopilotChat", () => {
apiFetch.mockResolvedValue({
ok: true,
body: makeStream([
'event: token\ndata: {"conversationId":"c1","content":"he"}\n\n',
'event: token\ndata: {"conversationId":"c1","content":"llo"}\n\n',
'event: done\ndata: {"conversationId":"c1"}\n\n',
'event: token\ndata: {"session_id":"s1","content":"he"}\n\n',
'event: token\ndata: {"session_id":"s1","content":"llo"}\n\n',
'event: done\ndata: {"session_id":"s1"}\n\n',
]),
});
const events: Array<{ type: string; content?: string; conversationId?: string }> = [];
const events: Array<{ type: string; content?: string; sessionId?: string }> = [];
await streamCopilotChat({
await streamAgentChat({
message: "hi",
model: "deepseek/deepseek-v4-pro",
onEvent: (event) => events.push(event),
});
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/copilot/chat/stream"),
expect.stringContaining("/api/v1/agent/chat/stream"),
expect.objectContaining({
method: "POST",
projectHeaderMode: "include",
skipAuthRedirect: true,
body: JSON.stringify({
message: "hi",
session_id: undefined,
model: "deepseek/deepseek-v4-pro",
approval_mode: undefined,
}),
}),
);
expect(events).toEqual([
{ type: "token", conversationId: "c1", content: "he" },
{ type: "token", conversationId: "c1", content: "llo" },
{ type: "done", conversationId: "c1" },
{ type: "token", sessionId: "s1", content: "he" },
{ type: "token", sessionId: "s1", content: "llo" },
{ type: "done", sessionId: "s1" },
]);
});
it("parses state events from a resumed stream", async () => {
apiFetch.mockResolvedValue({
ok: true,
body: makeStream([
'event: state\ndata: {"session_id":"s1","messages":[{"id":"a1","role":"assistant","content":"已输出"}],"is_streaming":true,"run_status":"running"}\n\n',
'event: token\ndata: {"session_id":"s1","content":"继续"}\n\n',
'event: done\ndata: {"session_id":"s1"}\n\n',
]),
});
const events: Array<{
type: string;
sessionId?: string;
messages?: unknown[];
isStreaming?: boolean;
runStatus?: string;
content?: string;
}> = [];
await resumeAgentChatStream({
sessionId: "s1",
onEvent: (event) => events.push(event),
});
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/session/s1/stream"),
expect.objectContaining({
method: "GET",
projectHeaderMode: "include",
skipAuthRedirect: true,
}),
);
expect(events).toEqual([
{
type: "state",
sessionId: "s1",
messages: [{ id: "a1", role: "assistant", content: "已输出" }],
isStreaming: true,
runStatus: "running",
},
{ type: "token", sessionId: "s1", content: "继续" },
{ type: "done", sessionId: "s1" },
]);
});
it("parses progress events", async () => {
apiFetch.mockResolvedValue({
ok: true,
body: makeStream([
'event: progress\ndata: {"session_id":"s1","id":"p1","phase":"tool","status":"running","title":"正在调用后端数据查询","detail":"GET /api/v1/demo"}\n\n',
'event: done\ndata: {"session_id":"s1"}\n\n',
]),
});
const events: Array<{ type: string; title?: string; status?: string; detail?: string }> = [];
await streamAgentChat({
message: "hi",
onEvent: (event) => events.push(event),
});
expect(events[0]).toEqual({
type: "progress",
sessionId: "s1",
id: "p1",
phase: "tool",
status: "running",
title: "正在调用后端数据查询",
detail: "GET /api/v1/demo",
});
});
it("parses tool_call arguments when params is empty", async () => {
apiFetch.mockResolvedValue({
ok: true,
body: makeStream([
'event: tool_call\ndata: {"session_id":"agent-1e75dd01-29e","tool":"locate_features","params":{},"arguments":"{\\"ids\\":[\\"142902\\"],\\"feature_type\\":\\"junction\\"}"}\n\n',
'event: done\ndata: {"session_id":"agent-1e75dd01-29e"}\n\n',
]),
});
const events: StreamEvent[] = [];
await streamAgentChat({
message: "hi",
onEvent: (event) => events.push(event),
});
expect(events[0]).toEqual({
type: "tool_call",
sessionId: "agent-1e75dd01-29e",
tool: "locate_features",
params: { ids: ["142902"], feature_type: "junction" },
});
});
it("parses permission request and response events", async () => {
apiFetch.mockResolvedValue({
ok: true,
body: makeStream([
'event: permission_request\ndata: {"session_id":"s1","request_id":"perm-1","permission":"bash","patterns":["rm *"],"target":"rm tmp.txt","always":["rm *"],"created_at":123}\n\n',
'event: permission_response\ndata: {"session_id":"s1","request_id":"perm-1","reply":"reject"}\n\n',
]),
});
const events: StreamEvent[] = [];
await streamAgentChat({
message: "hi",
onEvent: (event) => events.push(event),
});
expect(events).toEqual([
{
type: "permission_request",
sessionId: "s1",
requestId: "perm-1",
permission: "bash",
patterns: ["rm *"],
target: "rm tmp.txt",
always: ["rm *"],
tool: undefined,
createdAt: 123,
},
{
type: "permission_response",
sessionId: "s1",
requestId: "perm-1",
reply: "reject",
},
]);
});
it("parses question request, response, and todo update events", async () => {
apiFetch.mockResolvedValue({
ok: true,
body: makeStream([
'event: question_request\ndata: {"session_id":"s1","request_id":"q-1","questions":[{"header":"范围","question":"选择范围","options":[{"label":"城区","description":"中心城区"}],"multiple":false,"custom":true}],"tool":{"message_id":"m1","call_id":"c1"},"created_at":123}\n\n',
'event: question_response\ndata: {"session_id":"s1","request_id":"q-1","answers":[["城区","补充说明"]]}\n\n',
'event: todo_update\ndata: {"session_id":"s1","todos":[{"id":"t1","content":"分析水位","status":"in_progress","priority":"high","updated_at":456}],"created_at":456}\n\n',
]),
});
const events: StreamEvent[] = [];
await streamAgentChat({
message: "hi",
onEvent: (event) => events.push(event),
});
expect(events).toEqual([
{
type: "question_request",
sessionId: "s1",
requestId: "q-1",
questions: [
{
header: "范围",
question: "选择范围",
options: [{ label: "城区", description: "中心城区" }],
multiple: false,
custom: true,
},
],
tool: {
messageID: "m1",
callID: "c1",
},
createdAt: 123,
},
{
type: "question_response",
sessionId: "s1",
requestId: "q-1",
answers: [["城区", "补充说明"]],
rejected: false,
},
{
type: "todo_update",
sessionId: "s1",
messageId: undefined,
todos: [
{
id: "t1",
content: "分析水位",
status: "in_progress",
priority: "high",
createdAt: undefined,
updatedAt: 456,
},
],
createdAt: 456,
},
]);
});
@@ -78,7 +290,7 @@ describe("streamCopilotChat", () => {
});
const events: Array<{ type: string; message?: string; detail?: string }> = [];
await streamCopilotChat({
await streamAgentChat({
message: "hi",
onEvent: (event) => events.push(event),
});
@@ -97,7 +309,7 @@ describe("streamCopilotChat", () => {
});
const events: Array<{ type: string; message?: string; detail?: string }> = [];
await streamCopilotChat({
await streamAgentChat({
message: "hi",
onEvent: (event) => events.push(event),
});
@@ -111,7 +323,7 @@ describe("streamCopilotChat", () => {
apiFetch.mockRejectedValue(new TypeError("Failed to fetch"));
const events: Array<{ type: string; message?: string; detail?: string }> = [];
await streamCopilotChat({
await streamAgentChat({
message: "hi",
onEvent: (event) => events.push(event),
});
@@ -120,4 +332,107 @@ describe("streamCopilotChat", () => {
{ type: "error", message: "network request failed", detail: "Failed to fetch" },
]);
});
it("calls abort endpoint for an active session", async () => {
apiFetch.mockResolvedValue({
ok: true,
status: 202,
text: async () => "",
});
await abortAgentChat("s1");
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/abort"),
expect.objectContaining({
method: "POST",
projectHeaderMode: "include",
skipAuthRedirect: true,
body: JSON.stringify({
session_id: "s1",
}),
}),
);
});
it("calls permission reply endpoint", async () => {
apiFetch.mockResolvedValue({
ok: true,
status: 202,
text: async () => "",
});
await replyAgentPermission("s1", "perm-1", "once");
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/permission/perm-1/reply"),
expect.objectContaining({
method: "POST",
projectHeaderMode: "include",
skipAuthRedirect: true,
body: JSON.stringify({
session_id: "s1",
reply: "once",
}),
}),
);
});
it("calls question reply and reject endpoints", async () => {
apiFetch.mockResolvedValue({
ok: true,
status: 202,
text: async () => "",
});
await replyAgentQuestion("s1", "q-1", [["城区"]]);
await rejectAgentQuestion("s1", "q-2");
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/question/q-1/reply"),
expect.objectContaining({
method: "POST",
projectHeaderMode: "include",
skipAuthRedirect: true,
body: JSON.stringify({
session_id: "s1",
answers: [["城区"]],
}),
}),
);
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/question/q-2/reject"),
expect.objectContaining({
method: "POST",
projectHeaderMode: "include",
skipAuthRedirect: true,
body: JSON.stringify({
session_id: "s1",
}),
}),
);
});
it("calls fork endpoint and returns new session id", async () => {
apiFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ session_id: "forked-s1" }),
text: async () => "",
});
const sessionId = await forkAgentChat("s1", 3);
expect(sessionId).toBe("forked-s1");
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/fork"),
expect.objectContaining({
method: "POST",
body: JSON.stringify({
session_id: "s1",
keep_message_count: 3,
}),
}),
);
});
});
+599 -64
View File
@@ -1,25 +1,151 @@
import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config";
export type AgentModel =
| "deepseek/deepseek-v4-flash"
| "deepseek/deepseek-v4-pro";
export type PermissionReply = "once" | "always" | "reject";
export type AgentApprovalMode = "request" | "always";
export type AgentQuestionStatus =
| "pending"
| "submitting"
| "answered"
| "rejected"
| "error";
export type AgentQuestionRequest = {
requestId: string;
sessionId: string;
questions: Array<{
header: string;
question: string;
options: Array<{
label: string;
description: string;
}>;
multiple?: boolean;
custom?: boolean;
}>;
tool?: {
messageID: string;
callID: string;
};
createdAt: number;
repliedAt?: number;
status: AgentQuestionStatus;
answers?: string[][];
error?: string;
};
export type AgentTodoItem = {
id: string;
content: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
priority?: "low" | "medium" | "high";
createdAt?: number;
updatedAt?: number;
};
export type AgentTodoUpdate = {
sessionId: string;
messageId?: string;
todos: AgentTodoItem[];
createdAt: number;
};
export type StreamEvent =
| { type: "token"; conversationId: string; content: string }
| { type: "done"; conversationId: string }
| {
type: "state";
sessionId: string;
messages: unknown[];
isStreaming: boolean;
runStatus?: string;
}
| { type: "token"; sessionId: string; content: string }
| { type: "done"; sessionId: string; totalDurationMs?: number }
| { type: "session_title"; sessionId: string; title: string }
| {
type: "progress";
sessionId: string;
id: string;
phase: string;
status: "running" | "completed" | "error";
title: string;
detail?: string;
startedAt?: number;
endedAt?: number;
elapsedMs?: number;
durationMs?: number;
}
| {
type: "error";
conversationId?: string;
sessionId?: string;
message: string;
detail?: string;
totalDurationMs?: number;
}
| {
type: "tool_call";
conversationId: string;
sessionId: string;
tool: string;
params: Record<string, unknown>;
}
| {
type: "permission_request";
sessionId: string;
requestId: string;
permission: string;
patterns: string[];
target?: string;
always: string[];
tool?: {
messageID: string;
callID: string;
};
createdAt: number;
}
| {
type: "permission_response";
sessionId: string;
requestId: string;
reply: PermissionReply;
}
| {
type: "question_request";
sessionId: string;
requestId: string;
questions: AgentQuestionRequest["questions"];
tool?: AgentQuestionRequest["tool"];
createdAt: number;
}
| {
type: "question_response";
sessionId: string;
requestId: string;
answers?: string[][];
rejected?: boolean;
}
| {
type: "todo_update";
sessionId: string;
messageId?: string;
todos: AgentTodoItem[];
createdAt: number;
};
type StreamOptions = {
message: string;
conversationId?: string;
sessionId?: string;
model?: AgentModel;
approvalMode?: AgentApprovalMode;
signal?: AbortSignal;
onEvent: (event: StreamEvent) => void;
};
type ResumeStreamOptions = {
sessionId: string;
signal?: AbortSignal;
onEvent: (event: StreamEvent) => void;
};
@@ -43,16 +169,303 @@ const parseEventBlock = (block: string): { event?: string; data?: string } => {
};
};
export const streamCopilotChat = async ({
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const resolveToolParams = (
params: unknown,
argumentsPayload: unknown,
): Record<string, unknown> => {
if (isObjectRecord(params) && Object.keys(params).length > 0) {
return params;
}
if (isObjectRecord(argumentsPayload)) {
return argumentsPayload;
}
if (typeof argumentsPayload === "string") {
try {
const parsed = JSON.parse(argumentsPayload) as unknown;
return isObjectRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
return isObjectRecord(params) ? params : {};
};
const normalizeQuestionList = (value: unknown): AgentQuestionRequest["questions"] => {
if (!Array.isArray(value)) return [];
return value
.filter(isObjectRecord)
.map((question) => ({
header: typeof question.header === "string" ? question.header : "",
question: typeof question.question === "string" ? question.question : "",
options: Array.isArray(question.options)
? question.options.filter(isObjectRecord).map((option) => ({
label: typeof option.label === "string" ? option.label : "",
description:
typeof option.description === "string" ? option.description : "",
}))
: [],
multiple: typeof question.multiple === "boolean" ? question.multiple : undefined,
custom: typeof question.custom === "boolean" ? question.custom : undefined,
}));
};
const normalizeAnswers = (value: unknown): string[][] | undefined => {
if (!Array.isArray(value)) return undefined;
return value.map((answer) =>
Array.isArray(answer)
? answer.filter((item): item is string => typeof item === "string")
: [],
);
};
const normalizeQuestionTool = (value: unknown): AgentQuestionRequest["tool"] => {
if (!isObjectRecord(value)) return undefined;
const messageID =
typeof value.messageID === "string"
? value.messageID
: typeof value.message_id === "string"
? value.message_id
: undefined;
const callID =
typeof value.callID === "string"
? value.callID
: typeof value.call_id === "string"
? value.call_id
: undefined;
return messageID && callID ? { messageID, callID } : undefined;
};
const normalizeTodoStatus = (value: unknown): AgentTodoItem["status"] => {
if (value === "in_progress" || value === "completed" || value === "cancelled") {
return value;
}
return "pending";
};
const normalizeTodoPriority = (value: unknown): AgentTodoItem["priority"] => {
if (value === "low" || value === "medium" || value === "high") {
return value;
}
return undefined;
};
const normalizeTodos = (value: unknown): AgentTodoItem[] => {
if (!Array.isArray(value)) return [];
return value.filter(isObjectRecord).map((todo, index) => ({
id:
typeof todo.id === "string" && todo.id.trim()
? todo.id
: `todo-${index}`,
content: typeof todo.content === "string" ? todo.content : "",
status: normalizeTodoStatus(todo.status),
priority: normalizeTodoPriority(todo.priority),
createdAt: typeof todo.created_at === "number" ? todo.created_at : undefined,
updatedAt: typeof todo.updated_at === "number" ? todo.updated_at : undefined,
}));
};
const emitParsedStreamEvent = (
event: string,
data: string,
onEvent: (event: StreamEvent) => void,
) => {
try {
const parsed = JSON.parse(data) as {
session_id?: string;
content?: string;
message?: string;
detail?: string;
tool?: unknown;
params?: Record<string, unknown>;
arguments?: unknown;
id?: string;
phase?: string;
status?: "running" | "completed" | "error";
title?: string;
messages?: unknown[];
is_streaming?: boolean;
run_status?: string;
started_at?: number;
ended_at?: number;
elapsed_ms?: number;
duration_ms?: number;
total_duration_ms?: number;
request_id?: string;
permission?: string;
patterns?: unknown;
target?: string;
always?: unknown;
created_at?: number;
reply?: PermissionReply;
questions?: unknown;
answers?: unknown;
rejected?: boolean;
message_id?: string;
todos?: unknown;
};
if (event === "state") {
onEvent({
type: "state",
sessionId: parsed.session_id ?? "",
messages: Array.isArray(parsed.messages) ? parsed.messages : [],
isStreaming: parsed.is_streaming ?? false,
runStatus: parsed.run_status,
});
} else if (event === "token") {
onEvent({
type: "token",
sessionId: parsed.session_id ?? "",
content: parsed.content ?? "",
});
} else if (event === "progress") {
onEvent({
type: "progress",
sessionId: parsed.session_id ?? "",
id: parsed.id ?? `${parsed.phase ?? "progress"}-${Date.now()}`,
phase: parsed.phase ?? "progress",
status: parsed.status ?? "running",
title: parsed.title ?? "正在处理",
detail: parsed.detail,
startedAt: parsed.started_at,
endedAt: parsed.ended_at,
elapsedMs: parsed.elapsed_ms,
durationMs: parsed.duration_ms,
});
} else if (event === "done") {
onEvent({
type: "done",
sessionId: parsed.session_id ?? "",
totalDurationMs: parsed.total_duration_ms,
});
} else if (event === "session_title") {
onEvent({
type: "session_title",
sessionId: parsed.session_id ?? "",
title: typeof parsed.title === "string" ? parsed.title : "",
});
} else if (event === "error") {
onEvent({
type: "error",
sessionId: parsed.session_id,
message: parsed.message ?? "unknown error",
detail: parsed.detail,
totalDurationMs: parsed.total_duration_ms,
});
} else if (event === "tool_call") {
onEvent({
type: "tool_call",
sessionId: parsed.session_id ?? "",
tool: typeof parsed.tool === "string" ? parsed.tool : "",
params: resolveToolParams(parsed.params, parsed.arguments),
});
} else if (event === "permission_request") {
onEvent({
type: "permission_request",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
permission: parsed.permission ?? "",
patterns: Array.isArray(parsed.patterns)
? parsed.patterns.filter((item): item is string => typeof item === "string")
: [],
target: typeof parsed.target === "string" ? parsed.target : undefined,
always: Array.isArray(parsed.always)
? parsed.always.filter((item): item is string => typeof item === "string")
: [],
tool: isObjectRecord(parsed.tool) &&
typeof parsed.tool.messageID === "string" &&
typeof parsed.tool.callID === "string"
? {
messageID: parsed.tool.messageID,
callID: parsed.tool.callID,
}
: undefined,
createdAt: parsed.created_at ?? Date.now(),
});
} else if (event === "permission_response") {
onEvent({
type: "permission_response",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
reply: parsed.reply ?? "reject",
});
} else if (event === "question_request") {
onEvent({
type: "question_request",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
questions: normalizeQuestionList(parsed.questions),
tool: normalizeQuestionTool(parsed.tool),
createdAt: parsed.created_at ?? Date.now(),
});
} else if (event === "question_response") {
onEvent({
type: "question_response",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
answers: normalizeAnswers(parsed.answers),
rejected: parsed.rejected === true,
});
} else if (event === "todo_update") {
onEvent({
type: "todo_update",
sessionId: parsed.session_id ?? "",
messageId: parsed.message_id,
todos: normalizeTodos(parsed.todos),
createdAt: parsed.created_at ?? Date.now(),
});
}
} catch {
onEvent({
type: "error",
message: "invalid SSE data payload",
detail: data,
});
}
};
const readStreamEvents = async (
response: Response,
onEvent: (event: StreamEvent) => void,
) => {
if (!response.body) {
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split("\n\n");
buffer = blocks.pop() ?? "";
for (const block of blocks) {
const { event, data } = parseEventBlock(block);
if (!event || !data) continue;
emitParsedStreamEvent(event, data, onEvent);
}
}
};
export const streamAgentChat = async ({
message,
conversationId,
sessionId,
model,
approvalMode,
signal,
onEvent,
}: StreamOptions) => {
let response: Response;
try {
response = await apiFetch(
`${config.COPILOT_URL}/api/v1/copilot/chat/stream`,
`${config.AGENT_URL}/api/v1/agent/chat/stream`,
{
method: "POST",
signal,
@@ -62,9 +475,12 @@ export const streamCopilotChat = async ({
},
body: JSON.stringify({
message,
conversation_id: conversationId,
session_id: sessionId,
model,
approval_mode: approvalMode,
}),
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
@@ -97,64 +513,183 @@ export const streamCopilotChat = async ({
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
await readStreamEvents(response, onEvent);
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
export const resumeAgentChatStream = async ({
sessionId,
signal,
onEvent,
}: ResumeStreamOptions) => {
let response: Response;
try {
response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/stream`,
{
method: "GET",
signal,
headers: {
Accept: "text/event-stream",
},
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
onEvent({
type: "error",
sessionId,
message: "network request failed",
detail,
});
return;
}
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split("\n\n");
buffer = blocks.pop() ?? "";
if (!response.ok || !response.body) {
const detail = await response.text();
onEvent({
type: "error",
sessionId,
message: "stream request failed",
detail,
});
return;
}
for (const block of blocks) {
const { event, data } = parseEventBlock(block);
if (!event || !data) continue;
await readStreamEvents(response, onEvent);
};
try {
const parsed = JSON.parse(data) as {
conversationId?: string;
content?: string;
message?: string;
detail?: string;
tool?: string;
params?: Record<string, unknown>;
};
if (event === "token") {
onEvent({
type: "token",
conversationId: parsed.conversationId ?? "",
content: parsed.content ?? "",
});
} else if (event === "done") {
onEvent({
type: "done",
conversationId: parsed.conversationId ?? "",
});
} else if (event === "error") {
onEvent({
type: "error",
conversationId: parsed.conversationId,
message: parsed.message ?? "unknown error",
detail: parsed.detail,
});
} else if (event === "tool_call") {
onEvent({
type: "tool_call",
conversationId: parsed.conversationId ?? "",
tool: parsed.tool ?? "",
params: parsed.params ?? {},
});
}
} catch {
onEvent({
type: "error",
message: "invalid SSE data payload",
detail: data,
});
}
}
export const abortAgentChat = async (sessionId?: string) => {
if (!sessionId) {
return;
}
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/abort`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: sessionId,
}),
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `abort request failed: ${response.status}`);
}
};
export const replyAgentPermission = async (
sessionId: string,
requestId: string,
reply: PermissionReply,
) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/permission/${encodeURIComponent(requestId)}/reply`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: sessionId,
reply,
}),
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `permission reply failed: ${response.status}`);
}
};
export const replyAgentQuestion = async (
sessionId: string,
requestId: string,
answers: string[][],
) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/question/${encodeURIComponent(requestId)}/reply`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: sessionId,
answers,
}),
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `question reply failed: ${response.status}`);
}
};
export const rejectAgentQuestion = async (
sessionId: string,
requestId: string,
) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/question/${encodeURIComponent(requestId)}/reject`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: sessionId,
}),
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `question reject failed: ${response.status}`);
}
};
export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/fork`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: sessionId,
keep_message_count: keepMessageCount,
}),
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `fork request failed: ${response.status}`);
}
const payload = (await response.json()) as { session_id?: string };
if (!payload.session_id) {
throw new Error("fork request returned no session_id");
}
return payload.session_id;
};
+10 -1
View File
@@ -1,12 +1,14 @@
import { getAccessToken } from "@/lib/authToken";
import { getAccessToken, getUserId } from "@/lib/authToken";
import { useProjectStore } from "@/store/projectStore";
export type AuthHeaderMode = "include" | "omit";
export type ProjectHeaderMode = "auto" | "include" | "omit";
export type UserHeaderMode = "include" | "omit";
export interface AuthContextHeaderOptions {
authHeaderMode?: AuthHeaderMode;
projectHeaderMode?: ProjectHeaderMode;
userHeaderMode?: UserHeaderMode;
}
const shouldIncludeProjectHeader = (
@@ -34,6 +36,13 @@ export const applyAuthContextHeaders = async (
headers.set("Authorization", `Bearer ${accessToken}`);
}
if (options.userHeaderMode === "include") {
const userId = await getUserId();
if (userId) {
headers.set("X-User-Id", userId);
}
}
const projectId = useProjectStore.getState().currentProjectId;
if (
projectId &&
+24
View File
@@ -1,5 +1,7 @@
import { create } from "zustand";
import type { DefaultLayerStyleId, StyleConfig } from "@components/olmap/core/Controls/styleEditorTypes";
/* ------------------------------------------------------------------ */
/* Chat Tool Action Store */
/* Decouples chat tool calls from map/panel execution. */
@@ -13,6 +15,13 @@ export type ChatToolAction =
layer: string;
geometryKind: "point" | "line";
}
| {
type: "zoom_to_map";
coordinate: [number, number];
sourceCrs?: "EPSG:3857" | "EPSG:4326";
zoom?: number;
durationMs?: number;
}
| {
type: "view_history";
featureInfos: [string, string][];
@@ -34,6 +43,17 @@ export type ChatToolAction =
series?: Array<{ name: string; data: number[]; type?: "line" | "bar" }>;
xAxisName?: string;
yAxisName?: string;
}
| {
type: "render_junctions";
renderRef: string;
sessionId?: string;
}
| {
type: "apply_layer_style";
layerId: DefaultLayerStyleId;
resetToDefault: boolean;
styleConfig?: Partial<StyleConfig>;
};
interface ChatToolState {
@@ -41,6 +61,8 @@ interface ChatToolState {
lastAction: ChatToolAction | null;
/** Monotonically increasing counter lets subscribers detect new actions. */
actionSeq: number;
/** Timestamp of the most recent action dispatch. */
lastActionAt: number;
/** Dispatch a tool action from the chat. */
dispatch: (action: ChatToolAction) => void;
}
@@ -48,9 +70,11 @@ interface ChatToolState {
export const useChatToolStore = create<ChatToolState>((set) => ({
lastAction: null,
actionSeq: 0,
lastActionAt: 0,
dispatch: (action) =>
set((state) => ({
lastAction: action,
actionSeq: state.actionSeq + 1,
lastActionAt: Date.now(),
})),
}));
+6
View File
@@ -14,6 +14,10 @@
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"types": [
"jest",
"node"
],
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
@@ -63,6 +67,8 @@
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next*/types/**/*.ts",
".next*/dev/types/**/*.ts",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],