Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6ea97142a | |||
| 4374c89a63 | |||
| 224d53a04d | |||
| 7d2ae87e39 | |||
| 1e872ca873 | |||
| e2a6bb0e7d | |||
| 9c0a7a2864 | |||
| ab9e2a0420 | |||
| eee165c812 | |||
| 213a01ff7d | |||
| 0501afaced | |||
| d80a071987 | |||
| 216c7b1ab9 | |||
| 7d966a5e91 | |||
| 22afdbf2e8 | |||
| ed9828befe | |||
| 968d798a2a | |||
| 7da0ed0e39 | |||
| 166b45e529 | |||
| e5f13c3d46 | |||
| 36cdb1df8d | |||
| 865e425748 | |||
| 3a36c693cd | |||
| b23cb6acdd | |||
| 2691f42581 | |||
| 34fd5bfb1a | |||
| 40cc355fff | |||
| f7cd5ebfa7 | |||
| d31565d52c | |||
| e32823e4b5 | |||
| 5fc1812d53 | |||
| 709b029c4e | |||
| 57369772c7 | |||
| 7764e25398 | |||
| e60e1f6453 | |||
| 20ca410e0a | |||
| 06a3f32d2d | |||
| fa3e6b6e84 | |||
| 888132a60f | |||
| 9761ade8d8 | |||
| 0e82c080df | |||
| a4f0ffcd32 | |||
| 9dc8549f31 | |||
| 6b447eb398 | |||
| 54fbf15be8 | |||
| 4bf99e8069 | |||
| e4d45300b1 | |||
| 477350a2a1 | |||
| 424555aae2 | |||
| 98635e5247 | |||
| adf8ea5ca8 | |||
| 91a57123a4 | |||
| 4f54da64d0 |
@@ -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.
|
|
||||||
+3
-2
@@ -26,8 +26,7 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env.local
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
@@ -35,3 +34,5 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
memery.md
|
memery.md
|
||||||
|
|
||||||
|
docs/
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
|
This repository is the TJWater web frontend built with Refine, Next.js, React, and MUI. Application source lives under the existing Next.js project folders. Reuse established page, component, provider, map, and chat patterns instead of adding parallel structures. Static assets and public files should remain in the existing asset/public locations. Build output (`.next/`), dependency folders, and local caches are generated and must not be edited by hand.
|
||||||
|
|
||||||
|
Deployment files are `Dockerfile`, `docker-compose.yml`, and `.gitea/workflows/package.yml`.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
Use npm and Node 20 or newer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run test:coverage
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run dev` starts the Refine/Next development server. `npm run lint` runs ESLint. `npm test` runs Jest. `npm run build` creates the production build.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
|
Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for 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.
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Chat 流式生成动画改造经验
|
||||||
|
|
||||||
|
本文记录 `src/components/chat` 里本次文字生成、图表生成、滚动稳定性的改造经验。重点不是复盘代码行数,而是总结后续继续调整时应遵守的工程边界和交互原则。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 文本生成要有连续感,避免 token 直接到达导致忽快忽慢。
|
||||||
|
- 已生成内容必须稳定,不能反复淡入、重排或闪烁。
|
||||||
|
- 图表和工具调用插入时不能让“分析结果”边框剧烈抖动。
|
||||||
|
- 底部自动滚动要跟随,但不能每个 token 都强制贴底。
|
||||||
|
- 动画应辅助理解,不能比内容本身更抢眼。
|
||||||
|
|
||||||
|
## 文本流式生成
|
||||||
|
|
||||||
|
### 经验结论
|
||||||
|
|
||||||
|
不要把后端 token 到达节奏直接暴露给 UI。后端 token 通常不均匀,前端如果每个 token 都立即渲染,会出现文字跳动、滚动频繁、动画看不出来等问题。
|
||||||
|
|
||||||
|
更稳的做法是类似 Vercel AI SDK `smoothStream` 的思路:
|
||||||
|
|
||||||
|
- token 先进入缓冲区。
|
||||||
|
- 前端按固定节奏释放 chunk。
|
||||||
|
- chunk 尽量按词、短语、标点边界切分。
|
||||||
|
- 当缓冲积压较大时,自适应加快 drain,避免显示落后真实输出太多。
|
||||||
|
|
||||||
|
当前实现采用:
|
||||||
|
|
||||||
|
- `TOKEN_PLAYBACK_INTERVAL_MS = 16`
|
||||||
|
- 小缓冲按较短 chunk 输出。
|
||||||
|
- 大缓冲最多每帧释放 `160` 字符。
|
||||||
|
- 中文优先使用 `Intl.Segmenter("zh", { granularity: "word" })`。
|
||||||
|
- 非 token 事件前强制 flush,保证工具调用、done、error 的顺序正确。
|
||||||
|
|
||||||
|
### 踩坑
|
||||||
|
|
||||||
|
- 只做 `setTimeout(120ms)` 批量 flush 不够。它只是减少更新次数,并不能形成稳定播放节奏。
|
||||||
|
- interval 太小,例如 `8ms`,浏览器调度不一定更稳定,反而可能增加 React 更新压力。
|
||||||
|
- 中文按 `Intl.Segmenter` 的单个词输出会显得慢,必须结合缓冲长度动态放大 chunk。
|
||||||
|
- `done`、`error`、`tool_call` 前如果不 flush,会造成文本和结构事件顺序错乱。
|
||||||
|
|
||||||
|
## Markdown 动画
|
||||||
|
|
||||||
|
### 经验结论
|
||||||
|
|
||||||
|
Markdown 是流式文本动画里最容易出问题的部分。原因是 `ReactMarkdown` 每次都会重新解析完整内容,原始 Markdown 字符索引和最终 DOM 文本节点索引不一致。
|
||||||
|
|
||||||
|
典型例子:
|
||||||
|
|
||||||
|
- `**加粗**` 的原始长度包含 `**`,但可见文本不包含。
|
||||||
|
- 列表符号、链接语法、代码块围栏都可能影响原始索引。
|
||||||
|
- 新增文本可能落在 `p`、`li`、`strong`、`code` 等不同节点里。
|
||||||
|
|
||||||
|
因此不要简单用原始 `text.length` 或 `fadeFrom` 去对应 Markdown 渲染后的 DOM 文本。
|
||||||
|
|
||||||
|
当前策略:
|
||||||
|
|
||||||
|
- Markdown 仍完整解析,保证格式正确。
|
||||||
|
- 在 rehype 阶段处理 AST。
|
||||||
|
- 从 AST 尾部反向找最后的可见 text node。
|
||||||
|
- 只给最后一段尾部文本加动画。
|
||||||
|
- 每次最多动画最后 `48` 个字符,避免大 chunk 整段闪烁。
|
||||||
|
|
||||||
|
### 踩坑
|
||||||
|
|
||||||
|
- 用 `text.length` 作为 React key 会导致整段 Markdown remount,所有文本都会重新淡入。
|
||||||
|
- CSS animation 和 Web Animations 同时作用在同一个 span 上,会出现闪烁或动画重启。
|
||||||
|
- 在 render 阶段读写 ref 会触发 React hooks lint 规则,也容易产生不可控渲染。
|
||||||
|
- 反向遍历 AST 时拆分 text node 要注意顺序。使用 `unshift` 时应先插入动画尾巴,再插入稳定文本,最终 DOM 才是“稳定文本在前,动画尾巴在后”。
|
||||||
|
|
||||||
|
## 当前文字动画建议
|
||||||
|
|
||||||
|
推荐保留轻量动画:
|
||||||
|
|
||||||
|
- 使用 Web Animations,在 `useLayoutEffect` 中启动,避免先完整显示一帧再裁切。
|
||||||
|
- 使用 `clip-path` 做左到右 reveal。
|
||||||
|
- 叠加轻微 opacity:当前约 `0.46 -> 1`。
|
||||||
|
- 时长控制在 `120ms - 260ms`。
|
||||||
|
|
||||||
|
不要做:
|
||||||
|
|
||||||
|
- 外层整段 `motion.div` 淡入。
|
||||||
|
- 每次流式更新都改变 key。
|
||||||
|
- 对整个 Markdown AST 的新增范围大面积包 span。
|
||||||
|
- 在生成中对已有文本重复动画。
|
||||||
|
|
||||||
|
## 滚动和边框稳定
|
||||||
|
|
||||||
|
### 经验结论
|
||||||
|
|
||||||
|
滚动条在最底部时,内容增长会不断改变 `scrollTop`。如果每个 token 都执行 `scrollTop = scrollHeight` 或 `scrollIntoView`,最后一个 assistant turn 的边框会产生明显抖动。
|
||||||
|
|
||||||
|
当前策略:
|
||||||
|
|
||||||
|
- 生成中不再每个 token 精确贴底。
|
||||||
|
- 底部保留生成缓冲区,当前约 `180px`。
|
||||||
|
- 只有缓冲被消耗到阈值后才恢复滚动。
|
||||||
|
- 用户离开底部附近后,不再强制自动跟随。
|
||||||
|
- 使用 `scrollbar-gutter: stable` 减少滚动条出现/消失造成的宽度变化。
|
||||||
|
|
||||||
|
### 踩坑
|
||||||
|
|
||||||
|
- “锁最大高度”不是正确方向。问题不是高度无限增长,而是底部锚定过于频繁。
|
||||||
|
- 每 token 自动滚动会把视口不断向下推,视觉上就是边框抖动。
|
||||||
|
- 滚动判断阈值要和底部缓冲一致,否则缓冲刚出现就被判断为“离开底部”。
|
||||||
|
|
||||||
|
## 图表生成
|
||||||
|
|
||||||
|
### 经验结论
|
||||||
|
|
||||||
|
图表不能等数据到达后突然插入。图表生成应先占位,再 crossfade,再让图表内部动画接管。
|
||||||
|
|
||||||
|
当前策略:
|
||||||
|
|
||||||
|
- 工具调用 pending 时使用固定尺寸 `ChartGenerationSkeleton`。
|
||||||
|
- 图表真实数据到达后,继续短暂保留 skeleton overlay。
|
||||||
|
- ECharts 在 skeleton 下方淡入。
|
||||||
|
- 容器尺寸保持一致,避免边框高度突变。
|
||||||
|
- ECharts 内部使用 enter/update 动画,而不是外层布局动画。
|
||||||
|
|
||||||
|
图表类型动画建议:
|
||||||
|
|
||||||
|
- 折线图:平滑 enter,面积渐显。
|
||||||
|
- 柱状图:柱子从基线增长,并对数据点轻微 stagger。
|
||||||
|
- 饼图:使用 expansion/sweep 类进入动画。
|
||||||
|
- update 动画要短于 enter 动画。
|
||||||
|
|
||||||
|
### 踩坑
|
||||||
|
|
||||||
|
- 只给外层图表卡片 fade in 不够,插入瞬间仍可能造成内容跳变。
|
||||||
|
- skeleton 和最终图表尺寸不一致,会导致边框先长再缩。
|
||||||
|
- 图表更新时不要重建组件,尽量让 ECharts diff 数据并执行内部 transition。
|
||||||
|
|
||||||
|
## 状态提示
|
||||||
|
|
||||||
|
“正在生成”状态是有价值的,应该保留。它承担了部分动感和系统状态反馈,不需要让文本动画本身过于夸张。
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
- 状态放在“分析结果”标题行右侧。
|
||||||
|
- 使用小尺寸、低干扰的 pulsing dots。
|
||||||
|
- 不使用末尾光标,避免和业务文本混在一起。
|
||||||
|
|
||||||
|
## 验证建议
|
||||||
|
|
||||||
|
每次调整流式动画后至少跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx eslint src/components/chat/AgentMarkdownBlock.tsx src/components/chat/AgentTurn.tsx src/components/chat/ChatInlineChart.tsx src/components/chat/AgentWorkspace.tsx src/components/chat/GlobalChatbox.tsx src/components/chat/hooks/useAgentChatSession.ts
|
||||||
|
npx tsc --noEmit
|
||||||
|
npm test -- src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx src/components/chat/hooks/useAgentChatSession.actions.test.tsx src/components/chat/AgentWorkspace.test.tsx src/components/chat/ChatInlineChart.test.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
人工验证重点:
|
||||||
|
|
||||||
|
- 长中文回答是否明显落后后端真实速度。
|
||||||
|
- Markdown 加粗、列表、代码块是否乱序。
|
||||||
|
- 底部自动滚动时“分析结果”边框是否抖动。
|
||||||
|
- 工具调用 pending 到图表出现时是否有高度跳变。
|
||||||
|
- 用户手动上滚后是否停止强制跟随。
|
||||||
|
|
||||||
|
## 后续调整原则
|
||||||
|
|
||||||
|
1. 先调节 token playback,再调动画。
|
||||||
|
2. 动画只作用于新增内容,已有内容不能重播。
|
||||||
|
3. Markdown 动画优先保守,宁可弱一点,也不能破坏文本顺序。
|
||||||
|
4. 图表和工具调用先稳定布局,再考虑视觉效果。
|
||||||
|
5. 滚动跟随要有缓冲,不能逐 token 贴底。
|
||||||
Generated
-7
@@ -30,7 +30,6 @@
|
|||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.5",
|
"echarts-for-react": "^3.0.5",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"idb": "^8.0.3",
|
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
@@ -15844,12 +15843,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/idb": {
|
|
||||||
"version": "8.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
|
|
||||||
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
|||||||
@@ -39,7 +39,6 @@
|
|||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.5",
|
"echarts-for-react": "^3.0.5",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"idb": "^8.0.3",
|
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import SensorsRounded from "@mui/icons-material/SensorsRounded";
|
|||||||
import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded";
|
import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded";
|
||||||
|
|
||||||
import { ChatInlineChart } from "./ChatInlineChart";
|
import { ChatInlineChart } from "./ChatInlineChart";
|
||||||
import type { ChatChartSeries } from "./ChatInlineChart";
|
|
||||||
import type { AgentArtifact } from "./GlobalChatbox.types";
|
import type { AgentArtifact } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
const artifactIcon = (kind: AgentArtifact["kind"]) => {
|
const artifactIcon = (kind: AgentArtifact["kind"]) => {
|
||||||
@@ -61,8 +60,13 @@ export const AgentArtifactPanel = ({ artifacts }: { artifacts: AgentArtifact[] }
|
|||||||
chart_type={
|
chart_type={
|
||||||
(artifact.params.chart_type as "line" | "bar" | "pie") ?? "line"
|
(artifact.params.chart_type as "line" | "bar" | "pie") ?? "line"
|
||||||
}
|
}
|
||||||
x_data={(artifact.params.x_data as string[]) ?? []}
|
x_data={
|
||||||
series={(artifact.params.series as ChatChartSeries[]) ?? []}
|
artifact.params.x_data ??
|
||||||
|
artifact.params.xData ??
|
||||||
|
artifact.params.labels ??
|
||||||
|
artifact.params.categories
|
||||||
|
}
|
||||||
|
series={artifact.params.series}
|
||||||
x_axis_name={(artifact.params.x_axis_name as string) ?? undefined}
|
x_axis_name={(artifact.params.x_axis_name as string) ?? undefined}
|
||||||
y_axis_name={(artifact.params.y_axis_name as string) ?? undefined}
|
y_axis_name={(artifact.params.y_axis_name as string) ?? undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -26,46 +26,87 @@ import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
|||||||
import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
|
import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
|
||||||
import BoltRounded from "@mui/icons-material/BoltRounded";
|
import BoltRounded from "@mui/icons-material/BoltRounded";
|
||||||
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
||||||
import type { AgentModel } from "@/lib/chatStream";
|
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
||||||
|
import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded";
|
||||||
|
import type { AgentModelOption } from "@/lib/chatModels";
|
||||||
|
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
||||||
|
|
||||||
|
export type AgentComposerHandle = {
|
||||||
|
focus: () => void;
|
||||||
|
clear: () => void;
|
||||||
|
append: (text: string) => void;
|
||||||
|
setValue: (value: string) => void;
|
||||||
|
getValue: () => string;
|
||||||
|
};
|
||||||
|
|
||||||
type AgentComposerProps = {
|
type AgentComposerProps = {
|
||||||
input: string;
|
|
||||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
|
||||||
isHydrating?: boolean;
|
isHydrating?: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
isListening: boolean;
|
isListening: boolean;
|
||||||
isSttSupported: boolean;
|
isSttSupported: boolean;
|
||||||
presets: string[];
|
presets: string[];
|
||||||
onInputChange: (value: string) => void;
|
onSend: (prompt: string) => void;
|
||||||
onSend: () => void;
|
|
||||||
onAbort: () => void;
|
onAbort: () => void;
|
||||||
onStartListening: () => void;
|
onStartListening: () => void;
|
||||||
onStopListening: () => void;
|
onStopListening: () => void;
|
||||||
onPresetSelect: (prompt: string) => void;
|
modelOptions: AgentModelOption[];
|
||||||
selectedModel: AgentModel;
|
selectedModel?: AgentModel;
|
||||||
onModelChange: (model: AgentModel) => void;
|
onModelChange: (model: AgentModel) => void;
|
||||||
|
approvalMode: AgentApprovalMode;
|
||||||
|
onApprovalModeChange: (mode: AgentApprovalMode) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AgentComposer = ({
|
const renderModelIcon = (
|
||||||
input,
|
icon: AgentModelOption["icon"] | undefined,
|
||||||
inputRef,
|
props?: React.ComponentProps<typeof BoltRounded>,
|
||||||
|
) =>
|
||||||
|
icon === "bolt" ? (
|
||||||
|
<BoltRounded {...props} />
|
||||||
|
) : (
|
||||||
|
<AutoAwesomeRounded {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
|
||||||
isHydrating = false,
|
isHydrating = false,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
isListening,
|
isListening,
|
||||||
isSttSupported,
|
isSttSupported,
|
||||||
presets,
|
presets,
|
||||||
onInputChange,
|
|
||||||
onSend,
|
onSend,
|
||||||
onAbort,
|
onAbort,
|
||||||
onStartListening,
|
onStartListening,
|
||||||
onStopListening,
|
onStopListening,
|
||||||
onPresetSelect,
|
modelOptions,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
}: AgentComposerProps) => {
|
approvalMode,
|
||||||
|
onApprovalModeChange,
|
||||||
|
}, ref) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
|
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
|
||||||
|
const [input, setInput] = React.useState("");
|
||||||
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
|
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
|
||||||
|
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
|
||||||
|
const selectedModelOption = modelOptions.find((model) => model.id === selectedModel);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
|
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
|
||||||
@@ -121,8 +162,11 @@ export const AgentComposer = ({
|
|||||||
size="medium"
|
size="medium"
|
||||||
clickable
|
clickable
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onPresetSelect(prompt);
|
setInput(prompt);
|
||||||
setIsPresetOpen(false);
|
setIsPresetOpen(false);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
height: 32,
|
height: 32,
|
||||||
@@ -165,11 +209,11 @@ export const AgentComposer = ({
|
|||||||
<TextField
|
<TextField
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(event) => onInputChange(event.target.value)}
|
onChange={(event) => setInput(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onSend();
|
handleSend();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
|
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
|
||||||
@@ -221,24 +265,117 @@ export const AgentComposer = ({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
)
|
)
|
||||||
) : null}
|
) : 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>
|
||||||
|
|
||||||
<Stack direction="row" spacing={1} alignItems="center">
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
<FormControl size="small" sx={{ minWidth: 80 }}>
|
<FormControl size="small" sx={{ minWidth: 80 }}>
|
||||||
<Select
|
<Select
|
||||||
value={selectedModel}
|
value={selectedModel ?? ""}
|
||||||
onChange={(event) => onModelChange(event.target.value as AgentModel)}
|
onChange={(event) => onModelChange(event.target.value as AgentModel)}
|
||||||
disabled={isHydrating || isStreaming}
|
disabled={isHydrating || isStreaming || modelOptions.length === 0}
|
||||||
aria-label="模型选择"
|
aria-label="模型选择"
|
||||||
renderValue={(val) => (
|
renderValue={() => (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
{val === "deepseek/deepseek-v4-flash" ? (
|
{renderModelIcon(selectedModelOption?.icon, {
|
||||||
<BoltRounded sx={{ fontSize: 18, color: "inherit", transition: "color 0.2s" }} />
|
sx: {
|
||||||
) : (
|
fontSize: selectedModelOption?.icon === "bolt" ? 18 : 16,
|
||||||
<AutoAwesomeRounded sx={{ fontSize: 16, color: "inherit", transition: "color 0.2s" }} />
|
color: "inherit",
|
||||||
)}
|
transition: "color 0.2s",
|
||||||
|
},
|
||||||
|
})}
|
||||||
<Typography sx={{ fontSize: "0.8rem", fontWeight: 600, color: "inherit", transition: "color 0.2s" }}>
|
<Typography sx={{ fontSize: "0.8rem", fontWeight: 600, color: "inherit", transition: "color 0.2s" }}>
|
||||||
{val === "deepseek/deepseek-v4-flash" ? "快速" : "专家"}
|
{selectedModelOption?.label ?? "模型"}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -312,30 +449,25 @@ export const AgentComposer = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ px: 2, py: 1.5, pb: 1, display: "flex", alignItems: "center", gap: 1, pointerEvents: "none" }}>
|
<Box sx={{ px: 2, py: 1.5, pb: 1, display: "flex", alignItems: "center", gap: 1, pointerEvents: "none" }}>
|
||||||
<Box
|
<AutoAwesomeRounded sx={{ width: 16, height: 16, color: "text.secondary", flexShrink: 0 }} />
|
||||||
component="img"
|
|
||||||
src="/deepseek-logo.svg"
|
|
||||||
alt="DeepSeek"
|
|
||||||
sx={{ width: 16, height: 16, display: "block", flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
<Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}>
|
<Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}>
|
||||||
DEEPSEEK V4
|
模型选择
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<MenuItem value="deepseek/deepseek-v4-flash">
|
{modelOptions.map((model) => (
|
||||||
<BoltRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 20, color: "text.secondary", transition: "color 0.2s" }} />
|
<MenuItem key={model.id} value={model.id}>
|
||||||
<Box>
|
{renderModelIcon(model.icon, {
|
||||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>快速</Typography>
|
className: "icon",
|
||||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>快速回答和任务执行</Typography>
|
sx: { mr: 1.5, mt: 0.2, fontSize: model.icon === "bolt" ? 20 : 18, color: "text.secondary", transition: "color 0.2s" },
|
||||||
</Box>
|
})}
|
||||||
</MenuItem>
|
<Box>
|
||||||
<MenuItem value="deepseek/deepseek-v4-pro">
|
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>{model.label}</Typography>
|
||||||
<AutoAwesomeRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 18, color: "text.secondary", transition: "color 0.2s" }} />
|
{model.description ? (
|
||||||
<Box>
|
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>{model.description}</Typography>
|
||||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>专家</Typography>
|
) : null}
|
||||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>探索、解决复杂任务</Typography>
|
</Box>
|
||||||
</Box>
|
</MenuItem>
|
||||||
</MenuItem>
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@@ -362,7 +494,7 @@ export const AgentComposer = ({
|
|||||||
<motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
<motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
disabled={!canSend}
|
disabled={!canSend}
|
||||||
onClick={onSend}
|
onClick={handleSend}
|
||||||
aria-label="发送"
|
aria-label="发送"
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
@@ -397,4 +529,4 @@ export const AgentComposer = ({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
|
||||||
|
|
||||||
import { AgentHeader } from "./AgentHeader";
|
|
||||||
|
|
||||||
jest.mock("next/image", () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: (props: React.ComponentProps<"img">) => <img {...props} alt={props.alt ?? ""} />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const renderWithTheme = (ui: React.ReactElement) =>
|
|
||||||
render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>);
|
|
||||||
|
|
||||||
describe("AgentHeader", () => {
|
|
||||||
it("submits a renamed active session title", () => {
|
|
||||||
const onRenameSessionTitle = jest.fn();
|
|
||||||
|
|
||||||
renderWithTheme(
|
|
||||||
<AgentHeader
|
|
||||||
sessionTitle="原始标题"
|
|
||||||
canRenameSessionTitle
|
|
||||||
isStreaming={false}
|
|
||||||
isHistoryOpen={false}
|
|
||||||
onHistoryToggle={jest.fn()}
|
|
||||||
onRenameSessionTitle={onRenameSessionTitle}
|
|
||||||
onNewConversation={jest.fn()}
|
|
||||||
onClose={jest.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "修改对话标题" }));
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("请输入对话标题"), {
|
|
||||||
target: { value: "更新后的标题" },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByLabelText("确认修改对话标题"));
|
|
||||||
|
|
||||||
expect(onRenameSessionTitle).toHaveBeenCalledWith("更新后的标题");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -44,7 +44,7 @@ export const AgentHeader = ({
|
|||||||
onClose,
|
onClose,
|
||||||
}: AgentHeaderProps) => {
|
}: AgentHeaderProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const displayTitle = sessionTitle?.trim() || "TJWater Agent";
|
const displayTitle = sessionTitle?.trim() || "新对话";
|
||||||
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
|
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
|
||||||
const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || "");
|
const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || "");
|
||||||
|
|
||||||
@@ -87,8 +87,8 @@ export const AgentHeader = ({
|
|||||||
boxShadow: `0 1px 0 ${alpha("#fff", 0.6)} inset`,
|
boxShadow: `0 1px 0 ${alpha("#fff", 0.6)} inset`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack direction="row" alignItems="center" spacing={2}>
|
<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" }}>
|
<motion.div whileHover={{ rotate: 10, scale: 1.05 }} whileTap={{ scale: 0.95 }} style={{ display: "flex", flexShrink: 0 }}>
|
||||||
<Box sx={{ position: "relative" }}>
|
<Box sx={{ position: "relative" }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
sx={{
|
sx={{
|
||||||
@@ -129,10 +129,9 @@ export const AgentHeader = ({
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<Box sx={{ minWidth: 0, minHeight: 52, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
{isEditingTitle ? (
|
{isEditingTitle ? (
|
||||||
<Box>
|
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ width: "100%" }}>
|
||||||
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ width: { xs: "calc(100vw - 256px)", sm: 280 }, transform: "translateY(2px)" }}>
|
|
||||||
<TextField
|
<TextField
|
||||||
value={draftTitle}
|
value={draftTitle}
|
||||||
onChange={(event) => setDraftTitle(event.target.value)}
|
onChange={(event) => setDraftTitle(event.target.value)}
|
||||||
@@ -152,27 +151,35 @@ export const AgentHeader = ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
"& .MuiOutlinedInput-root": {
|
"& .MuiOutlinedInput-root": {
|
||||||
height: 34,
|
padding: "6px 8px",
|
||||||
bgcolor: alpha("#fff", 0.7),
|
bgcolor: "transparent",
|
||||||
borderRadius: 1.5,
|
borderRadius: 1.5,
|
||||||
transition: "all 0.2s ease-in-out",
|
transition: "all 0.2s ease-in-out",
|
||||||
|
"&.Mui-focused": {
|
||||||
|
bgcolor: alpha("#fff", 0.6),
|
||||||
|
boxShadow: `0 2px 10px ${alpha("#000", 0.05)}`,
|
||||||
|
},
|
||||||
"& fieldset": {
|
"& fieldset": {
|
||||||
borderColor: alpha("#000", 0.08),
|
borderColor: "transparent",
|
||||||
},
|
},
|
||||||
"&:hover fieldset": {
|
"&:hover fieldset": {
|
||||||
borderColor: alpha(theme.palette.primary.main, 0.4),
|
borderColor: alpha(theme.palette.primary.main, 0.2),
|
||||||
},
|
},
|
||||||
"&.Mui-focused fieldset": {
|
"&.Mui-focused fieldset": {
|
||||||
borderColor: theme.palette.primary.main,
|
borderColor: alpha(theme.palette.primary.main, 0.5),
|
||||||
borderWidth: "1.5px",
|
borderWidth: "1px",
|
||||||
boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"& .MuiInputBase-input": {
|
"& .MuiInputBase-input": {
|
||||||
padding: "4px 12px",
|
padding: 0,
|
||||||
fontSize: "1.05rem",
|
height: "auto",
|
||||||
fontWeight: 700,
|
fontSize: "1.25rem",
|
||||||
color: theme.palette.text.primary,
|
fontWeight: 800,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
lineHeight: "1.2",
|
||||||
|
background: `linear-gradient(90deg, #01579b, #00838f)`,
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent",
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -206,22 +213,21 @@ export const AgentHeader = ({
|
|||||||
<CloseRounded sx={{ fontSize: 18 }} />
|
<CloseRounded sx={{ fontSize: 18 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}>
|
||||||
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}>
|
<Typography
|
||||||
<Typography
|
|
||||||
variant="h6"
|
variant="h6"
|
||||||
fontWeight={800}
|
fontWeight={800}
|
||||||
sx={{
|
sx={{
|
||||||
background: `linear-gradient(90deg, #01579b, #00838f)`,
|
background: `linear-gradient(90deg, #01579b, #00838f)`,
|
||||||
backgroundClip: "text",
|
WebkitBackgroundClip: "text",
|
||||||
color: "transparent",
|
WebkitTextFillColor: "transparent",
|
||||||
letterSpacing: -0.3,
|
letterSpacing: -0.3,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
maxWidth: { xs: "calc(100vw - 256px)", sm: 284 },
|
px: "8px",
|
||||||
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{displayTitle}
|
{displayTitle}
|
||||||
@@ -235,8 +241,8 @@ export const AgentHeader = ({
|
|||||||
onClick={handleStartEditing}
|
onClick={handleStartEditing}
|
||||||
disabled={isHydrating || isStreaming}
|
disabled={isHydrating || isStreaming}
|
||||||
sx={{
|
sx={{
|
||||||
width: 24,
|
width: 30,
|
||||||
height: 24,
|
height: 30,
|
||||||
color: "text.secondary",
|
color: "text.secondary",
|
||||||
bgcolor: alpha("#fff", 0.45),
|
bgcolor: alpha("#fff", 0.45),
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
@@ -245,25 +251,17 @@ export const AgentHeader = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EditRounded sx={{ fontSize: 16 }} />
|
<EditRounded sx={{ fontSize: 18 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
|
||||||
{isStreaming
|
|
||||||
? "正在思考分析任务..."
|
|
||||||
: displayTitle === "TJWater Agent"
|
|
||||||
? "基于大模型的水力分析引擎"
|
|
||||||
: "当前会话标题"}
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack direction="row" spacing={1.25} alignItems="center">
|
<Stack direction="row" spacing={1.25} alignItems="center" sx={{ flexShrink: 0 }}>
|
||||||
<Tooltip title="新建对话">
|
<Tooltip title="新建对话">
|
||||||
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
|
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -8,6 +8,56 @@ const renderWithTheme = (ui: React.ReactElement) =>
|
|||||||
render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>);
|
render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>);
|
||||||
|
|
||||||
describe("AgentHistoryPanel", () => {
|
describe("AgentHistoryPanel", () => {
|
||||||
|
it("shows skeleton rows while history sessions are loading", () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<AgentHistoryPanel
|
||||||
|
sessions={[]}
|
||||||
|
isLoadingSessions
|
||||||
|
onNewSession={jest.fn()}
|
||||||
|
onRenameSession={jest.fn()}
|
||||||
|
onSelectSession={jest.fn()}
|
||||||
|
onDeleteSession={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("正在加载历史会话")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("暂无历史会话")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the loading history session item", () => {
|
||||||
|
const onSelectSession = jest.fn();
|
||||||
|
const onRenameSession = jest.fn();
|
||||||
|
const onDeleteSession = jest.fn();
|
||||||
|
|
||||||
|
renderWithTheme(
|
||||||
|
<AgentHistoryPanel
|
||||||
|
sessions={[
|
||||||
|
{
|
||||||
|
id: "session-loading",
|
||||||
|
title: "正在加载的会话",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
loadingSessionId="session-loading"
|
||||||
|
onNewSession={jest.fn()}
|
||||||
|
onRenameSession={onRenameSession}
|
||||||
|
onSelectSession={onSelectSession}
|
||||||
|
onDeleteSession={onDeleteSession}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText("正在加载的会话")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "修改会话标题" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "删除会话" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("正在加载会话 正在加载的会话"));
|
||||||
|
|
||||||
|
expect(onSelectSession).not.toHaveBeenCalled();
|
||||||
|
expect(onRenameSession).not.toHaveBeenCalled();
|
||||||
|
expect(onDeleteSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("renames a history session from the list", () => {
|
it("renames a history session from the list", () => {
|
||||||
const onRenameSession = jest.fn();
|
const onRenameSession = jest.fn();
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
IconButton,
|
IconButton,
|
||||||
Paper,
|
Paper,
|
||||||
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -34,9 +35,11 @@ type AgentHistoryPanelProps = {
|
|||||||
sessions: ChatSessionSummary[];
|
sessions: ChatSessionSummary[];
|
||||||
activeSessionId?: string;
|
activeSessionId?: string;
|
||||||
isHydrating?: boolean;
|
isHydrating?: boolean;
|
||||||
|
isLoadingSessions?: boolean;
|
||||||
|
loadingSessionId?: string;
|
||||||
onNewSession: () => void;
|
onNewSession: () => void;
|
||||||
onRenameSession: (sessionId: string, title: string) => void;
|
onRenameSession: (sessionId: string, title: string) => void;
|
||||||
onSelectSession: (sessionId: string) => void;
|
onSelectSession: (sessionId: string, title: string) => void;
|
||||||
onDeleteSession: (sessionId: string) => void;
|
onDeleteSession: (sessionId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,6 +79,8 @@ export const AgentHistoryPanel = ({
|
|||||||
sessions,
|
sessions,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
isHydrating = false,
|
isHydrating = false,
|
||||||
|
isLoadingSessions = false,
|
||||||
|
loadingSessionId,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
onRenameSession,
|
onRenameSession,
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
@@ -85,7 +90,6 @@ export const AgentHistoryPanel = ({
|
|||||||
const [keyword, setKeyword] = React.useState("");
|
const [keyword, setKeyword] = React.useState("");
|
||||||
const [editingSessionId, setEditingSessionId] = React.useState<string | null>(null);
|
const [editingSessionId, setEditingSessionId] = React.useState<string | null>(null);
|
||||||
const [draftTitle, setDraftTitle] = React.useState("");
|
const [draftTitle, setDraftTitle] = React.useState("");
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
|
|
||||||
const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState<string | null>(null);
|
const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const filteredSessions = React.useMemo(() => {
|
const filteredSessions = React.useMemo(() => {
|
||||||
@@ -128,6 +132,30 @@ export const AgentHistoryPanel = ({
|
|||||||
(session) => session.id === pendingDeleteSessionId,
|
(session) => session.id === pendingDeleteSessionId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderSessionListSkeleton = () => (
|
||||||
|
<Stack spacing={1} aria-label="正在加载历史会话">
|
||||||
|
{Array.from({ length: 6 }, (_, index) => (
|
||||||
|
<Paper
|
||||||
|
key={index}
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
px: 1.25,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: alpha("#fff", 0.48),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.68)}`,
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#000", 0.025)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={0.75} sx={{ minHeight: 46, justifyContent: "center" }}>
|
||||||
|
<Skeleton variant="text" width={`${72 - (index % 3) * 12}%`} height={18} />
|
||||||
|
<Skeleton variant="text" width="32%" height={14} />
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
const handleStartRename = (sessionId: string, title: string) => {
|
const handleStartRename = (sessionId: string, title: string) => {
|
||||||
setEditingSessionId(sessionId);
|
setEditingSessionId(sessionId);
|
||||||
setDraftTitle(title);
|
setDraftTitle(title);
|
||||||
@@ -166,9 +194,6 @@ export const AgentHistoryPanel = ({
|
|||||||
<Typography variant="subtitle2" fontWeight={800} color="text.primary">
|
<Typography variant="subtitle2" fontWeight={800} color="text.primary">
|
||||||
历史会话
|
历史会话
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
本地保存于浏览器
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Tooltip title="新建对话">
|
<Tooltip title="新建对话">
|
||||||
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
|
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
|
||||||
@@ -219,7 +244,9 @@ export const AgentHistoryPanel = ({
|
|||||||
<Divider sx={{ borderColor: alpha("#fff", 0.6) }} />
|
<Divider sx={{ borderColor: alpha("#fff", 0.6) }} />
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflowY: "auto", px: 1.25, py: 1.25 }}>
|
<Box sx={{ flex: 1, overflowY: "auto", px: 1.25, py: 1.25 }}>
|
||||||
{sessions.length === 0 ? (
|
{isLoadingSessions ? (
|
||||||
|
renderSessionListSkeleton()
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
<Stack
|
<Stack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
@@ -275,32 +302,47 @@ export const AgentHistoryPanel = ({
|
|||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
{groupSessions.map((session) => {
|
{groupSessions.map((session) => {
|
||||||
const isActive = session.id === activeSessionId;
|
const isActive = session.id === activeSessionId;
|
||||||
|
const isLoading = session.id === loadingSessionId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
key={session.id}
|
key={session.id}
|
||||||
elevation={0}
|
elevation={0}
|
||||||
|
aria-label={isLoading ? `正在加载会话 ${session.title}` : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (editingSessionId === session.id) return;
|
if (editingSessionId === session.id || isLoading) return;
|
||||||
onSelectSession(session.id);
|
onSelectSession(session.id, session.title);
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
px: 1.25,
|
px: 1.25,
|
||||||
py: 1,
|
py: 1,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
cursor: isHydrating ? "default" : "pointer",
|
cursor: isHydrating || isLoading ? "default" : "pointer",
|
||||||
bgcolor: isActive ? alpha("#00acc1", 0.12) : alpha("#fff", 0.56),
|
bgcolor:
|
||||||
border: `1px solid ${isActive ? alpha("#00acc1", 0.25) : alpha("#fff", 0.72)}`,
|
isActive || isLoading
|
||||||
boxShadow: isActive ? `0 8px 20px ${alpha("#00acc1", 0.12)}` : `0 4px 12px ${alpha("#000", 0.03)}`,
|
? alpha("#00acc1", 0.12)
|
||||||
|
: alpha("#fff", 0.56),
|
||||||
|
border: `1px solid ${
|
||||||
|
isActive || isLoading
|
||||||
|
? alpha("#00acc1", 0.25)
|
||||||
|
: alpha("#fff", 0.72)
|
||||||
|
}`,
|
||||||
|
boxShadow:
|
||||||
|
isActive || isLoading
|
||||||
|
? `0 8px 20px ${alpha("#00acc1", 0.12)}`
|
||||||
|
: `0 4px 12px ${alpha("#000", 0.03)}`,
|
||||||
transition: "all 0.2s ease",
|
transition: "all 0.2s ease",
|
||||||
pointerEvents: isHydrating ? "none" : "auto",
|
pointerEvents: isHydrating || isLoading ? "none" : "auto",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
bgcolor: isActive ? alpha("#00acc1", 0.14) : alpha("#fff", 0.86),
|
bgcolor:
|
||||||
|
isActive || isLoading
|
||||||
|
? alpha("#00acc1", 0.14)
|
||||||
|
: alpha("#fff", 0.86),
|
||||||
borderColor: alpha("#00acc1", 0.2),
|
borderColor: alpha("#00acc1", 0.2),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack direction="row" spacing={1} alignItems="flex-start">
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
{editingSessionId === session.id ? (
|
{editingSessionId === session.id ? (
|
||||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ minHeight: 46 }}>
|
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ minHeight: 46 }}>
|
||||||
@@ -386,6 +428,43 @@ export const AgentHistoryPanel = ({
|
|||||||
<CloseRounded sx={{ fontSize: 16 }} />
|
<CloseRounded sx={{ fontSize: 16 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
) : isLoading ? (
|
||||||
|
<Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
||||||
|
<Skeleton variant="text" width="74%" height={18} />
|
||||||
|
<Skeleton variant="text" width="34%" height={14} sx={{ mt: 0.5 }} />
|
||||||
|
</Box>
|
||||||
|
) : pendingDeleteSessionId === session.id ? (
|
||||||
|
<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" }}>
|
<Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
||||||
<Typography
|
<Typography
|
||||||
@@ -409,8 +488,9 @@ export const AgentHistoryPanel = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Stack direction="row" spacing={0.25} sx={{ display: editingSessionId === session.id ? 'none' : 'flex' }}>
|
{!(editingSessionId === session.id || pendingDeleteSessionId === session.id || isLoading) && (
|
||||||
<Tooltip title="修改会话标题">
|
<Stack direction="row" spacing={0.25}>
|
||||||
|
<Tooltip title="修改会话标题">
|
||||||
<span>
|
<span>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
@@ -421,8 +501,8 @@ export const AgentHistoryPanel = ({
|
|||||||
}}
|
}}
|
||||||
disabled={isHydrating || editingSessionId === session.id}
|
disabled={isHydrating || editingSessionId === session.id}
|
||||||
sx={{
|
sx={{
|
||||||
width: 24,
|
width: 28,
|
||||||
height: 24,
|
height: 28,
|
||||||
color: "text.secondary",
|
color: "text.secondary",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
color: "primary.main",
|
color: "primary.main",
|
||||||
@@ -442,11 +522,11 @@ export const AgentHistoryPanel = ({
|
|||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setPendingDeleteSessionId(session.id);
|
setPendingDeleteSessionId(session.id);
|
||||||
setIsDeleteDialogOpen(true);
|
|
||||||
}}
|
}}
|
||||||
|
disabled={isHydrating}
|
||||||
sx={{
|
sx={{
|
||||||
width: 24,
|
width: 28,
|
||||||
height: 24,
|
height: 28,
|
||||||
color: "text.secondary",
|
color: "text.secondary",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
color: "error.main",
|
color: "error.main",
|
||||||
@@ -459,6 +539,48 @@ export const AgentHistoryPanel = ({
|
|||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Stack>
|
</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>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
@@ -470,97 +592,6 @@ export const AgentHistoryPanel = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={isDeleteDialogOpen}
|
|
||||||
onClose={() => setIsDeleteDialogOpen(false)}
|
|
||||||
sx={{ zIndex: (theme) => theme.zIndex.modal + 200 }}
|
|
||||||
TransitionProps={{
|
|
||||||
onExited: () => setPendingDeleteSessionId(null)
|
|
||||||
}}
|
|
||||||
PaperProps={{
|
|
||||||
sx: {
|
|
||||||
borderRadius: 4,
|
|
||||||
bgcolor: alpha("#fff", 0.85),
|
|
||||||
backdropFilter: "blur(24px)",
|
|
||||||
boxShadow: `0 16px 40px ${alpha("#000", 0.12)}`,
|
|
||||||
border: `1px solid ${alpha("#fff", 0.6)}`,
|
|
||||||
minWidth: 320,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTitle sx={{ display: "flex", alignItems: "center", gap: 1.5, pb: 1, pt: 3, px: 3 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: "50%",
|
|
||||||
bgcolor: alpha("#ef5350", 0.12),
|
|
||||||
color: "#ef5350",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<WarningRounded sx={{ fontSize: 22 }} />
|
|
||||||
</Box>
|
|
||||||
<Typography component="span" variant="h6" fontWeight={800} color="text.primary">
|
|
||||||
删除确认
|
|
||||||
</Typography>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent sx={{ px: 3, pb: 2 }}>
|
|
||||||
<DialogContentText color="text.secondary" sx={{ fontSize: "0.95rem" }}>
|
|
||||||
确定要删除
|
|
||||||
{pendingDeleteSession ? (
|
|
||||||
<Typography component="span" fontWeight={700} color="text.primary">
|
|
||||||
“{pendingDeleteSession.title}”
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
"该会话"
|
|
||||||
)}
|
|
||||||
吗?
|
|
||||||
<br />
|
|
||||||
此操作不可撤销,删除后聊天记录将永久丢失。
|
|
||||||
</DialogContentText>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions sx={{ px: 3, pb: 3, pt: 1 }}>
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsDeleteDialogOpen(false)}
|
|
||||||
sx={{
|
|
||||||
color: "text.secondary",
|
|
||||||
fontWeight: 600,
|
|
||||||
borderRadius: 2.5,
|
|
||||||
px: 2.5,
|
|
||||||
"&:hover": { bgcolor: alpha("#000", 0.04) },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => {
|
|
||||||
if (pendingDeleteSessionId) {
|
|
||||||
onDeleteSession(pendingDeleteSessionId);
|
|
||||||
}
|
|
||||||
setIsDeleteDialogOpen(false);
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
bgcolor: "#ef5350",
|
|
||||||
color: "#fff",
|
|
||||||
fontWeight: 700,
|
|
||||||
borderRadius: 2.5,
|
|
||||||
px: 3,
|
|
||||||
boxShadow: `0 4px 12px ${alpha("#ef5350", 0.3)}`,
|
|
||||||
"&:hover": {
|
|
||||||
bgcolor: "#e53935",
|
|
||||||
boxShadow: `0 6px 16px ${alpha("#ef5350", 0.4)}`,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
确认删除
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import type { Element, Root, RootContent, Text } from "hast";
|
||||||
|
import ReactMarkdown, { type Components } from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
|
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
||||||
|
|
||||||
|
export const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, "");
|
||||||
|
|
||||||
|
const isTextNode = (node: RootContent): node is Text => node.type === "text";
|
||||||
|
|
||||||
|
const isElementNode = (node: RootContent): node is Element => node.type === "element";
|
||||||
|
|
||||||
|
const createFadeSpan = (value: string, fadeKey: string): Element => ({
|
||||||
|
type: "element",
|
||||||
|
tagName: "span",
|
||||||
|
properties: {
|
||||||
|
className: [markdownStyles.streamFade],
|
||||||
|
dataStreamFadeKey: fadeKey,
|
||||||
|
dataStreamRevealLength: value.length,
|
||||||
|
},
|
||||||
|
children: [{ type: "text", value }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const splitTextTail = (value: string, tailLength: number) => {
|
||||||
|
const codePoints = Array.from(value);
|
||||||
|
const stableText = codePoints.slice(0, -tailLength).join("");
|
||||||
|
const animatedText = codePoints.slice(-tailLength).join("");
|
||||||
|
return { stableText, animatedText };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createStreamFadePlugin = (fadeLength: number, fadeKey: string) => {
|
||||||
|
return () => (tree: Root) => {
|
||||||
|
let remainingFadeLength = fadeLength;
|
||||||
|
|
||||||
|
const visitChildren = (parent: Element | Root) => {
|
||||||
|
const nextChildren: RootContent[] = [];
|
||||||
|
|
||||||
|
for (let index = parent.children.length - 1; index >= 0; index -= 1) {
|
||||||
|
const child = (parent.children as RootContent[])[index];
|
||||||
|
if (isTextNode(child)) {
|
||||||
|
if (!child.value.trim()) {
|
||||||
|
nextChildren.unshift(child);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainingFadeLength <= 0) {
|
||||||
|
nextChildren.unshift(child);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textLength = Array.from(child.value).length;
|
||||||
|
const tailLength = Math.min(textLength, remainingFadeLength);
|
||||||
|
const { stableText, animatedText } = splitTextTail(child.value, tailLength);
|
||||||
|
remainingFadeLength -= tailLength;
|
||||||
|
|
||||||
|
if (animatedText) {
|
||||||
|
nextChildren.unshift(createFadeSpan(animatedText, fadeKey));
|
||||||
|
}
|
||||||
|
if (stableText) {
|
||||||
|
nextChildren.unshift({ ...child, value: stableText });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isElementNode(child)) {
|
||||||
|
visitChildren(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextChildren.unshift(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.children = nextChildren as typeof parent.children;
|
||||||
|
};
|
||||||
|
|
||||||
|
visitChildren(tree);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const StreamFadeSpan: Components["span"] = ({ node, children, ...props }) => {
|
||||||
|
const ref = React.useRef<HTMLSpanElement>(null);
|
||||||
|
const fadeKeyValue = node?.properties?.dataStreamFadeKey;
|
||||||
|
const fadeKey = typeof fadeKeyValue === "string" ? fadeKeyValue : undefined;
|
||||||
|
const revealLengthValue = node?.properties?.dataStreamRevealLength;
|
||||||
|
const revealLength =
|
||||||
|
typeof revealLengthValue === "number"
|
||||||
|
? revealLengthValue
|
||||||
|
: typeof revealLengthValue === "string"
|
||||||
|
? Number.parseInt(revealLengthValue, 10)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!fadeKey) return;
|
||||||
|
|
||||||
|
const element = ref.current;
|
||||||
|
if (!element) return;
|
||||||
|
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
|
||||||
|
|
||||||
|
const duration = Math.min(260, Math.max(120, revealLength * 14));
|
||||||
|
const animation = element.animate(
|
||||||
|
[
|
||||||
|
{ clipPath: "inset(0 100% 0 0)", opacity: 0.46 },
|
||||||
|
{ clipPath: "inset(0 0% 0 0)", opacity: 1 },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration,
|
||||||
|
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
fill: "both",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
animation.cancel();
|
||||||
|
};
|
||||||
|
}, [fadeKey, revealLength]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span {...props} ref={ref}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const markdownComponents: Components = {
|
||||||
|
span: StreamFadeSpan,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MarkdownBlock = ({
|
||||||
|
children,
|
||||||
|
streamFadeKey,
|
||||||
|
streamFadeLength,
|
||||||
|
}: {
|
||||||
|
children: string;
|
||||||
|
streamFadeKey?: string;
|
||||||
|
streamFadeLength?: number | null;
|
||||||
|
}) => {
|
||||||
|
const handleCopy = React.useCallback((event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||||
|
const selectedText = window.getSelection()?.toString();
|
||||||
|
if (!selectedText) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText));
|
||||||
|
}, []);
|
||||||
|
const rehypePlugins = React.useMemo(
|
||||||
|
() =>
|
||||||
|
typeof streamFadeLength === "number" && streamFadeLength > 0
|
||||||
|
? [createStreamFadePlugin(
|
||||||
|
streamFadeLength,
|
||||||
|
streamFadeKey ?? `stream-tail-${children.length}`,
|
||||||
|
)]
|
||||||
|
: [],
|
||||||
|
[children.length, streamFadeKey, streamFadeLength],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={markdownStyles.markdown} onCopy={handleCopy}>
|
||||||
|
<ReactMarkdown
|
||||||
|
components={markdownComponents}
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Collapse,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { Theme } from "@mui/material/styles";
|
||||||
|
import TerminalRounded from "@mui/icons-material/TerminalRounded";
|
||||||
|
import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
|
||||||
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||||
|
import BlockRounded from "@mui/icons-material/BlockRounded";
|
||||||
|
import PushPinRounded from "@mui/icons-material/PushPinRounded";
|
||||||
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||||
|
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
||||||
|
|
||||||
|
import type { PermissionReply } from "@/lib/chatStream";
|
||||||
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => {
|
||||||
|
if (permission.permission === "external_directory") return "访问工作区外目录";
|
||||||
|
if (permission.permission === "bash") return "执行终端命令";
|
||||||
|
if (permission.permission === "edit") return "修改文件内容";
|
||||||
|
return permission.permission || "工具权限请求";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPermissionPrimaryValue = (
|
||||||
|
permission: NonNullable<Message["permissions"]>[number],
|
||||||
|
) => {
|
||||||
|
if (typeof permission.target === "string" && permission.target.trim()) {
|
||||||
|
return permission.target.trim();
|
||||||
|
}
|
||||||
|
return permission.patterns[0] ?? permission.permission;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PermissionIcon = ({
|
||||||
|
permission,
|
||||||
|
}: {
|
||||||
|
permission: NonNullable<Message["permissions"]>[number];
|
||||||
|
}) => {
|
||||||
|
if (permission.permission === "bash") {
|
||||||
|
return <TerminalRounded sx={{ fontSize: 22 }} />;
|
||||||
|
}
|
||||||
|
if (permission.permission === "external_directory") {
|
||||||
|
return <FolderOpenRounded sx={{ fontSize: 22 }} />;
|
||||||
|
}
|
||||||
|
return <VerifiedUserRounded sx={{ fontSize: 22 }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[number]["status"]) => {
|
||||||
|
if (status === "approved_always") return "已始终允许";
|
||||||
|
if (status === "approved_once") return "已允许一次";
|
||||||
|
if (status === "rejected") return "已拒绝";
|
||||||
|
if (status === "aborted") return "已中断";
|
||||||
|
if (status === "error") return "提交失败";
|
||||||
|
if (status === "submitting") return "提交中";
|
||||||
|
return "等待确认";
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingPermissionColor = "#f9a825";
|
||||||
|
const approvedOncePermissionColor = "#00838f";
|
||||||
|
|
||||||
|
const getPermissionStatusColor = (
|
||||||
|
status: NonNullable<Message["permissions"]>[number]["status"],
|
||||||
|
theme: Theme,
|
||||||
|
) => {
|
||||||
|
if (status === "approved_once") return approvedOncePermissionColor;
|
||||||
|
if (status === "approved_always") return theme.palette.success.main;
|
||||||
|
if (status === "rejected" || status === "error") return theme.palette.error.main;
|
||||||
|
if (status === "aborted") return theme.palette.text.secondary;
|
||||||
|
return pendingPermissionColor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPermissionStatusTextColor = (
|
||||||
|
status: NonNullable<Message["permissions"]>[number]["status"],
|
||||||
|
theme: Theme,
|
||||||
|
) => {
|
||||||
|
if (status === "approved_once") return "#006c78";
|
||||||
|
if (status === "approved_always") return theme.palette.success.dark;
|
||||||
|
if (status === "rejected" || status === "error") return theme.palette.error.main;
|
||||||
|
if (status === "aborted") return theme.palette.text.secondary;
|
||||||
|
return "#8a5a00";
|
||||||
|
};
|
||||||
|
|
||||||
|
const PermissionRequestCard = ({
|
||||||
|
permission,
|
||||||
|
isRunning,
|
||||||
|
onReply,
|
||||||
|
}: {
|
||||||
|
permission: NonNullable<Message["permissions"]>[number];
|
||||||
|
isRunning: boolean;
|
||||||
|
onReply: (requestId: string, reply: PermissionReply) => void;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isPending =
|
||||||
|
isRunning && (permission.status === "pending" || permission.status === "error");
|
||||||
|
const isSubmitting = isRunning && permission.status === "submitting";
|
||||||
|
const primaryValue = getPermissionPrimaryValue(permission);
|
||||||
|
const accentColor = getPermissionStatusColor(permission.status, theme);
|
||||||
|
const statusTextColor = getPermissionStatusTextColor(permission.status, theme);
|
||||||
|
const statusLabel = getPermissionStatusLabel(permission.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.5),
|
||||||
|
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
|
||||||
|
backdropFilter: "blur(20px)",
|
||||||
|
position: "relative",
|
||||||
|
"&::before": {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
inset: "10px auto 10px 0",
|
||||||
|
width: 3,
|
||||||
|
borderRadius: "0 999px 999px 0",
|
||||||
|
bgcolor: accentColor,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.25,
|
||||||
|
pl: 1.75,
|
||||||
|
borderBottom: `1px solid ${alpha("#000", 0.05)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: accentColor,
|
||||||
|
bgcolor: alpha(accentColor, 0.1),
|
||||||
|
border: `1px solid ${alpha(accentColor, 0.16)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PermissionIcon permission={permission} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||||
|
{getPermissionTitle(permission)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={statusLabel}
|
||||||
|
sx={{
|
||||||
|
height: 24,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
borderRadius: "12px",
|
||||||
|
bgcolor: alpha(accentColor, 0.12),
|
||||||
|
color: statusTextColor,
|
||||||
|
"& .MuiChip-label": { px: 1 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1.25,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
bgcolor: alpha("#000", 0.025),
|
||||||
|
border: `1px solid ${alpha("#000", 0.045)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={800}>
|
||||||
|
请求目标
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.primary"
|
||||||
|
fontFamily={permission.permission === "bash" ? "monospace" : undefined}
|
||||||
|
sx={{
|
||||||
|
mt: 0.25,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
wordBreak: "break-word",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{primaryValue}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{permission.error ? (
|
||||||
|
<Box sx={{ px: 1.5, pb: isPending || isSubmitting ? 1 : 1.35, pl: 1.75 }}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="error.main"
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: alpha(theme.palette.error.main, 0.06),
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{permission.error}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isPending || isSubmitting ? (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
flexWrap="wrap"
|
||||||
|
useFlexGap
|
||||||
|
sx={{ px: 1.5, pb: 1.35, pl: 1.75, pt: 0 }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
disableElevation
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => onReply(permission.requestId, "once")}
|
||||||
|
startIcon={
|
||||||
|
isSubmitting ? (
|
||||||
|
<CircularProgress size={14} color="inherit" />
|
||||||
|
) : (
|
||||||
|
<CheckCircleRounded fontSize="small" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
minWidth: 94,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "17px",
|
||||||
|
bgcolor: "#00838f",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
textTransform: "none",
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`,
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: "#006c78",
|
||||||
|
boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
允许一次
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => onReply(permission.requestId, "always")}
|
||||||
|
startIcon={<PushPinRounded fontSize="small" />}
|
||||||
|
sx={{
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "17px",
|
||||||
|
px: 1.5,
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
textTransform: "none",
|
||||||
|
color: "#00838f",
|
||||||
|
borderColor: alpha("#00838f", 0.24),
|
||||||
|
bgcolor: alpha("#fff", 0.45),
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: alpha("#00838f", 0.36),
|
||||||
|
bgcolor: alpha("#00838f", 0.08),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
始终允许
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => onReply(permission.requestId, "reject")}
|
||||||
|
startIcon={<BlockRounded fontSize="small" />}
|
||||||
|
sx={{
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "17px",
|
||||||
|
px: 1.5,
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
textTransform: "none",
|
||||||
|
borderColor: alpha(theme.palette.error.main, 0.22),
|
||||||
|
bgcolor: alpha("#fff", 0.45),
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: alpha(theme.palette.error.main, 0.34),
|
||||||
|
bgcolor: alpha(theme.palette.error.main, 0.07),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
拒绝
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PermissionRequestGroup = ({
|
||||||
|
permissions,
|
||||||
|
isRunning,
|
||||||
|
onReply,
|
||||||
|
}: {
|
||||||
|
permissions: NonNullable<Message["permissions"]>;
|
||||||
|
isRunning: boolean;
|
||||||
|
onReply: (requestId: string, reply: PermissionReply) => void;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const onceCount = permissions.filter((permission) => permission.status === "approved_once").length;
|
||||||
|
const alwaysCount = permissions.filter((permission) => permission.status === "approved_always").length;
|
||||||
|
const rejectedCount = permissions.filter((permission) => permission.status === "rejected").length;
|
||||||
|
const abortedCount = permissions.filter((permission) => permission.status === "aborted").length;
|
||||||
|
const pendingCount = permissions.filter(
|
||||||
|
(permission) =>
|
||||||
|
permission.status === "pending" ||
|
||||||
|
permission.status === "submitting" ||
|
||||||
|
permission.status === "error",
|
||||||
|
).length;
|
||||||
|
const hasPendingPermissions = pendingCount > 0;
|
||||||
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
|
const latestPermissions = permissions.slice(-3);
|
||||||
|
const pendingPermissions = permissions.filter(
|
||||||
|
(permission) =>
|
||||||
|
permission.status === "pending" ||
|
||||||
|
permission.status === "submitting" ||
|
||||||
|
permission.status === "error",
|
||||||
|
);
|
||||||
|
const summaryItems = [
|
||||||
|
{ label: "共", value: permissions.length, color: theme.palette.text.secondary },
|
||||||
|
{ label: "允许一次", value: onceCount, color: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) },
|
||||||
|
{ label: "始终允许", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) },
|
||||||
|
{ label: "拒绝", value: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) },
|
||||||
|
{ label: "中断", value: abortedCount, color: getPermissionStatusColor("aborted", theme), textColor: getPermissionStatusTextColor("aborted", theme) },
|
||||||
|
];
|
||||||
|
const chipColor =
|
||||||
|
pendingCount > 0
|
||||||
|
? getPermissionStatusColor("pending", theme)
|
||||||
|
: abortedCount > 0
|
||||||
|
? getPermissionStatusColor("aborted", theme)
|
||||||
|
: rejectedCount > 0
|
||||||
|
? getPermissionStatusColor("rejected", theme)
|
||||||
|
: getPermissionStatusColor("approved_always", theme);
|
||||||
|
const chipTextColor =
|
||||||
|
pendingCount > 0
|
||||||
|
? getPermissionStatusTextColor("pending", theme)
|
||||||
|
: abortedCount > 0
|
||||||
|
? getPermissionStatusTextColor("aborted", theme)
|
||||||
|
: rejectedCount > 0
|
||||||
|
? getPermissionStatusTextColor("rejected", theme)
|
||||||
|
: getPermissionStatusTextColor("approved_always", theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.46),
|
||||||
|
boxShadow: `0 8px 24px ${alpha("#000", 0.045)}`,
|
||||||
|
backdropFilter: "blur(20px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={1}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setExpanded((value) => !value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
setExpanded((value) => !value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.15,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background-color 0.2s ease",
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.025) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: chipColor,
|
||||||
|
bgcolor: alpha(chipColor, 0.1),
|
||||||
|
border: `1px solid ${alpha(chipColor, 0.15)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VerifiedUserRounded sx={{ fontSize: 18 }} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||||
|
权限请求
|
||||||
|
</Typography>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
flexWrap="wrap"
|
||||||
|
gap={0.6}
|
||||||
|
sx={{ mt: 0.55, maxHeight: 48, overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
{summaryItems.map((item) => (
|
||||||
|
<Box
|
||||||
|
key={item.label}
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.45,
|
||||||
|
height: 22,
|
||||||
|
px: 0.8,
|
||||||
|
borderRadius: "11px",
|
||||||
|
bgcolor: alpha(item.color, 0.08),
|
||||||
|
border: `1px solid ${alpha(item.color, 0.12)}`,
|
||||||
|
color: "textColor" in item ? item.textColor : item.color,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
lineHeight: 1,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
color: "textColor" in item ? item.textColor : item.color,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Box>
|
||||||
|
<Box component="span">{item.value} 项</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
{isRunning && pendingCount > 0 ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`待确认 ${pendingCount} 项`}
|
||||||
|
sx={{
|
||||||
|
height: 24,
|
||||||
|
borderRadius: "12px",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: chipTextColor,
|
||||||
|
bgcolor: alpha(chipColor, 0.1),
|
||||||
|
"& .MuiChip-label": { px: 1 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={expanded ? "收起权限请求" : "展开权限请求"}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#000", 0.035),
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.07) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
|
||||||
|
) : (
|
||||||
|
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{!expanded && isRunning && !hasPendingPermissions && latestPermissions.length > 0 ? (
|
||||||
|
<Stack spacing={0} sx={{ px: 1.5, pb: 1.25 }}>
|
||||||
|
{latestPermissions.map((permission, index) => {
|
||||||
|
const primaryValue = getPermissionPrimaryValue(permission);
|
||||||
|
const isLast = index === latestPermissions.length - 1;
|
||||||
|
const itemColor = getPermissionStatusColor(permission.status, theme);
|
||||||
|
const itemTextColor = getPermissionStatusTextColor(permission.status, theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
key={permission.requestId}
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
py: 0.8,
|
||||||
|
borderTop: index === 0 ? `1px solid ${alpha(chipColor, 0.1)}` : "none",
|
||||||
|
borderBottom: isLast ? "none" : `1px solid ${alpha("#000", 0.045)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: itemColor,
|
||||||
|
bgcolor: alpha(itemColor, 0.08),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PermissionIcon permission={permission} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.primary" fontWeight={750} noWrap sx={{ display: "block" }}>
|
||||||
|
{getPermissionTitle(permission)}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
noWrap
|
||||||
|
title={primaryValue}
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
fontFamily: permission.permission === "bash" ? "monospace" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{primaryValue}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={getPermissionStatusLabel(permission.status)}
|
||||||
|
sx={{
|
||||||
|
height: 22,
|
||||||
|
borderRadius: "11px",
|
||||||
|
fontSize: "0.68rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: itemTextColor,
|
||||||
|
bgcolor: alpha(itemColor, 0.08),
|
||||||
|
"& .MuiChip-label": { px: 0.85 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{!expanded && isRunning && hasPendingPermissions ? (
|
||||||
|
<motion.div
|
||||||
|
key="pending-permissions"
|
||||||
|
initial={{ opacity: 0, y: -10, height: 0 }}
|
||||||
|
animate={{ opacity: 1, y: 0, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, y: -8, height: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
|
style={{ overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}>
|
||||||
|
{pendingPermissions.map((permission) => (
|
||||||
|
<PermissionRequestCard
|
||||||
|
key={permission.requestId}
|
||||||
|
permission={permission}
|
||||||
|
isRunning={isRunning}
|
||||||
|
onReply={onReply}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
<Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}>
|
||||||
|
{permissions.map((permission) => (
|
||||||
|
<PermissionRequestCard
|
||||||
|
key={permission.requestId}
|
||||||
|
permission={permission}
|
||||||
|
isRunning={isRunning}
|
||||||
|
onReply={onReply}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -30,8 +30,8 @@ describe("AgentProgressTimeline", () => {
|
|||||||
id: "tool",
|
id: "tool",
|
||||||
phase: "tool",
|
phase: "tool",
|
||||||
status: "running",
|
status: "running",
|
||||||
title: "正在调用 dynamic_http_call",
|
title: "正在调用 tjwater_cli",
|
||||||
detail: "GET /api/v1/network/bottlenecks",
|
detail: "analysis bottlenecks",
|
||||||
startedAt: now - 1200,
|
startedAt: now - 1200,
|
||||||
elapsedMs: 1200,
|
elapsedMs: 1200,
|
||||||
elapsedSnapshotAt: now,
|
elapsedSnapshotAt: now,
|
||||||
@@ -43,7 +43,7 @@ describe("AgentProgressTimeline", () => {
|
|||||||
expect(screen.getByText(/Agent 过程:/)).toBeInTheDocument();
|
expect(screen.getByText(/Agent 过程:/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/耗时 5.0s/)).toBeInTheDocument();
|
expect(screen.getByText(/耗时 5.0s/)).toBeInTheDocument();
|
||||||
expect(screen.getByText("查询后端数据")).toBeInTheDocument();
|
expect(screen.getByText("查询后端数据")).toBeInTheDocument();
|
||||||
expect(screen.getByText("GET /api/v1/network/bottlenecks")).toBeInTheDocument();
|
expect(screen.getByText("analysis bottlenecks")).toBeInTheDocument();
|
||||||
expect(screen.getByText("1.2s")).toBeInTheDocument();
|
expect(screen.getByText("1.2s")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ describe("AgentProgressTimeline", () => {
|
|||||||
id: "tool",
|
id: "tool",
|
||||||
phase: "tool",
|
phase: "tool",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
title: "正在调用 dynamic_http_call",
|
title: "正在调用 tjwater_cli",
|
||||||
startedAt: Date.now() - 4000,
|
startedAt: Date.now() - 4000,
|
||||||
endedAt: Date.now(),
|
endedAt: Date.now(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ const phaseIcon = (phase: string, status: ChatProgress["status"]) => {
|
|||||||
|
|
||||||
const formatToolTitle = (item: ChatProgress) => {
|
const formatToolTitle = (item: ChatProgress) => {
|
||||||
const text = `${item.title} ${item.detail ?? ""}`;
|
const text = `${item.title} ${item.detail ?? ""}`;
|
||||||
if (text.includes("dynamic_http_call")) return "查询后端数据";
|
if (text.includes("tjwater_cli")) return "查询后端数据";
|
||||||
if (text.includes("show_chart")) return "生成图表";
|
if (text.includes("show_chart")) return "生成图表";
|
||||||
if (text.includes("locate_features")) return "地图定位";
|
if (text.includes("locate_features")) return "地图定位";
|
||||||
if (text.includes("view_history")) return "打开历史曲线";
|
if (text.includes("view_history")) return "打开历史曲线";
|
||||||
@@ -85,7 +85,12 @@ const formatToolTitle = (item: ChatProgress) => {
|
|||||||
return item.title;
|
return item.title;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatProgress[], isAborted?: boolean }) => {
|
type AgentProgressTimelineProps = {
|
||||||
|
progress: ChatProgress[];
|
||||||
|
isAborted?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AgentProgressTimelineInner = ({ progress, isAborted }: AgentProgressTimelineProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
@@ -356,3 +361,12 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
|
|||||||
</Box>
|
</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>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
+277
-309
@@ -1,14 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -18,82 +15,196 @@ import {
|
|||||||
useTheme,
|
useTheme,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
|
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
|
||||||
import RefreshRounded from "@mui/icons-material/RefreshRounded";
|
import { TbArrowsSplit2 } from "react-icons/tb";
|
||||||
import EditRounded from "@mui/icons-material/EditRounded";
|
import type { PermissionReply } from "@/lib/chatStream";
|
||||||
import CloseRounded from "@mui/icons-material/CloseRounded";
|
|
||||||
import ChevronLeftRounded from "@mui/icons-material/ChevronLeftRounded";
|
|
||||||
import ChevronRightRounded from "@mui/icons-material/ChevronRightRounded";
|
|
||||||
import {
|
import {
|
||||||
parseAssistantMessageSections,
|
parseAssistantMessageSections,
|
||||||
parseContentWithToolCalls,
|
parseContentWithToolCalls,
|
||||||
type ContentSegment,
|
type ContentSegment,
|
||||||
} from "./chatMessageSections";
|
} from "./chatMessageSections";
|
||||||
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
import type { Message, SpeechState } from "./GlobalChatbox.types";
|
||||||
import type { BranchState, Message, SpeechState } from "./GlobalChatbox.types";
|
|
||||||
import { stripMarkdown } from "./GlobalChatbox.utils";
|
import { stripMarkdown } from "./GlobalChatbox.utils";
|
||||||
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
||||||
import { ChatInlineChart } from "./ChatInlineChart";
|
import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart";
|
||||||
import type { ChatChartSeries } from "./ChatInlineChart";
|
|
||||||
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
||||||
import { AgentArtifactPanel } from "./AgentArtifactPanel";
|
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
|
||||||
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
import { PermissionRequestGroup } from "./AgentPermissionRequests";
|
||||||
|
import { QuestionRequestGroup } from "./AgentQuestionRequests";
|
||||||
|
import { TodoPlanCard } from "./AgentTodoPlanCard";
|
||||||
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
|
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
|
||||||
import PauseRounded from "@mui/icons-material/PauseRounded";
|
import PauseRounded from "@mui/icons-material/PauseRounded";
|
||||||
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
||||||
import StopRounded from "@mui/icons-material/StopRounded";
|
import StopRounded from "@mui/icons-material/StopRounded";
|
||||||
import SendRounded from "@mui/icons-material/SendRounded";
|
|
||||||
|
|
||||||
type AgentTurnProps = {
|
type AgentTurnProps = {
|
||||||
message: Message;
|
message: Message;
|
||||||
branchState?: BranchState;
|
isStreaming: boolean;
|
||||||
messageSpeechState: SpeechState;
|
messageSpeechState: SpeechState;
|
||||||
onSpeak: (messageId: string, text: string) => void;
|
onSpeak: (messageId: string, text: string) => void;
|
||||||
onPause: () => void;
|
onPause: () => void;
|
||||||
onResume: () => void;
|
onResume: () => void;
|
||||||
onStopSpeech: () => void;
|
onStopSpeech: () => void;
|
||||||
isTtsSupported: boolean;
|
isTtsSupported: boolean;
|
||||||
onRegenerate: () => void;
|
onCreateBranch: (messageId: string) => void;
|
||||||
onEditResubmit: (messageId: string, newContent: string) => void;
|
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||||
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||||
|
onRejectQuestion: (requestId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MarkdownBlock = ({ children }: { children: string }) => (
|
const StreamingStatus = () => {
|
||||||
<div className={markdownStyles.markdown}>
|
const theme = useTheme();
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
|
|
||||||
</div>
|
return (
|
||||||
);
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={0.75}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
px: 1,
|
||||||
|
py: 0.35,
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: alpha(theme.palette.primary.main, 0.07),
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={0.35} alignItems="center">
|
||||||
|
{[0, 1, 2].map((index) => (
|
||||||
|
<motion.span
|
||||||
|
key={index}
|
||||||
|
animate={{ opacity: [0.28, 0.86, 0.28] }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.95,
|
||||||
|
repeat: Infinity,
|
||||||
|
delay: index * 0.14,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: theme.palette.primary.main,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={700}>
|
||||||
|
正在生成
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StreamingMarkdownBlock = ({
|
||||||
|
text,
|
||||||
|
isStreaming,
|
||||||
|
segmentKey,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
isStreaming: boolean;
|
||||||
|
segmentKey: string;
|
||||||
|
}) => {
|
||||||
|
const [streamTextState, setStreamTextState] = React.useState<{
|
||||||
|
displayText: string;
|
||||||
|
animatedTailLength: number;
|
||||||
|
}>({
|
||||||
|
displayText: text,
|
||||||
|
animatedTailLength: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
setStreamTextState((current) => {
|
||||||
|
if (current.displayText === text) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStreaming) {
|
||||||
|
return {
|
||||||
|
displayText: text,
|
||||||
|
animatedTailLength: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.displayText === text) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayText: text,
|
||||||
|
animatedTailLength:
|
||||||
|
text.length > current.displayText.length &&
|
||||||
|
text.startsWith(current.displayText)
|
||||||
|
? Math.min(48, text.length - current.displayText.length)
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [isStreaming, text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MarkdownBlock
|
||||||
|
streamFadeKey={`${segmentKey}-${streamTextState.displayText.length}`}
|
||||||
|
streamFadeLength={streamTextState.animatedTailLength}
|
||||||
|
>
|
||||||
|
{streamTextState.displayText}
|
||||||
|
</MarkdownBlock>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const AgentTurn = React.memo(
|
export const AgentTurn = React.memo(
|
||||||
({
|
({
|
||||||
message,
|
message,
|
||||||
branchState,
|
isStreaming,
|
||||||
messageSpeechState,
|
messageSpeechState,
|
||||||
onSpeak,
|
onSpeak,
|
||||||
onPause,
|
onPause,
|
||||||
onResume,
|
onResume,
|
||||||
onStopSpeech,
|
onStopSpeech,
|
||||||
isTtsSupported,
|
isTtsSupported,
|
||||||
onRegenerate,
|
onCreateBranch,
|
||||||
onEditResubmit,
|
onReplyPermission,
|
||||||
onCycleBranch,
|
onReplyQuestion,
|
||||||
|
onRejectQuestion,
|
||||||
}: AgentTurnProps) => {
|
}: AgentTurnProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
const isErrorMessage = Boolean(message.isError);
|
const isErrorMessage = Boolean(message.isError);
|
||||||
|
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
|
||||||
const [isHovered, setIsHovered] = React.useState(false);
|
const [isHovered, setIsHovered] = React.useState(false);
|
||||||
const [isEditing, setIsEditing] = React.useState(false);
|
const isProgressComplete = message.progress?.some(
|
||||||
const [editDraft, setEditDraft] = React.useState(message.content);
|
(item) => item.phase === "complete" && item.status === "completed",
|
||||||
const rootMessageId = message.branchRootId ?? message.id;
|
) ?? false;
|
||||||
|
const isProgressRunning = !isErrorMessage && !isProgressComplete && (
|
||||||
|
message.progress?.some((item) => item.status === "running") ?? false
|
||||||
|
);
|
||||||
|
|
||||||
const parsedAssistantSections =
|
const parsedAssistantSections = useMemo(
|
||||||
!isUser && !isErrorMessage
|
() =>
|
||||||
? parseAssistantMessageSections(message.content)
|
!isUser && !isErrorMessage
|
||||||
: null;
|
? parseAssistantMessageSections(message.content)
|
||||||
|
: null,
|
||||||
|
[isErrorMessage, isUser, message.content],
|
||||||
|
);
|
||||||
const answerContent = parsedAssistantSections?.answer ?? message.content;
|
const answerContent = parsedAssistantSections?.answer ?? message.content;
|
||||||
const contentSegments: ContentSegment[] =
|
const contentSegments: ContentSegment[] = useMemo(
|
||||||
!isUser && !isErrorMessage
|
() =>
|
||||||
? parseContentWithToolCalls(answerContent).segments
|
!isUser && !isErrorMessage
|
||||||
: [{ type: "text", content: answerContent }];
|
? parseContentWithToolCalls(answerContent).segments
|
||||||
|
: [{ type: "text", content: answerContent }],
|
||||||
|
[answerContent, isErrorMessage, isUser],
|
||||||
|
);
|
||||||
|
const hasInlineChart = contentSegments.some(
|
||||||
|
(segment) =>
|
||||||
|
segment.type === "tool_call" &&
|
||||||
|
(segment.toolCall.tool === "chart" ||
|
||||||
|
segment.toolCall.tool === "show_chart"),
|
||||||
|
);
|
||||||
|
const visibleChartArtifacts = useMemo(
|
||||||
|
() =>
|
||||||
|
hasInlineChart
|
||||||
|
? []
|
||||||
|
: (message.artifacts?.filter((artifact) => artifact.kind === "chart") ?? []),
|
||||||
|
[hasInlineChart, message.artifacts],
|
||||||
|
);
|
||||||
|
|
||||||
if (isUser) {
|
if (isUser) {
|
||||||
return (
|
return (
|
||||||
@@ -106,185 +217,33 @@ export const AgentTurn = React.memo(
|
|||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
{isEditing ? (
|
<Paper
|
||||||
<Paper
|
elevation={4}
|
||||||
elevation={12}
|
sx={{
|
||||||
sx={{
|
p: 2,
|
||||||
p: 1.5,
|
borderRadius: 5,
|
||||||
borderRadius: 5,
|
borderBottomRightRadius: 2,
|
||||||
bgcolor: alpha("#ffffff", 0.75),
|
color: "#fff",
|
||||||
backdropFilter: "blur(40px)",
|
background: `linear-gradient(135deg, #0288d1, #00acc1)`,
|
||||||
border: `1px solid ${alpha("#ffffff", 0.9)}`,
|
boxShadow: `0 8px 24px -8px ${alpha("#00acc1", 0.5)}, inset 0 2px 4px ${alpha("#fff", 0.2)}`,
|
||||||
boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
|
backdropFilter: "blur(10px)",
|
||||||
minWidth: { xs: 260, sm: 320, md: 400 },
|
"--chat-md-text": alpha("#fff", 0.96),
|
||||||
maxWidth: "100%",
|
"--chat-md-heading": "#fff",
|
||||||
}}
|
"--chat-md-link": "#e0f7fa",
|
||||||
>
|
"--chat-md-link-hover": "#fff",
|
||||||
<Box component="textarea"
|
"--chat-md-inline-code-bg": "rgba(255,255,255,0.15)",
|
||||||
autoFocus
|
"--chat-md-inline-code-border": alpha("#fff", 0.1),
|
||||||
value={editDraft}
|
"--chat-md-inline-code-text": "#fff",
|
||||||
onChange={(e) => setEditDraft(e.target.value)}
|
"--chat-md-pre-bg": "rgba(0, 0, 0, 0.25)",
|
||||||
onKeyDown={(e) => {
|
"--chat-md-pre-border": alpha("#fff", 0.1),
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
"--chat-md-pre-text": "#F8FAFC",
|
||||||
e.preventDefault();
|
"--chat-md-quote-border": alpha("#fff", 0.4),
|
||||||
if (editDraft.trim() !== message.content) {
|
"--chat-md-quote-bg": alpha("#fff", 0.05),
|
||||||
onEditResubmit(message.id, editDraft);
|
"--chat-md-quote-text": alpha("#fff", 0.8),
|
||||||
}
|
}}
|
||||||
setIsEditing(false);
|
>
|
||||||
} else if (e.key === "Escape") {
|
<MarkdownBlock>{message.content}</MarkdownBlock>
|
||||||
setEditDraft(message.content);
|
</Paper>
|
||||||
setIsEditing(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
width: "100%",
|
|
||||||
minHeight: 60,
|
|
||||||
bgcolor: "transparent",
|
|
||||||
border: "none",
|
|
||||||
outline: "none",
|
|
||||||
resize: "none",
|
|
||||||
fontFamily: "inherit",
|
|
||||||
fontSize: "1rem",
|
|
||||||
color: "text.primary",
|
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 1 }}>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label="取消"
|
|
||||||
onClick={() => { setEditDraft(message.content); setIsEditing(false); }}
|
|
||||||
sx={{
|
|
||||||
bgcolor: alpha("#000", 0.05),
|
|
||||||
color: "text.secondary",
|
|
||||||
width: 34, height: 34,
|
|
||||||
"&:hover": { bgcolor: alpha("#000", 0.1) }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CloseRounded fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label="发送修改"
|
|
||||||
disabled={editDraft.trim() === "" || editDraft.trim() === message.content}
|
|
||||||
onClick={() => {
|
|
||||||
onEditResubmit(message.id, editDraft);
|
|
||||||
setIsEditing(false);
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00acc1" : alpha("#000", 0.1),
|
|
||||||
color: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#fff" : "action.disabled",
|
|
||||||
width: 34, height: 34,
|
|
||||||
boxShadow: editDraft.trim() !== "" && editDraft.trim() !== message.content ? `0 4px 12px ${alpha("#00acc1", 0.4)}` : "none",
|
|
||||||
"&:hover": { bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00838f" : alpha("#000", 0.1) }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SendRounded fontSize="small" sx={{ ml: 0.2 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{isHovered && !isEditing && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.9 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
style={{ position: "absolute", top: -12, right: -8, zIndex: 10 }}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => { setIsEditing(true); setEditDraft(message.content); }}
|
|
||||||
aria-label="编辑提问"
|
|
||||||
sx={{
|
|
||||||
width: 26,
|
|
||||||
height: 26,
|
|
||||||
bgcolor: alpha("#fff", 0.9),
|
|
||||||
color: "#00acc1",
|
|
||||||
boxShadow: `0 2px 8px ${alpha("#000", 0.15)}`,
|
|
||||||
"&:hover": { bgcolor: "#fff", color: "#00838f" }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditRounded sx={{ fontSize: 14 }} />
|
|
||||||
</IconButton>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{branchState && branchState.total > 1 ? (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
justifyContent="flex-end"
|
|
||||||
sx={{ mt: 0.5, mr: 0.5 }}
|
|
||||||
>
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 0.5,
|
|
||||||
px: 0.5,
|
|
||||||
py: 0.25,
|
|
||||||
borderRadius: 4,
|
|
||||||
bgcolor: alpha("#000", 0.04),
|
|
||||||
backdropFilter: "blur(4px)",
|
|
||||||
border: `1px solid ${alpha("#000", 0.08)}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label="上一分支"
|
|
||||||
onClick={() => onCycleBranch(rootMessageId, -1)}
|
|
||||||
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
|
|
||||||
>
|
|
||||||
<ChevronLeftRounded sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}>
|
|
||||||
{branchState.activeIndex + 1} / {branchState.total}
|
|
||||||
</Typography>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label="下一分支"
|
|
||||||
onClick={() => onCycleBranch(rootMessageId, 1)}
|
|
||||||
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
|
|
||||||
>
|
|
||||||
<ChevronRightRounded sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Paper>
|
|
||||||
</Stack>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -353,23 +312,54 @@ export const AgentTurn = React.memo(
|
|||||||
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
|
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
|
||||||
) : null}
|
) : 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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
p: 1.5,
|
p: 1.5,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
bgcolor: alpha("#fff", 0.4),
|
bgcolor: alpha("#fff", 0.4),
|
||||||
border: `1px solid ${alpha("#fff", 0.6)}`,
|
border: `1px solid ${alpha("#fff", 0.6)}`,
|
||||||
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack spacing={1.2}>
|
<Stack spacing={1.2}>
|
||||||
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
||||||
分析结果
|
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
||||||
</Typography>
|
分析结果
|
||||||
|
</Typography>
|
||||||
|
{isStreamingAssistant ? <StreamingStatus /> : null}
|
||||||
|
</Stack>
|
||||||
{contentSegments.map((segment, segIdx) => {
|
{contentSegments.map((segment, segIdx) => {
|
||||||
if (segment.type === "text") {
|
if (segment.type === "text") {
|
||||||
const text = segment.content.trim();
|
const text = segment.content.trim();
|
||||||
if (!text && contentSegments.length > 1) return null;
|
if (!text && contentSegments.length > 1) return null;
|
||||||
return <MarkdownBlock key={segIdx}>{text || "..."}</MarkdownBlock>;
|
return (
|
||||||
|
<StreamingMarkdownBlock
|
||||||
|
key={segIdx}
|
||||||
|
text={text || "..."}
|
||||||
|
isStreaming={isStreamingAssistant}
|
||||||
|
segmentKey={`${message.id}-${segIdx}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (segment.type === "tool_call") {
|
if (segment.type === "tool_call") {
|
||||||
if (
|
if (
|
||||||
@@ -384,10 +374,11 @@ export const AgentTurn = React.memo(
|
|||||||
chart_type={
|
chart_type={
|
||||||
(p.chart_type as "line" | "bar" | "pie") ?? "line"
|
(p.chart_type as "line" | "bar" | "pie") ?? "line"
|
||||||
}
|
}
|
||||||
x_data={(p.x_data as string[]) ?? []}
|
x_data={p.x_data ?? p.xData ?? p.labels ?? p.categories}
|
||||||
series={(p.series as ChatChartSeries[]) ?? []}
|
series={p.series}
|
||||||
x_axis_name={(p.x_axis_name as string) ?? undefined}
|
x_axis_name={(p.x_axis_name as string) ?? undefined}
|
||||||
y_axis_name={(p.y_axis_name as string) ?? undefined}
|
y_axis_name={(p.y_axis_name as string) ?? undefined}
|
||||||
|
isStreaming={isStreamingAssistant}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -400,19 +391,41 @@ export const AgentTurn = React.memo(
|
|||||||
}
|
}
|
||||||
if (segment.type === "tool_call_pending") {
|
if (segment.type === "tool_call_pending") {
|
||||||
return (
|
return (
|
||||||
<Typography key="tool-pending" variant="caption" color="text.secondary">
|
<ChartGenerationSkeleton
|
||||||
正在准备工具调用...
|
key="tool-pending"
|
||||||
</Typography>
|
status={<StreamingStatus />}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{visibleChartArtifacts.map((artifact) => (
|
||||||
|
<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 ??
|
||||||
|
artifact.params.xData ??
|
||||||
|
artifact.params.labels ??
|
||||||
|
artifact.params.categories
|
||||||
|
}
|
||||||
|
series={artifact.params.series}
|
||||||
|
x_axis_name={(artifact.params.x_axis_name as string) ?? undefined}
|
||||||
|
y_axis_name={(artifact.params.y_axis_name as string) ?? undefined}
|
||||||
|
isStreaming={isStreamingAssistant}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isHovered && (
|
{isHovered && !isStreaming && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.9, y: 5 }}
|
initial={{ opacity: 0, scale: 0.9, y: 5 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
@@ -438,7 +451,9 @@ export const AgentTurn = React.memo(
|
|||||||
size="small"
|
size="small"
|
||||||
aria-label="复制"
|
aria-label="复制"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(message.content);
|
navigator.clipboard.writeText(
|
||||||
|
normalizeClipboardText(message.content),
|
||||||
|
);
|
||||||
// Could add a toast here
|
// Could add a toast here
|
||||||
}}
|
}}
|
||||||
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
||||||
@@ -446,16 +461,16 @@ export const AgentTurn = React.memo(
|
|||||||
<ContentCopyRounded sx={{ fontSize: 16 }} />
|
<ContentCopyRounded sx={{ fontSize: 16 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="重新生成">
|
<Tooltip title="拆分为新会话">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
aria-label="重新生成"
|
aria-label="拆分为新会话"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onRegenerate();
|
onCreateBranch(message.id);
|
||||||
}}
|
}}
|
||||||
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
||||||
>
|
>
|
||||||
<RefreshRounded sx={{ fontSize: 16 }} />
|
<TbArrowsSplit2 size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -466,87 +481,40 @@ export const AgentTurn = React.memo(
|
|||||||
</Paper>
|
</Paper>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{(!isErrorMessage && isTtsSupported) || (branchState && branchState.total > 1) ? (
|
{!isErrorMessage && isTtsSupported ? (
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 0.5, ml: 6, mb: 1 }}>
|
<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" }}>
|
<Stack direction="row" spacing={0.5} sx={{ opacity: isHovered ? 1 : 0.4, transition: "opacity 0.2s" }}>
|
||||||
{!isErrorMessage && isTtsSupported ? (
|
{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" ? (
|
||||||
<>
|
<>
|
||||||
{messageSpeechState === "idle" ? (
|
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||||
<IconButton
|
<PauseRounded sx={{ fontSize: 16 }} />
|
||||||
size="small"
|
</IconButton>
|
||||||
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
|
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||||
aria-label="朗读消息"
|
<StopRounded sx={{ fontSize: 16 }} />
|
||||||
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
|
</IconButton>
|
||||||
>
|
</>
|
||||||
<VolumeUpRounded sx={{ fontSize: 16 }} />
|
) : null}
|
||||||
</IconButton>
|
{messageSpeechState === "paused" ? (
|
||||||
) : null}
|
<>
|
||||||
{messageSpeechState === "playing" ? (
|
<IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||||
<>
|
<PlayArrowRounded sx={{ fontSize: 16 }} />
|
||||||
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
</IconButton>
|
||||||
<PauseRounded sx={{ fontSize: 16 }} />
|
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||||
</IconButton>
|
<StopRounded sx={{ fontSize: 16 }} />
|
||||||
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
</IconButton>
|
||||||
<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}
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{branchState && branchState.total > 1 ? (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
justifyContent="flex-start"
|
|
||||||
sx={{ mr: 0.5 }}
|
|
||||||
>
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 0.5,
|
|
||||||
px: 0.5,
|
|
||||||
py: 0.25,
|
|
||||||
borderRadius: 4,
|
|
||||||
bgcolor: alpha("#000", 0.04),
|
|
||||||
backdropFilter: "blur(4px)",
|
|
||||||
border: `1px solid ${alpha("#000", 0.08)}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label="上一分支"
|
|
||||||
onClick={() => onCycleBranch(rootMessageId, -1)}
|
|
||||||
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
|
|
||||||
>
|
|
||||||
<ChevronLeftRounded sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}>
|
|
||||||
{branchState.activeIndex + 1} / {branchState.total}
|
|
||||||
</Typography>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label="下一分支"
|
|
||||||
onClick={() => onCycleBranch(rootMessageId, 1)}
|
|
||||||
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
|
|
||||||
>
|
|
||||||
<ChevronRightRounded sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Paper>
|
|
||||||
</Stack>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
) : null}
|
) : null}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import React from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { AgentWorkspace } from "./AgentWorkspace";
|
||||||
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
const renderCounts = new Map<string, number>();
|
||||||
|
const mountCounts = new Map<string, number>();
|
||||||
|
const unmountCounts = new Map<string, number>();
|
||||||
|
const streamingFlags = new Map<string, boolean>();
|
||||||
|
|
||||||
|
jest.mock("next/image", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt ?? ""} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("framer-motion", () => ({
|
||||||
|
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
motion: {
|
||||||
|
div: ({
|
||||||
|
children,
|
||||||
|
animate: _animate,
|
||||||
|
exit: _exit,
|
||||||
|
initial: _initial,
|
||||||
|
layout: _layout,
|
||||||
|
transition: _transition,
|
||||||
|
whileHover: _whileHover,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => (
|
||||||
|
<div {...props}>{children}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./GlobalChatbox.parts", () => ({
|
||||||
|
TypingIndicator: () => <div>typing</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentTurn", () => ({
|
||||||
|
AgentTurn: ({ message, isStreaming }: { message: Message; isStreaming: boolean }) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
mountCounts.set(message.id, (mountCounts.get(message.id) ?? 0) + 1);
|
||||||
|
return () => {
|
||||||
|
unmountCounts.set(message.id, (unmountCounts.get(message.id) ?? 0) + 1);
|
||||||
|
};
|
||||||
|
}, [message.id]);
|
||||||
|
renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1);
|
||||||
|
streamingFlags.set(message.id, isStreaming);
|
||||||
|
return <div data-testid={`turn-${message.id}`}>{message.content}</div>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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();
|
||||||
|
mountCounts.clear();
|
||||||
|
unmountCounts.clear();
|
||||||
|
streamingFlags.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a loading skeleton instead of the empty state while switching history sessions", () => {
|
||||||
|
render(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming={false}
|
||||||
|
isLoadingSession
|
||||||
|
messages={[]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("正在加载历史记录")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("我已就绪,请描述任务")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps stable history turns from re-rendering while the last assistant message streams", () => {
|
||||||
|
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);
|
||||||
|
expect(streamingFlags.get("assistant-1")).toBe(false);
|
||||||
|
expect(streamingFlags.get("assistant-2")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not remount the streaming assistant turn when streaming finishes", () => {
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: "user-1",
|
||||||
|
role: "user",
|
||||||
|
content: "question",
|
||||||
|
};
|
||||||
|
const assistantMessage: Message = {
|
||||||
|
id: "assistant-1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "final answer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming
|
||||||
|
messages={[userMessage, assistantMessage]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(streamingFlags.get("assistant-1")).toBe(true);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming={false}
|
||||||
|
messages={[userMessage, assistantMessage]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mountCounts.get("assistant-1")).toBe(1);
|
||||||
|
expect(unmountCounts.get("assistant-1") ?? 0).toBe(0);
|
||||||
|
expect(streamingFlags.get("assistant-1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { Box, Paper, Stack, Typography, alpha, useTheme, Grid } from "@mui/material";
|
import { Box, Paper, Skeleton, Stack, Typography, alpha, useTheme, Grid } from "@mui/material";
|
||||||
import WaterDropRounded from "@mui/icons-material/WaterDropRounded";
|
import WaterDropRounded from "@mui/icons-material/WaterDropRounded";
|
||||||
import SensorsRounded from "@mui/icons-material/SensorsRounded";
|
import SensorsRounded from "@mui/icons-material/SensorsRounded";
|
||||||
import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
|
import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
|
||||||
@@ -11,19 +11,19 @@ import MapRounded from "@mui/icons-material/MapRounded";
|
|||||||
|
|
||||||
import { AgentTurn } from "./AgentTurn";
|
import { AgentTurn } from "./AgentTurn";
|
||||||
import { TypingIndicator } from "./GlobalChatbox.parts";
|
import { TypingIndicator } from "./GlobalChatbox.parts";
|
||||||
|
import type { PermissionReply } from "@/lib/chatStream";
|
||||||
import type {
|
import type {
|
||||||
BranchGroup,
|
|
||||||
BranchTransition,
|
|
||||||
Message,
|
Message,
|
||||||
SpeechState,
|
SpeechState,
|
||||||
} from "./GlobalChatbox.types";
|
} from "./GlobalChatbox.types";
|
||||||
|
|
||||||
type AgentWorkspaceProps = {
|
type AgentWorkspaceProps = {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
branchGroups: BranchGroup[];
|
|
||||||
branchTransition: BranchTransition | null;
|
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
|
isLoadingSession?: boolean;
|
||||||
|
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
bottomRef: React.RefObject<HTMLDivElement | null>;
|
bottomRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
onScrollStateChange?: (isNearBottom: boolean) => void;
|
||||||
speakingMessageId: string | null;
|
speakingMessageId: string | null;
|
||||||
speechState: SpeechState;
|
speechState: SpeechState;
|
||||||
onSpeak: (messageId: string, text: string) => void;
|
onSpeak: (messageId: string, text: string) => void;
|
||||||
@@ -31,11 +31,98 @@ type AgentWorkspaceProps = {
|
|||||||
onResumeSpeech: () => void;
|
onResumeSpeech: () => void;
|
||||||
onStopSpeech: () => void;
|
onStopSpeech: () => void;
|
||||||
isTtsSupported: boolean;
|
isTtsSupported: boolean;
|
||||||
onRegenerate: () => void;
|
onCreateBranch: (messageId: string) => void;
|
||||||
onEditResubmit: (messageId: string, newContent: string) => void;
|
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||||
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||||
|
onRejectQuestion: (requestId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TurnListProps = {
|
||||||
|
messages: Message[];
|
||||||
|
isStreaming: boolean;
|
||||||
|
streamingMessageId: string | 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STREAMING_BOTTOM_RESERVE_PX = 180;
|
||||||
|
const STREAMING_NEAR_BOTTOM_THRESHOLD_PX = STREAMING_BOTTOM_RESERVE_PX + 120;
|
||||||
|
|
||||||
|
const sameMessages = (left: Message[], right: Message[]) =>
|
||||||
|
left.length === right.length &&
|
||||||
|
left.every((message, index) => message === right[index]);
|
||||||
|
|
||||||
|
const TurnItem = React.memo(AgentTurn);
|
||||||
|
|
||||||
|
const TurnListInner = ({
|
||||||
|
messages,
|
||||||
|
isStreaming,
|
||||||
|
streamingMessageId,
|
||||||
|
speakingMessageId,
|
||||||
|
speechState,
|
||||||
|
onSpeak,
|
||||||
|
onPauseSpeech,
|
||||||
|
onResumeSpeech,
|
||||||
|
onStopSpeech,
|
||||||
|
isTtsSupported,
|
||||||
|
onCreateBranch,
|
||||||
|
onReplyPermission,
|
||||||
|
onReplyQuestion,
|
||||||
|
onRejectQuestion,
|
||||||
|
}: TurnListProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<TurnItem
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
isStreaming={isStreaming && message.id === streamingMessageId}
|
||||||
|
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.streamingMessageId === nextProps.streamingMessageId &&
|
||||||
|
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 EmptyState = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const capabilities = [
|
const capabilities = [
|
||||||
@@ -150,12 +237,75 @@ const EmptyState = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SessionLoadingSkeleton = () => (
|
||||||
|
<Stack
|
||||||
|
spacing={2.25}
|
||||||
|
aria-label="正在加载历史记录"
|
||||||
|
sx={{ width: "100%", maxWidth: 760, alignSelf: "stretch" }}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 2 }, (_, turnIndex) => (
|
||||||
|
<Stack key={turnIndex} spacing={1.25}>
|
||||||
|
<Stack direction="row" justifyContent="flex-end">
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
width: turnIndex === 0 ? "72%" : "64%",
|
||||||
|
maxWidth: "86%",
|
||||||
|
p: 1.75,
|
||||||
|
borderRadius: 5,
|
||||||
|
borderBottomRightRadius: 2,
|
||||||
|
bgcolor: alpha("#00acc1", 0.16),
|
||||||
|
border: `1px solid ${alpha("#00acc1", 0.12)}`,
|
||||||
|
boxShadow: `0 8px 24px -12px ${alpha("#00acc1", 0.35)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={0.85}>
|
||||||
|
<Skeleton variant="text" width="76%" height={18} />
|
||||||
|
<Skeleton variant="text" width="48%" height={15} />
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
||||||
|
<Skeleton
|
||||||
|
variant="circular"
|
||||||
|
width={34}
|
||||||
|
height={34}
|
||||||
|
sx={{ bgcolor: alpha("#00acc1", 0.12), flexShrink: 0, mt: 0.25 }}
|
||||||
|
/>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 5,
|
||||||
|
bgcolor: alpha("#ffffff", 0.52),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||||
|
boxShadow: `0 10px 30px -10px ${alpha("#000", 0.06)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Skeleton variant="text" width="38%" height={16} />
|
||||||
|
<Skeleton variant="text" width="94%" height={16} />
|
||||||
|
<Skeleton variant="text" width={turnIndex === 0 ? "88%" : "82%"} height={16} />
|
||||||
|
<Skeleton variant="text" width={turnIndex === 0 ? "78%" : "70%"} height={16} />
|
||||||
|
<Skeleton variant="rounded" width="100%" height={turnIndex === 0 ? 104 : 76} sx={{ borderRadius: 2 }} />
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
export const AgentWorkspace = ({
|
export const AgentWorkspace = ({
|
||||||
messages,
|
messages,
|
||||||
branchGroups,
|
|
||||||
branchTransition,
|
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
isLoadingSession = false,
|
||||||
|
scrollContainerRef,
|
||||||
bottomRef,
|
bottomRef,
|
||||||
|
onScrollStateChange,
|
||||||
speakingMessageId,
|
speakingMessageId,
|
||||||
speechState,
|
speechState,
|
||||||
onSpeak,
|
onSpeak,
|
||||||
@@ -163,9 +313,10 @@ export const AgentWorkspace = ({
|
|||||||
onResumeSpeech,
|
onResumeSpeech,
|
||||||
onStopSpeech,
|
onStopSpeech,
|
||||||
isTtsSupported,
|
isTtsSupported,
|
||||||
onRegenerate,
|
onCreateBranch,
|
||||||
onEditResubmit,
|
onReplyPermission,
|
||||||
onCycleBranch,
|
onReplyQuestion,
|
||||||
|
onRejectQuestion,
|
||||||
}: AgentWorkspaceProps) => {
|
}: AgentWorkspaceProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const latestAssistant = [...messages]
|
const latestAssistant = [...messages]
|
||||||
@@ -176,46 +327,28 @@ export const AgentWorkspace = ({
|
|||||||
(!latestAssistant ||
|
(!latestAssistant ||
|
||||||
(latestAssistant.content.trim().length === 0 &&
|
(latestAssistant.content.trim().length === 0 &&
|
||||||
!(latestAssistant.artifacts?.length)));
|
!(latestAssistant.artifacts?.length)));
|
||||||
const stableMessages = branchTransition
|
const streamingMessage =
|
||||||
? messages.slice(0, branchTransition.parentCount)
|
isStreaming && messages.at(-1)?.role === "assistant"
|
||||||
: messages;
|
? messages.at(-1)
|
||||||
const transitionMessages = branchTransition
|
: undefined;
|
||||||
? messages.slice(branchTransition.parentCount)
|
const handleScroll = React.useCallback(
|
||||||
: [];
|
(event: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
if (!onScrollStateChange) return;
|
||||||
const renderTurn = (message: Message) => {
|
const target = event.currentTarget;
|
||||||
const rootMessageId = message.branchRootId ?? message.id;
|
const distanceToBottom =
|
||||||
const branchGroup = branchGroups.find(
|
target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||||
(group) => group.rootMessageId === rootMessageId,
|
onScrollStateChange(
|
||||||
);
|
distanceToBottom <
|
||||||
|
(isStreaming ? STREAMING_NEAR_BOTTOM_THRESHOLD_PX : 96),
|
||||||
return (
|
);
|
||||||
<AgentTurn
|
},
|
||||||
key={rootMessageId}
|
[isStreaming, onScrollStateChange],
|
||||||
message={message}
|
);
|
||||||
branchState={
|
|
||||||
branchGroup && branchGroup.branches.length > 1
|
|
||||||
? {
|
|
||||||
activeIndex: branchGroup.activeIndex,
|
|
||||||
total: branchGroup.branches.length,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
|
||||||
onSpeak={onSpeak}
|
|
||||||
onPause={onPauseSpeech}
|
|
||||||
onResume={onResumeSpeech}
|
|
||||||
onStopSpeech={onStopSpeech}
|
|
||||||
isTtsSupported={isTtsSupported}
|
|
||||||
onRegenerate={onRegenerate}
|
|
||||||
onEditResubmit={onEditResubmit}
|
|
||||||
onCycleBranch={onCycleBranch}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
@@ -223,35 +356,42 @@ export const AgentWorkspace = ({
|
|||||||
py: 2,
|
py: 2,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
scrollbarGutter: "stable",
|
||||||
zIndex: 5,
|
zIndex: 5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AnimatePresence initial={false}>
|
{isLoadingSession ? (
|
||||||
{messages.length === 0 ? <EmptyState /> : null}
|
<SessionLoadingSkeleton />
|
||||||
</AnimatePresence>
|
) : (
|
||||||
|
<>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{messages.length === 0 ? <EmptyState /> : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{messages.length > 0 ? (
|
{messages.length > 0 ? (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
{stableMessages.map(renderTurn)}
|
<TurnList
|
||||||
|
messages={messages}
|
||||||
{branchTransition ? (
|
isStreaming={isStreaming}
|
||||||
<AnimatePresence initial={false} mode="wait">
|
streamingMessageId={streamingMessage?.id ?? null}
|
||||||
<motion.div
|
speakingMessageId={speakingMessageId}
|
||||||
key={`${branchTransition.rootMessageId}:${branchTransition.activeBranchId}:${branchTransition.nonce}`}
|
speechState={speechState}
|
||||||
initial={{ opacity: 0, y: 8 }}
|
onSpeak={onSpeak}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
onPauseSpeech={onPauseSpeech}
|
||||||
exit={{ opacity: 0, y: -8 }}
|
onResumeSpeech={onResumeSpeech}
|
||||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
onStopSpeech={onStopSpeech}
|
||||||
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
isTtsSupported={isTtsSupported}
|
||||||
>
|
onCreateBranch={onCreateBranch}
|
||||||
{transitionMessages.map(renderTurn)}
|
onReplyPermission={onReplyPermission}
|
||||||
</motion.div>
|
onReplyQuestion={onReplyQuestion}
|
||||||
</AnimatePresence>
|
onRejectQuestion={onRejectQuestion}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{showTypingIndicator ? (
|
{!isLoadingSession && showTypingIndicator ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10, scale: 0.94 }}
|
initial={{ opacity: 0, y: 10, scale: 0.94 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
@@ -272,7 +412,13 @@ export const AgentWorkspace = ({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div ref={bottomRef} style={{ height: 1 }} />
|
<div
|
||||||
|
ref={bottomRef}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
height: isStreaming ? STREAMING_BOTTOM_RESERVE_PX : 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { normalizeChartData } from "./ChatInlineChart";
|
||||||
|
|
||||||
|
describe("normalizeChartData", () => {
|
||||||
|
it("keeps standard bar chart series data", () => {
|
||||||
|
const result = normalizeChartData(["A", "B"], [
|
||||||
|
{ name: "数量", data: [3, 5], type: "bar" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
xData: ["A", "B"],
|
||||||
|
series: [{ name: "数量", data: [3, 5], type: "bar" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes line chart point arrays into x labels and y values", () => {
|
||||||
|
const result = normalizeChartData(undefined, [
|
||||||
|
{ name: "压力", data: [["10:00", 12.5], ["11:00", 13.1]] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
xData: ["10:00", "11:00"],
|
||||||
|
series: [{ name: "压力", data: [12.5, 13.1], type: undefined }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes pie chart point objects into a single series", () => {
|
||||||
|
const result = normalizeChartData(undefined, [
|
||||||
|
{ name: "低风险", value: 8 },
|
||||||
|
{ name: "高风险", value: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
xData: ["低风险", "高风险"],
|
||||||
|
series: [{ name: "数据", data: [8, 2], type: undefined }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a single series object", () => {
|
||||||
|
const result = normalizeChartData(["A", "B"], {
|
||||||
|
name: "流量",
|
||||||
|
values: ["1.2", "2.4"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
xData: ["A", "B"],
|
||||||
|
series: [{ name: "流量", data: [1.2, 2.4], type: undefined }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import ReactECharts from "echarts-for-react";
|
import ReactECharts from "echarts-for-react";
|
||||||
import * as echarts from "echarts";
|
import * as echarts from "echarts";
|
||||||
import { Box, Paper, Typography, alpha, useTheme } from "@mui/material";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { Box, Paper, Skeleton, Stack, Typography, alpha, useTheme } from "@mui/material";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Inline chart rendered inside a chat message bubble. */
|
/* Inline chart rendered inside a chat message bubble. */
|
||||||
@@ -16,15 +17,43 @@ export interface ChatChartSeries {
|
|||||||
type?: "line" | "bar";
|
type?: "line" | "bar";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RawChartPoint =
|
||||||
|
| number
|
||||||
|
| string
|
||||||
|
| [unknown, unknown]
|
||||||
|
| RawChartPointObject;
|
||||||
|
|
||||||
|
type RawChartPointObject = {
|
||||||
|
x?: unknown;
|
||||||
|
y?: unknown;
|
||||||
|
time?: unknown;
|
||||||
|
timestamp?: unknown;
|
||||||
|
label?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
value?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawChartSeries = {
|
||||||
|
name?: unknown;
|
||||||
|
data?: unknown;
|
||||||
|
points?: unknown;
|
||||||
|
values?: unknown;
|
||||||
|
type?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ChatInlineChartProps {
|
export interface ChatInlineChartProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
chart_type?: "line" | "bar" | "pie";
|
chart_type?: "line" | "bar" | "pie";
|
||||||
x_data?: string[];
|
x_data?: unknown;
|
||||||
series?: ChatChartSeries[];
|
series?: unknown;
|
||||||
y_axis_name?: string;
|
y_axis_name?: string;
|
||||||
x_axis_name?: string;
|
x_axis_name?: string;
|
||||||
|
isStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CHART_HEIGHT = 240;
|
||||||
|
export const CHART_MIN_HEIGHT = 286;
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
"#5470c6",
|
"#5470c6",
|
||||||
"#91cc75",
|
"#91cc75",
|
||||||
@@ -37,28 +66,211 @@ const COLORS = [
|
|||||||
"#ea7ccc",
|
"#ea7ccc",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ChartSkeletonContent = ({ status }: { status?: React.ReactNode }) => (
|
||||||
|
<Stack spacing={1.25} sx={{ p: 1.5 }}>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||||
|
<Skeleton variant="text" width="34%" height={20} />
|
||||||
|
{status}
|
||||||
|
</Stack>
|
||||||
|
<Skeleton variant="rounded" height={208} sx={{ borderRadius: 2 }} />
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Skeleton variant="text" width="24%" height={16} />
|
||||||
|
<Skeleton variant="text" width="18%" height={16} />
|
||||||
|
<Skeleton variant="text" width="20%" height={16} />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChartGenerationSkeleton = ({ status }: { status?: React.ReactNode }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
mb: 1,
|
||||||
|
minHeight: CHART_MIN_HEIGHT,
|
||||||
|
borderRadius: 3,
|
||||||
|
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.78),
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChartSkeletonContent status={status} />
|
||||||
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toFiniteNumber = (value: unknown): number | null => {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointToLabelValue = (
|
||||||
|
point: RawChartPoint,
|
||||||
|
fallbackLabel: string,
|
||||||
|
): { label: string; value: number } | null => {
|
||||||
|
const directValue = toFiniteNumber(point);
|
||||||
|
if (directValue !== null) {
|
||||||
|
return { label: fallbackLabel, value: directValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(point)) {
|
||||||
|
const value = toFiniteNumber(point[1]);
|
||||||
|
if (value === null) return null;
|
||||||
|
return { label: String(point[0] ?? fallbackLabel), value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (point && typeof point === "object") {
|
||||||
|
const value = toFiniteNumber(point.value ?? point.y);
|
||||||
|
if (value === null) return null;
|
||||||
|
const label =
|
||||||
|
point.x ?? point.time ?? point.timestamp ?? point.label ?? point.name ?? fallbackLabel;
|
||||||
|
return { label: String(label), value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeXData = (rawXData: unknown): string[] =>
|
||||||
|
Array.isArray(rawXData)
|
||||||
|
? rawXData.map((item) => String(item ?? "")).filter((item) => item.length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const normalizeSeriesType = (type: unknown): "line" | "bar" | undefined =>
|
||||||
|
type === "line" || type === "bar" ? type : undefined;
|
||||||
|
|
||||||
|
const isRawChartPoint = (item: unknown): boolean => {
|
||||||
|
if (toFiniteNumber(item) !== null) return true;
|
||||||
|
if (Array.isArray(item)) return item.length >= 2 && toFiniteNumber(item[1]) !== null;
|
||||||
|
if (item && typeof item === "object") {
|
||||||
|
const rawItem = item as RawChartSeries & RawChartPointObject;
|
||||||
|
return (
|
||||||
|
rawItem.data === undefined &&
|
||||||
|
rawItem.points === undefined &&
|
||||||
|
rawItem.values === undefined &&
|
||||||
|
toFiniteNumber(rawItem.value ?? rawItem.y) !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeRawSeriesItems = (rawSeries: unknown): unknown[] => {
|
||||||
|
if (!Array.isArray(rawSeries)) {
|
||||||
|
return rawSeries && typeof rawSeries === "object" ? [rawSeries] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawSeries.length > 0 && rawSeries.every(isRawChartPoint)
|
||||||
|
? [{ name: "数据", data: rawSeries }]
|
||||||
|
: rawSeries;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeChartData = (
|
||||||
|
rawXData: unknown,
|
||||||
|
rawSeries: unknown,
|
||||||
|
): { xData: string[]; series: ChatChartSeries[] } => {
|
||||||
|
const xData = normalizeXData(rawXData);
|
||||||
|
const rawSeriesItems = normalizeRawSeriesItems(rawSeries);
|
||||||
|
if (!rawSeriesItems.length) {
|
||||||
|
return { xData, series: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSeries = rawSeriesItems
|
||||||
|
.map((rawItem, seriesIndex): ChatChartSeries | null => {
|
||||||
|
const item =
|
||||||
|
rawItem && typeof rawItem === "object" && !Array.isArray(rawItem)
|
||||||
|
? (rawItem as RawChartSeries)
|
||||||
|
: ({ data: rawItem } satisfies RawChartSeries);
|
||||||
|
const rawData = item.data ?? item.points ?? item.values;
|
||||||
|
if (!Array.isArray(rawData)) return null;
|
||||||
|
|
||||||
|
const labelsFromPoints: string[] = [];
|
||||||
|
const data = rawData
|
||||||
|
.map((point, index) => {
|
||||||
|
const parsed = pointToLabelValue(
|
||||||
|
point as RawChartPoint,
|
||||||
|
xData[index] ?? `${index + 1}`,
|
||||||
|
);
|
||||||
|
if (!parsed) return null;
|
||||||
|
labelsFromPoints[index] = parsed.label;
|
||||||
|
return parsed.value;
|
||||||
|
})
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
|
||||||
|
if (!data.length) return null;
|
||||||
|
if (!xData.length && labelsFromPoints.length) {
|
||||||
|
xData.push(...labelsFromPoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name:
|
||||||
|
typeof item.name === "string" && item.name.trim()
|
||||||
|
? item.name
|
||||||
|
: `系列 ${seriesIndex + 1}`,
|
||||||
|
data,
|
||||||
|
type: normalizeSeriesType(item.type),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is ChatChartSeries => Boolean(item));
|
||||||
|
|
||||||
|
return { xData, series: normalizedSeries };
|
||||||
|
};
|
||||||
|
|
||||||
export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
||||||
title,
|
title,
|
||||||
chart_type: chartType = "line",
|
chart_type: chartType = "line",
|
||||||
x_data: xData,
|
x_data,
|
||||||
series = [],
|
series,
|
||||||
y_axis_name: yAxisName,
|
y_axis_name: yAxisName,
|
||||||
x_axis_name: xAxisName,
|
x_axis_name: xAxisName,
|
||||||
|
isStreaming = false,
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const [showIntroSkeleton, setShowIntroSkeleton] = React.useState(true);
|
||||||
|
const { xData, series: chartSeries } = useMemo(
|
||||||
|
() => normalizeChartData(x_data, series),
|
||||||
|
[x_data, series],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setShowIntroSkeleton(false);
|
||||||
|
}, isStreaming ? 360 : 260);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [isStreaming]);
|
||||||
|
|
||||||
const option = useMemo(() => {
|
const option = useMemo(() => {
|
||||||
if (!series.length) return null;
|
if (!chartSeries.length) return null;
|
||||||
|
|
||||||
/* ---------- Pie chart ---------- */
|
/* ---------- Pie chart ---------- */
|
||||||
if (chartType === "pie") {
|
if (chartType === "pie") {
|
||||||
const pieData =
|
const pieData =
|
||||||
series[0]?.data.map((value, i) => ({
|
chartSeries[0]?.data.map((value, i) => ({
|
||||||
name: xData?.[i] ?? `${i}`,
|
name: xData?.[i] ?? `${i}`,
|
||||||
value,
|
value,
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
animation: true,
|
||||||
|
animationDuration: isStreaming ? 560 : 420,
|
||||||
|
animationDurationUpdate: 240,
|
||||||
|
animationEasing: "cubicOut",
|
||||||
|
animationEasingUpdate: "cubicOut",
|
||||||
tooltip: { trigger: "item" },
|
tooltip: { trigger: "item" },
|
||||||
legend: { top: "bottom", textStyle: { fontSize: 11 } },
|
legend: { top: "bottom", textStyle: { fontSize: 11 } },
|
||||||
series: [
|
series: [
|
||||||
@@ -74,6 +286,10 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
label: { fontSize: 11 },
|
label: { fontSize: 11 },
|
||||||
|
animationType: "expansion",
|
||||||
|
animationDuration: isStreaming ? 560 : 420,
|
||||||
|
animationDelay: (idx: number) => idx * 40,
|
||||||
|
animationDurationUpdate: 240,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
color: COLORS,
|
color: COLORS,
|
||||||
@@ -82,6 +298,11 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
|||||||
|
|
||||||
/* ---------- Line / Bar chart ---------- */
|
/* ---------- Line / Bar chart ---------- */
|
||||||
return {
|
return {
|
||||||
|
animation: true,
|
||||||
|
animationDuration: isStreaming ? 560 : 420,
|
||||||
|
animationDurationUpdate: 240,
|
||||||
|
animationEasing: "cubicOut",
|
||||||
|
animationEasingUpdate: "cubicOut",
|
||||||
tooltip: { trigger: "axis", confine: true },
|
tooltip: { trigger: "axis", confine: true },
|
||||||
legend: { top: "top", textStyle: { fontSize: 11 } },
|
legend: { top: "top", textStyle: { fontSize: 11 } },
|
||||||
grid: {
|
grid: {
|
||||||
@@ -111,16 +332,24 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
|||||||
xData && xData.length > 20
|
xData && xData.length > 20
|
||||||
? [{ type: "inside", start: 0, end: 100 }]
|
? [{ type: "inside", start: 0, end: 100 }]
|
||||||
: undefined,
|
: undefined,
|
||||||
series: series.map((s, i) => {
|
series: chartSeries.map((s, i) => {
|
||||||
const color = COLORS[i % COLORS.length];
|
const color = COLORS[i % COLORS.length];
|
||||||
|
const isLineSeries = chartType === "line";
|
||||||
return {
|
return {
|
||||||
name: s.name,
|
name: s.name,
|
||||||
type: (s.type ?? chartType) as string,
|
type: (s.type ?? chartType) as string,
|
||||||
data: s.data,
|
data: s.data,
|
||||||
symbol: chartType === "line" ? "none" : undefined,
|
symbol: isLineSeries ? "none" : undefined,
|
||||||
smooth: chartType === "line",
|
smooth: isLineSeries,
|
||||||
itemStyle: { color },
|
itemStyle: { color },
|
||||||
...(chartType === "line"
|
animationDuration: isStreaming ? 560 : 420,
|
||||||
|
animationDurationUpdate: 240,
|
||||||
|
animationDelay:
|
||||||
|
chartType === "bar"
|
||||||
|
? (idx: number) => i * 80 + idx * 18
|
||||||
|
: i * 80,
|
||||||
|
animationDelayUpdate: 0,
|
||||||
|
...(isLineSeries
|
||||||
? {
|
? {
|
||||||
areaStyle: {
|
areaStyle: {
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
@@ -135,44 +364,96 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
|||||||
}),
|
}),
|
||||||
color: COLORS,
|
color: COLORS,
|
||||||
};
|
};
|
||||||
}, [chartType, xData, series, title, yAxisName, xAxisName]);
|
}, [chartType, xData, chartSeries, title, yAxisName, xAxisName, isStreaming]);
|
||||||
|
|
||||||
if (!option) {
|
if (!option) {
|
||||||
return (
|
return (
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
<Paper
|
||||||
图表数据为空
|
elevation={0}
|
||||||
</Typography>
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
mb: 1,
|
||||||
|
minHeight: 72,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
px: 2,
|
||||||
|
borderRadius: 3,
|
||||||
|
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.72),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
图表数据为空
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<motion.div
|
||||||
elevation={0}
|
initial={{ opacity: 0 }}
|
||||||
sx={{
|
animate={{ opacity: 1 }}
|
||||||
mt: 1.5,
|
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||||
mb: 1,
|
style={{ width: "100%" }}
|
||||||
borderRadius: 3,
|
|
||||||
border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
|
|
||||||
bgcolor: alpha("#fff", 0.92),
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{title && (
|
<Paper
|
||||||
<Typography
|
elevation={0}
|
||||||
variant="subtitle2"
|
sx={{
|
||||||
sx={{ px: 2, pt: 1.5, fontWeight: 600, color: "text.primary" }}
|
mt: 1.5,
|
||||||
|
mb: 1,
|
||||||
|
minHeight: CHART_MIN_HEIGHT,
|
||||||
|
borderRadius: 3,
|
||||||
|
border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.92),
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{showIntroSkeleton ? (
|
||||||
|
<Box
|
||||||
|
key="chart-intro-skeleton"
|
||||||
|
component={motion.div}
|
||||||
|
aria-hidden
|
||||||
|
initial={{ opacity: 1 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
bgcolor: alpha("#fff", 0.92),
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChartSkeletonContent />
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
{title && (
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{ px: 2, pt: 1.5, fontWeight: 600, color: "text.primary" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
component={motion.div}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: showIntroSkeleton ? 0.35 : 1 }}
|
||||||
|
transition={{ duration: 0.24, ease: "easeOut" }}
|
||||||
|
sx={{ px: 1, pb: 1, minHeight: CHART_HEIGHT }}
|
||||||
>
|
>
|
||||||
{title}
|
<ReactECharts
|
||||||
</Typography>
|
option={option}
|
||||||
)}
|
style={{ height: CHART_HEIGHT, width: "100%" }}
|
||||||
<Box sx={{ px: 1, pb: 1 }}>
|
notMerge
|
||||||
<ReactECharts
|
lazyUpdate
|
||||||
option={option}
|
/>
|
||||||
style={{ height: 240, width: "100%" }}
|
</Box>
|
||||||
notMerge
|
</Paper>
|
||||||
lazyUpdate
|
</motion.div>
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ import {
|
|||||||
type ChatToolAction,
|
type ChatToolAction,
|
||||||
} from "@/store/chatToolStore";
|
} from "@/store/chatToolStore";
|
||||||
import type { ToolCall } from "./chatMessageSections";
|
import type { ToolCall } from "./chatMessageSections";
|
||||||
|
import {
|
||||||
|
APPLY_LAYER_STYLE_TOOL,
|
||||||
|
describeApplyLayerStyle,
|
||||||
|
parseApplyLayerStylePayload,
|
||||||
|
} from "./toolCallStyleHelpers";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Interactive card rendered inside a chat bubble for tool actions */
|
/* Interactive card rendered inside a chat bubble for tool actions */
|
||||||
@@ -113,6 +118,12 @@ const TOOL_META: Record<string, ToolMeta> = {
|
|||||||
actionLabel: "定位到地图",
|
actionLabel: "定位到地图",
|
||||||
color: "#3ba272",
|
color: "#3ba272",
|
||||||
},
|
},
|
||||||
|
zoom_to_map: {
|
||||||
|
label: "缩放到坐标",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "缩放到地图",
|
||||||
|
color: "#0ea5e9",
|
||||||
|
},
|
||||||
view_history: {
|
view_history: {
|
||||||
label: "查看计算结果",
|
label: "查看计算结果",
|
||||||
icon: <TimelineRounded sx={{ fontSize: 18 }} />,
|
icon: <TimelineRounded sx={{ fontSize: 18 }} />,
|
||||||
@@ -137,6 +148,12 @@ const TOOL_META: Record<string, ToolMeta> = {
|
|||||||
actionLabel: "应用渲染",
|
actionLabel: "应用渲染",
|
||||||
color: "#3b82f6",
|
color: "#3b82f6",
|
||||||
},
|
},
|
||||||
|
[APPLY_LAYER_STYLE_TOOL]: {
|
||||||
|
label: "图层样式",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "应用样式",
|
||||||
|
color: "#14b8a6",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- helpers ---------- */
|
/* ---------- helpers ---------- */
|
||||||
@@ -165,6 +182,46 @@ function normalizeLocateIds(params: Record<string, unknown>): string[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readFiniteNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildZoomTo3857Action(
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): Extract<ChatToolAction, { type: "zoom_to_map" }> | null {
|
||||||
|
const rawCoordinate = params.coordinate ?? params.coordinates ?? params.center;
|
||||||
|
const tuple = Array.isArray(rawCoordinate)
|
||||||
|
? rawCoordinate
|
||||||
|
: [params.x ?? params.lon ?? params.longitude, params.y ?? params.lat ?? params.latitude];
|
||||||
|
const x = readFiniteNumber(tuple[0]);
|
||||||
|
const y = readFiniteNumber(tuple[1]);
|
||||||
|
if (x === null || y === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoom = readFiniteNumber(params.zoom);
|
||||||
|
const durationMs = readFiniteNumber(params.duration_ms ?? params.durationMs);
|
||||||
|
const rawSourceCrs = params.source_crs ?? params.sourceCrs ?? params.crs;
|
||||||
|
const normalizedSourceCrs =
|
||||||
|
typeof rawSourceCrs === "string" ? rawSourceCrs.trim().toUpperCase() : "";
|
||||||
|
const sourceCrs =
|
||||||
|
normalizedSourceCrs === "EPSG:4326" ? "EPSG:4326" : "EPSG:3857";
|
||||||
|
return {
|
||||||
|
type: "zoom_to_map",
|
||||||
|
coordinate: [x, y],
|
||||||
|
sourceCrs,
|
||||||
|
zoom: zoom ?? undefined,
|
||||||
|
durationMs: durationMs ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getToolDescription(toolCall: ToolCall): string {
|
function getToolDescription(toolCall: ToolCall): string {
|
||||||
const { params } = toolCall;
|
const { params } = toolCall;
|
||||||
const resolveScadaFeatureInfos = (): [string, string][] => {
|
const resolveScadaFeatureInfos = (): [string, string][] => {
|
||||||
@@ -270,6 +327,18 @@ function getToolDescription(toolCall: ToolCall): string {
|
|||||||
case "render_junctions": {
|
case "render_junctions": {
|
||||||
return (params.render_ref as string | undefined) ?? "渲染引用";
|
return (params.render_ref as string | undefined) ?? "渲染引用";
|
||||||
}
|
}
|
||||||
|
case "zoom_to_map": {
|
||||||
|
const action = buildZoomTo3857Action(params);
|
||||||
|
if (!action) {
|
||||||
|
return "地图坐标";
|
||||||
|
}
|
||||||
|
const zoom = action.zoom === undefined ? "" : ` · zoom ${action.zoom}`;
|
||||||
|
return `${action.coordinate[0]}, ${action.coordinate[1]} · ${action.sourceCrs}${zoom}`;
|
||||||
|
}
|
||||||
|
case APPLY_LAYER_STYLE_TOOL: {
|
||||||
|
const payload = parseApplyLayerStylePayload(params);
|
||||||
|
return payload ? describeApplyLayerStyle(payload) : "图层样式";
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -326,6 +395,8 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
|
|||||||
(params.end as string | undefined),
|
(params.end as string | undefined),
|
||||||
});
|
});
|
||||||
switch (toolCall.tool) {
|
switch (toolCall.tool) {
|
||||||
|
case "zoom_to_map":
|
||||||
|
return buildZoomTo3857Action(params);
|
||||||
case "locate_features": {
|
case "locate_features": {
|
||||||
const featureTypeRaw = params.feature_type;
|
const featureTypeRaw = params.feature_type;
|
||||||
const featureType =
|
const featureType =
|
||||||
@@ -403,6 +474,18 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
|
|||||||
renderRef,
|
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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
||||||
|
import { useNotification } from "@refinedev/core";
|
||||||
|
|
||||||
import type { AgentModel } from "@/lib/chatStream";
|
import { getAccessToken } from "@/lib/authToken";
|
||||||
import { AgentComposer } from "./AgentComposer";
|
import { fetchAgentModels, type AgentModelOption } from "@/lib/chatModels";
|
||||||
|
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
||||||
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
|
import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
|
||||||
import { AgentHeader } from "./AgentHeader";
|
import { AgentHeader } from "./AgentHeader";
|
||||||
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
||||||
import { AgentWorkspace } from "./AgentWorkspace";
|
import { AgentWorkspace } from "./AgentWorkspace";
|
||||||
@@ -20,18 +24,28 @@ import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice"
|
|||||||
import { useAgentChatSession } from "./hooks/useAgentChatSession";
|
import { useAgentChatSession } from "./hooks/useAgentChatSession";
|
||||||
import { useAgentToolActions } from "./hooks/useAgentToolActions";
|
import { useAgentToolActions } from "./hooks/useAgentToolActions";
|
||||||
|
|
||||||
|
const STREAMING_BOTTOM_RESERVE_PX = 180;
|
||||||
|
const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36;
|
||||||
|
|
||||||
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const [width, setWidth] = useState(520);
|
const [width, setWidth] = useState(520);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState<AgentModel>(
|
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
|
||||||
"deepseek/deepseek-v4-pro",
|
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
|
||||||
);
|
const [selectedModel, setSelectedModel] = useState<AgentModel | undefined>(undefined);
|
||||||
|
const [approvalMode, setApprovalMode] =
|
||||||
|
useState<AgentApprovalMode>("request");
|
||||||
|
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const workspaceScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isNearBottomRef = useRef(true);
|
||||||
|
const streamingScrollFrameRef = useRef<number | null>(null);
|
||||||
|
const composerRef = useRef<AgentComposerHandle | null>(null);
|
||||||
|
const hasResetForOpenRef = useRef(false);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const { open: openNotification } = useNotification();
|
||||||
|
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
speechState,
|
speechState,
|
||||||
@@ -44,7 +58,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
} = useSpeechSynthesis();
|
} = useSpeechSynthesis();
|
||||||
|
|
||||||
const handleSpeechResult = useCallback((text: string) => {
|
const handleSpeechResult = useCallback((text: string) => {
|
||||||
setInput((prev) => prev + text);
|
composerRef.current?.append(text);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -54,100 +68,209 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
isSupported: isSttSupported,
|
isSupported: isSttSupported,
|
||||||
} = useSpeechRecognition(handleSpeechResult);
|
} = useSpeechRecognition(handleSpeechResult);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadModels = async () => {
|
||||||
|
try {
|
||||||
|
const modelConfig = await fetchAgentModels();
|
||||||
|
if (cancelled) return;
|
||||||
|
setModelOptions(modelConfig.models);
|
||||||
|
setSelectedModel((current) => {
|
||||||
|
if (current && modelConfig.models.some((model) => model.id === current)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return modelConfig.defaultModel;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[GlobalChatbox] Failed to load agent models:", error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setModelOptions([]);
|
||||||
|
setSelectedModel(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadModels();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleToolCall = useAgentToolActions();
|
const handleToolCall = useAgentToolActions();
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
chatSessions,
|
chatSessions,
|
||||||
activeStorageSessionId,
|
activeSessionId,
|
||||||
branchGroups,
|
|
||||||
branchTransition,
|
|
||||||
isHydrating,
|
isHydrating,
|
||||||
|
loadingSessionId,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
sessionTitle,
|
sessionTitle,
|
||||||
sendPrompt,
|
sendPrompt,
|
||||||
regenerate,
|
createBranch,
|
||||||
editAndResubmit,
|
|
||||||
cycleBranch,
|
|
||||||
abort,
|
abort,
|
||||||
|
replyPermission,
|
||||||
|
replyQuestion,
|
||||||
|
rejectQuestion,
|
||||||
createSession,
|
createSession,
|
||||||
renameSession,
|
renameSession,
|
||||||
removeSession,
|
removeSession,
|
||||||
switchSession,
|
switchSession,
|
||||||
} = useAgentChatSession({
|
} = useAgentChatSession({
|
||||||
|
projectId: currentProjectId,
|
||||||
onToolCall: handleToolCall,
|
onToolCall: handleToolCall,
|
||||||
onBeforeSend: stopListening,
|
onBeforeSend: stopListening,
|
||||||
getModel: () => selectedModel,
|
getModel: () => selectedModel,
|
||||||
|
getApprovalMode: () => approvalMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
bottomRef.current?.scrollIntoView({ behavior });
|
||||||
}, [messages, isStreaming]);
|
}, []);
|
||||||
|
|
||||||
|
const cancelStreamingScroll = useCallback(() => {
|
||||||
|
if (streamingScrollFrameRef.current === null) return;
|
||||||
|
window.cancelAnimationFrame(streamingScrollFrameRef.current);
|
||||||
|
streamingScrollFrameRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleStreamingScrollToBottom = useCallback(() => {
|
||||||
|
if (streamingScrollFrameRef.current !== null) return;
|
||||||
|
streamingScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
streamingScrollFrameRef.current = null;
|
||||||
|
const container = workspaceScrollRef.current;
|
||||||
|
if (!container || !isNearBottomRef.current) return;
|
||||||
|
|
||||||
|
const distanceToBottom =
|
||||||
|
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
if (distanceToBottom < STREAMING_SCROLL_RESTORE_AT_PX) return;
|
||||||
|
|
||||||
|
container.scrollTop = container.scrollHeight - container.clientHeight;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleWorkspaceScrollStateChange = useCallback((isNearBottom: boolean) => {
|
||||||
|
isNearBottomRef.current = isNearBottom;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (isStreaming) {
|
||||||
|
if (!isNearBottomRef.current) return;
|
||||||
|
scheduleStreamingScrollToBottom();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelStreamingScroll();
|
||||||
|
scrollToBottom("smooth");
|
||||||
|
}, [
|
||||||
|
cancelStreamingScroll,
|
||||||
|
isStreaming,
|
||||||
|
messages,
|
||||||
|
scheduleStreamingScrollToBottom,
|
||||||
|
scrollToBottom,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
cancelStreamingScroll();
|
||||||
|
},
|
||||||
|
[cancelStreamingScroll],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
hasResetForOpenRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasResetForOpenRef.current || isHydrating) return;
|
||||||
|
hasResetForOpenRef.current = true;
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
inputRef.current?.focus();
|
createSession();
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
composerRef.current?.clear();
|
||||||
|
setIsHistoryOpen(false);
|
||||||
|
composerRef.current?.focus();
|
||||||
|
isNearBottomRef.current = true;
|
||||||
|
cancelStreamingScroll();
|
||||||
|
scrollToBottom("auto");
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [open]);
|
}, [cancelStreamingScroll, createSession, isHydrating, open, scrollToBottom]);
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(async (prompt: string) => {
|
||||||
const prompt = input.trim();
|
if (isStreaming || isCheckingAuth) return;
|
||||||
if (!prompt || isStreaming) return;
|
|
||||||
setInput("");
|
|
||||||
void sendPrompt(prompt);
|
|
||||||
}, [input, isStreaming, sendPrompt]);
|
|
||||||
|
|
||||||
const handlePresetPromptSelect = useCallback((prompt: string) => {
|
setIsCheckingAuth(true);
|
||||||
setInput(prompt);
|
try {
|
||||||
window.setTimeout(() => {
|
const accessToken = await getAccessToken();
|
||||||
inputRef.current?.focus();
|
if (!accessToken) {
|
||||||
}, 0);
|
composerRef.current?.setValue(prompt);
|
||||||
}, []);
|
openNotification?.({
|
||||||
|
type: "error",
|
||||||
|
message: "登录状态已失效",
|
||||||
|
description: "请重新登录后再发送对话。",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendPrompt(prompt);
|
||||||
|
} catch (error) {
|
||||||
|
composerRef.current?.setValue(prompt);
|
||||||
|
openNotification?.({
|
||||||
|
type: "error",
|
||||||
|
message: "登录状态校验失败",
|
||||||
|
description: error instanceof Error ? error.message : "请重新登录后再试。",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCheckingAuth(false);
|
||||||
|
}
|
||||||
|
}, [isCheckingAuth, isStreaming, openNotification, sendPrompt]);
|
||||||
|
|
||||||
const handleNewConversation = useCallback(() => {
|
const handleNewConversation = useCallback(() => {
|
||||||
handleStopSpeech();
|
handleStopSpeech();
|
||||||
stopListening();
|
stopListening();
|
||||||
void createSession();
|
createSession();
|
||||||
setInput("");
|
composerRef.current?.clear();
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
inputRef.current?.focus();
|
composerRef.current?.focus();
|
||||||
|
isNearBottomRef.current = true;
|
||||||
|
cancelStreamingScroll();
|
||||||
|
scrollToBottom("auto");
|
||||||
}, 0);
|
}, 0);
|
||||||
}, [createSession, handleStopSpeech, stopListening]);
|
}, [cancelStreamingScroll, createSession, handleStopSpeech, scrollToBottom, stopListening]);
|
||||||
|
|
||||||
const handleHistoryToggle = useCallback(() => {
|
const handleHistoryToggle = useCallback(() => {
|
||||||
setIsHistoryOpen((prev) => !prev);
|
setIsHistoryOpen((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelectSession = useCallback(
|
const handleSelectSession = useCallback(
|
||||||
(storageSessionId: string) => {
|
(sessionId: string, title: string) => {
|
||||||
setInput("");
|
composerRef.current?.clear();
|
||||||
void switchSession(storageSessionId);
|
void switchSession(sessionId, title);
|
||||||
},
|
},
|
||||||
[switchSession],
|
[switchSession],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteSession = useCallback(
|
const handleDeleteSession = useCallback(
|
||||||
(storageSessionId: string) => {
|
(sessionId: string) => {
|
||||||
void removeSession(storageSessionId);
|
void removeSession(sessionId);
|
||||||
},
|
},
|
||||||
[removeSession],
|
[removeSession],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRenameSession = useCallback(
|
const handleRenameSession = useCallback(
|
||||||
(storageSessionId: string, title: string) => {
|
(sessionId: string, title: string) => {
|
||||||
void renameSession(storageSessionId, title);
|
void renameSession(sessionId, title);
|
||||||
},
|
},
|
||||||
[renameSession],
|
[renameSession],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRenameActiveSession = useCallback(
|
const handleRenameActiveSession = useCallback(
|
||||||
(title: string) => {
|
(title: string) => {
|
||||||
if (!activeStorageSessionId) return;
|
if (!activeSessionId) return;
|
||||||
void renameSession(activeStorageSessionId, title);
|
void renameSession(activeSessionId, title);
|
||||||
},
|
},
|
||||||
[activeStorageSessionId, renameSession],
|
[activeSessionId, renameSession],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseDown = useCallback((event: React.MouseEvent) => {
|
const handleMouseDown = useCallback((event: React.MouseEvent) => {
|
||||||
@@ -159,7 +282,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
const handleMouseMove = (event: MouseEvent) => {
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
if (!isResizing) return;
|
if (!isResizing) return;
|
||||||
const newWidth = window.innerWidth - event.clientX;
|
const newWidth = window.innerWidth - event.clientX;
|
||||||
if (newWidth > 360 && newWidth < 1240) {
|
if (newWidth > 360 && newWidth < 800) {
|
||||||
setWidth(newWidth);
|
setWidth(newWidth);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -247,7 +370,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
|
|
||||||
<AgentHeader
|
<AgentHeader
|
||||||
sessionTitle={sessionTitle}
|
sessionTitle={sessionTitle}
|
||||||
canRenameSessionTitle={Boolean(activeStorageSessionId)}
|
canRenameSessionTitle={Boolean(activeSessionId)}
|
||||||
isHydrating={isHydrating}
|
isHydrating={isHydrating}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
isHistoryOpen={isHistoryOpen}
|
isHistoryOpen={isHistoryOpen}
|
||||||
@@ -286,14 +409,16 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
>
|
>
|
||||||
<AgentHistoryPanel
|
<AgentHistoryPanel
|
||||||
sessions={chatSessions}
|
sessions={chatSessions}
|
||||||
activeSessionId={activeStorageSessionId}
|
activeSessionId={activeSessionId}
|
||||||
isHydrating={isHydrating}
|
isHydrating={isHydrating}
|
||||||
|
isLoadingSessions={isHydrating && chatSessions.length === 0}
|
||||||
|
loadingSessionId={loadingSessionId}
|
||||||
onNewSession={() => {
|
onNewSession={() => {
|
||||||
handleNewConversation();
|
handleNewConversation();
|
||||||
setIsHistoryOpen(false);
|
setIsHistoryOpen(false);
|
||||||
}}
|
}}
|
||||||
onSelectSession={(id) => {
|
onSelectSession={(id, title) => {
|
||||||
handleSelectSession(id);
|
handleSelectSession(id, title);
|
||||||
setIsHistoryOpen(false);
|
setIsHistoryOpen(false);
|
||||||
}}
|
}}
|
||||||
onRenameSession={handleRenameSession}
|
onRenameSession={handleRenameSession}
|
||||||
@@ -304,10 +429,11 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
<Box sx={{ flex: 1, display: "flex", minWidth: 0, flexDirection: "column" }}>
|
<Box sx={{ flex: 1, display: "flex", minWidth: 0, flexDirection: "column" }}>
|
||||||
<AgentWorkspace
|
<AgentWorkspace
|
||||||
messages={messages}
|
messages={messages}
|
||||||
branchGroups={branchGroups}
|
|
||||||
branchTransition={branchTransition}
|
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
|
isLoadingSession={Boolean(loadingSessionId)}
|
||||||
|
scrollContainerRef={workspaceScrollRef}
|
||||||
bottomRef={bottomRef}
|
bottomRef={bottomRef}
|
||||||
|
onScrollStateChange={handleWorkspaceScrollStateChange}
|
||||||
speakingMessageId={speakingMessageId}
|
speakingMessageId={speakingMessageId}
|
||||||
speechState={speechState}
|
speechState={speechState}
|
||||||
onSpeak={handleSpeak}
|
onSpeak={handleSpeak}
|
||||||
@@ -315,27 +441,28 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
onResumeSpeech={handleResumeSpeech}
|
onResumeSpeech={handleResumeSpeech}
|
||||||
onStopSpeech={handleStopSpeech}
|
onStopSpeech={handleStopSpeech}
|
||||||
isTtsSupported={isTtsSupported}
|
isTtsSupported={isTtsSupported}
|
||||||
onRegenerate={regenerate}
|
onCreateBranch={createBranch}
|
||||||
onEditResubmit={editAndResubmit}
|
onReplyPermission={replyPermission}
|
||||||
onCycleBranch={cycleBranch}
|
onReplyQuestion={replyQuestion}
|
||||||
|
onRejectQuestion={rejectQuestion}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AgentComposer
|
<AgentComposer
|
||||||
input={input}
|
ref={composerRef}
|
||||||
inputRef={inputRef}
|
isHydrating={isHydrating || isCheckingAuth}
|
||||||
isHydrating={isHydrating}
|
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
isListening={isListening}
|
isListening={isListening}
|
||||||
isSttSupported={isSttSupported}
|
isSttSupported={isSttSupported}
|
||||||
presets={PRESET_PROMPTS}
|
presets={PRESET_PROMPTS}
|
||||||
onInputChange={setInput}
|
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onAbort={abort}
|
onAbort={abort}
|
||||||
onStartListening={startListening}
|
onStartListening={startListening}
|
||||||
onStopListening={stopListening}
|
onStopListening={stopListening}
|
||||||
onPresetSelect={handlePresetPromptSelect}
|
modelOptions={modelOptions}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onModelChange={setSelectedModel}
|
onModelChange={setSelectedModel}
|
||||||
|
approvalMode={approvalMode}
|
||||||
|
onApprovalModeChange={setApprovalMode}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import type {
|
||||||
|
AgentQuestionRequest,
|
||||||
|
AgentTodoUpdate,
|
||||||
|
} from "@/lib/chatStream";
|
||||||
|
|
||||||
export type ChatProgress = {
|
export type ChatProgress = {
|
||||||
id: string;
|
id: string;
|
||||||
phase: string;
|
phase: string;
|
||||||
@@ -22,6 +27,32 @@ export type AgentArtifact = {
|
|||||||
params: Record<string, unknown>;
|
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 = {
|
export type Message = {
|
||||||
id: string;
|
id: string;
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
@@ -29,34 +60,9 @@ export type Message = {
|
|||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
progress?: ChatProgress[];
|
progress?: ChatProgress[];
|
||||||
artifacts?: AgentArtifact[];
|
artifacts?: AgentArtifact[];
|
||||||
branchRootId?: string;
|
permissions?: AgentPermissionRequest[];
|
||||||
};
|
questions?: AgentQuestionRequest[];
|
||||||
|
todos?: AgentTodoUpdate;
|
||||||
export type BranchState = {
|
|
||||||
activeIndex: number;
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MessageBranch = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
sessionId?: string;
|
|
||||||
messages: Message[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BranchGroup = {
|
|
||||||
id: string;
|
|
||||||
rootMessageId: string;
|
|
||||||
parentCount: number;
|
|
||||||
activeIndex: number;
|
|
||||||
branches: MessageBranch[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BranchTransition = {
|
|
||||||
rootMessageId: string;
|
|
||||||
parentCount: number;
|
|
||||||
activeBranchId: string;
|
|
||||||
nonce: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
@@ -66,41 +72,20 @@ export type Props = {
|
|||||||
|
|
||||||
export type SpeechState = "idle" | "playing" | "paused";
|
export type SpeechState = "idle" | "playing" | "paused";
|
||||||
|
|
||||||
export type LegacyPersistedChatState = {
|
|
||||||
messages: Message[];
|
|
||||||
sessionId?: string;
|
|
||||||
branchGroups?: BranchGroup[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChatSessionRecord = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
isTitleManuallyEdited?: boolean;
|
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
sessionId?: string;
|
|
||||||
messages: Message[];
|
|
||||||
branchGroups: BranchGroup[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChatSessionSummary = {
|
export type ChatSessionSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
isStreaming?: boolean;
|
||||||
|
runStatus?: string;
|
||||||
export type ChatStorageMeta = {
|
|
||||||
key: "chat-meta";
|
|
||||||
activeSessionId?: string;
|
|
||||||
migratedFromLocalStorage?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoadedChatState = {
|
export type LoadedChatState = {
|
||||||
storageSessionId?: string;
|
sessionId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
isTitleManuallyEdited?: boolean;
|
isTitleManuallyEdited?: boolean;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
sessionId?: string;
|
isStreaming?: boolean;
|
||||||
branchGroups: BranchGroup[];
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import type { BranchGroup, Message } from "./GlobalChatbox.types";
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
import type {
|
||||||
|
AgentQuestionRequest,
|
||||||
|
AgentTodoUpdate,
|
||||||
|
} from "@/lib/chatStream";
|
||||||
|
|
||||||
export const createId = () =>
|
export const createId = () =>
|
||||||
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
export const PRESET_PROMPTS = [
|
export const PRESET_PROMPTS = [
|
||||||
"分析当前管网中的水力瓶颈管道,并给出改造建议。",
|
"分析当前管网中的水力瓶颈管道,并给出改造建议。",
|
||||||
|
"供水服务分区分析。",
|
||||||
"帮我分析当前管网压力异常点,并按风险等级排序。",
|
"帮我分析当前管网压力异常点,并按风险等级排序。",
|
||||||
"帮我生成一份今日运行简报,包含问题、原因和建议。",
|
"帮我生成一份今日运行简报,包含问题、原因和建议。",
|
||||||
"查询关键 SCADA 点位最近 24 小时的异常波动。",
|
"查询关键 SCADA 点位最近 24 小时的异常波动。",
|
||||||
@@ -28,19 +33,65 @@ export const stripMarkdown = (md: string): string =>
|
|||||||
.replace(/<[^>]+>/g, "")
|
.replace(/<[^>]+>/g, "")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
|
const normalizeQuestionRequests = (
|
||||||
|
questions: Message["questions"],
|
||||||
|
): Message["questions"] =>
|
||||||
|
Array.isArray(questions)
|
||||||
|
? questions.map((request) => ({
|
||||||
|
...request,
|
||||||
|
questions: Array.isArray(request.questions)
|
||||||
|
? request.questions.map((question) => ({
|
||||||
|
...question,
|
||||||
|
header: typeof question.header === "string" ? question.header : "",
|
||||||
|
question:
|
||||||
|
typeof question.question === "string" ? question.question : "",
|
||||||
|
options: Array.isArray(question.options)
|
||||||
|
? question.options.map((option) => ({
|
||||||
|
label:
|
||||||
|
typeof option.label === "string" ? option.label : "",
|
||||||
|
description:
|
||||||
|
typeof option.description === "string"
|
||||||
|
? option.description
|
||||||
|
: "",
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
answers: Array.isArray(request.answers)
|
||||||
|
? request.answers.map((answer) =>
|
||||||
|
Array.isArray(answer)
|
||||||
|
? answer.filter((item): item is string => typeof item === "string")
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
} satisfies AgentQuestionRequest))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const normalizeTodoUpdate = (todos: Message["todos"]): Message["todos"] => {
|
||||||
|
if (!todos) return undefined;
|
||||||
|
return {
|
||||||
|
...todos,
|
||||||
|
todos: Array.isArray(todos.todos)
|
||||||
|
? todos.todos.map((todo) => ({ ...todo }))
|
||||||
|
: [],
|
||||||
|
} satisfies AgentTodoUpdate;
|
||||||
|
};
|
||||||
|
|
||||||
export const cloneMessage = (message: Message): Message => ({
|
export const cloneMessage = (message: Message): Message => ({
|
||||||
...message,
|
...message,
|
||||||
progress: message.progress ? [...message.progress] : undefined,
|
progress: Array.isArray(message.progress) ? [...message.progress] : undefined,
|
||||||
artifacts: message.artifacts ? [...message.artifacts] : 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);
|
export const cloneMessages = (messages: Message[]) => messages.map(cloneMessage);
|
||||||
|
|
||||||
export const cloneBranchGroups = (branchGroups: BranchGroup[]) =>
|
|
||||||
branchGroups.map((group) => ({
|
|
||||||
...group,
|
|
||||||
branches: group.branches.map((branch) => ({
|
|
||||||
...branch,
|
|
||||||
messages: cloneMessages(branch.messages),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|||||||
@@ -115,3 +115,8 @@
|
|||||||
color: var(--chat-md-quote-text);
|
color: var(--chat-md-quote-text);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.streamFade {
|
||||||
|
box-decoration-break: clone;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
createEmptyChatState,
|
||||||
|
deleteChatSession,
|
||||||
|
listChatSessions,
|
||||||
|
loadChatSessionById,
|
||||||
|
updateChatSessionTitle,
|
||||||
|
} from "./chatStorage";
|
||||||
|
|
||||||
|
const apiFetch = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("@/lib/apiFetch", () => ({
|
||||||
|
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("chatStorage backend session operations", () => {
|
||||||
|
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("lists backend sessions sorted by created time", async () => {
|
||||||
|
apiFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: "session-old",
|
||||||
|
title: "旧会话",
|
||||||
|
created_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
updated_at: "2026-01-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "session-new",
|
||||||
|
title: "新会话",
|
||||||
|
created_at: "2026-01-03T00:00:00.000Z",
|
||||||
|
updated_at: "2026-01-03T00:00:00.000Z",
|
||||||
|
is_streaming: true,
|
||||||
|
run_status: "running",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(listChatSessions()).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "session-new",
|
||||||
|
title: "新会话",
|
||||||
|
isStreaming: true,
|
||||||
|
runStatus: "running",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "session-old",
|
||||||
|
title: "旧会话",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads a backend session state", async () => {
|
||||||
|
apiFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
id: "session-1",
|
||||||
|
title: "管网分析",
|
||||||
|
is_title_manually_edited: true,
|
||||||
|
messages: [{ id: "message-1", role: "user", content: "查压力" }],
|
||||||
|
is_streaming: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(loadChatSessionById("session-1")).resolves.toMatchObject({
|
||||||
|
title: "管网分析",
|
||||||
|
isTitleManuallyEdited: true,
|
||||||
|
sessionId: "session-1",
|
||||||
|
messages: [{ id: "message-1", role: "user", content: "查压力" }],
|
||||||
|
});
|
||||||
|
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1");
|
||||||
|
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates a backend session title through the title endpoint", async () => {
|
||||||
|
apiFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: async () => "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateChatSessionTitle("session-1", " 新标题 ", {
|
||||||
|
isTitleManuallyEdited: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1/title");
|
||||||
|
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" });
|
||||||
|
expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({
|
||||||
|
title: "新标题",
|
||||||
|
is_title_manually_edited: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a backend session and returns the next active session id", async () => {
|
||||||
|
apiFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: async () => "",
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: "session-next",
|
||||||
|
title: "下一会话",
|
||||||
|
created_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
updated_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(deleteChatSession("session-1")).resolves.toBe("session-next");
|
||||||
|
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "DELETE" });
|
||||||
|
expect(apiFetch.mock.calls[1][1]).toMatchObject({ method: "GET" });
|
||||||
|
});
|
||||||
|
});
|
||||||
+125
-322
@@ -1,68 +1,35 @@
|
|||||||
import { openDB, type DBSchema } from "idb";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { config } from "@config/config";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BranchGroup,
|
|
||||||
ChatSessionRecord,
|
|
||||||
ChatSessionSummary,
|
ChatSessionSummary,
|
||||||
ChatStorageMeta,
|
|
||||||
LegacyPersistedChatState,
|
|
||||||
LoadedChatState,
|
LoadedChatState,
|
||||||
Message,
|
Message,
|
||||||
} from "./GlobalChatbox.types";
|
} from "./GlobalChatbox.types";
|
||||||
import {
|
import { cloneMessages } from "./GlobalChatbox.utils";
|
||||||
cloneBranchGroups,
|
|
||||||
cloneMessages,
|
|
||||||
createId,
|
|
||||||
} from "./GlobalChatbox.utils";
|
|
||||||
|
|
||||||
const CHAT_DB_NAME = "tjwater-agent-chat";
|
type BackendSessionPayload = {
|
||||||
const CHAT_DB_VERSION = 1;
|
id?: string;
|
||||||
const SESSION_STORE = "sessions";
|
title?: string;
|
||||||
const META_STORE = "meta";
|
created_at?: string | number;
|
||||||
const META_KEY = "chat-meta" as const;
|
updated_at?: string | number;
|
||||||
const LEGACY_CHAT_STORAGE_KEY = "tjwater_agent_chat_state_v1";
|
is_streaming?: boolean;
|
||||||
|
run_status?: string;
|
||||||
type ChatDB = DBSchema & {
|
|
||||||
sessions: {
|
|
||||||
key: string;
|
|
||||||
value: ChatSessionRecord;
|
|
||||||
indexes: {
|
|
||||||
"by-updatedAt": number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
meta: {
|
|
||||||
key: string;
|
|
||||||
value: ChatStorageMeta;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyLoadedChatState = (): LoadedChatState => ({
|
export const createEmptyChatState = (): LoadedChatState => ({
|
||||||
storageSessionId: undefined,
|
|
||||||
title: undefined,
|
title: undefined,
|
||||||
isTitleManuallyEdited: false,
|
isTitleManuallyEdited: false,
|
||||||
messages: [],
|
messages: [],
|
||||||
sessionId: undefined,
|
sessionId: undefined,
|
||||||
branchGroups: [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const sanitizeMessages = (messages: Message[] | undefined) =>
|
const sanitizeMessages = (messages: Message[] | undefined) =>
|
||||||
Array.isArray(messages) ? cloneMessages(messages) : [];
|
Array.isArray(messages) ? cloneMessages(messages) : [];
|
||||||
|
|
||||||
const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) =>
|
|
||||||
Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : [];
|
|
||||||
|
|
||||||
const hasChatContent = (state: {
|
|
||||||
messages: Message[];
|
|
||||||
branchGroups: BranchGroup[];
|
|
||||||
sessionId?: string;
|
|
||||||
}) =>
|
|
||||||
state.messages.length > 0 ||
|
|
||||||
state.branchGroups.length > 0 ||
|
|
||||||
Boolean(state.sessionId);
|
|
||||||
|
|
||||||
const compareSessionsByAnchorTime = (
|
const compareSessionsByAnchorTime = (
|
||||||
left: Pick<ChatSessionRecord, "id" | "createdAt" | "updatedAt">,
|
left: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
||||||
right: Pick<ChatSessionRecord, "id" | "createdAt" | "updatedAt">,
|
right: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
||||||
) => {
|
) => {
|
||||||
const createdAtDiff = right.createdAt - left.createdAt;
|
const createdAtDiff = right.createdAt - left.createdAt;
|
||||||
if (createdAtDiff !== 0) return createdAtDiff;
|
if (createdAtDiff !== 0) return createdAtDiff;
|
||||||
@@ -73,229 +40,120 @@ const compareSessionsByAnchorTime = (
|
|||||||
return right.id.localeCompare(left.id);
|
return right.id.localeCompare(left.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toLoadedChatState = (session: ChatSessionRecord | undefined): LoadedChatState => {
|
const toMillis = (value: string | number | undefined) =>
|
||||||
if (!session) return emptyLoadedChatState();
|
typeof value === "number" ? value : value ? new Date(value).getTime() : Date.now();
|
||||||
return {
|
|
||||||
storageSessionId: session.id,
|
const normalizeTitle = (value?: string) => value?.trim() || "新对话";
|
||||||
title: session.title,
|
|
||||||
isTitleManuallyEdited: session.isTitleManuallyEdited ?? false,
|
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||||
messages: sanitizeMessages(session.messages),
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
|
||||||
sessionId: session.sessionId,
|
method: "GET",
|
||||||
branchGroups: sanitizeBranchGroups(session.branchGroups),
|
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 toSessionSummary = (session: ChatSessionRecord): ChatSessionSummary => ({
|
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
|
||||||
id: session.id,
|
const response = await apiFetch(
|
||||||
title: session.title,
|
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
||||||
createdAt: session.createdAt,
|
{
|
||||||
updatedAt: session.updatedAt,
|
method: "GET",
|
||||||
});
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
const getDb = () =>
|
skipAuthRedirect: true,
|
||||||
openDB<ChatDB>(CHAT_DB_NAME, CHAT_DB_VERSION, {
|
|
||||||
upgrade(db) {
|
|
||||||
if (!db.objectStoreNames.contains(SESSION_STORE)) {
|
|
||||||
const sessionStore = db.createObjectStore(SESSION_STORE, { keyPath: "id" });
|
|
||||||
sessionStore.createIndex("by-updatedAt", "updatedAt");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains(META_STORE)) {
|
|
||||||
db.createObjectStore(META_STORE, { keyPath: "key" });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
if (!response.ok) {
|
||||||
const readLegacyChatState = (): LegacyPersistedChatState | null => {
|
if (response.status === 404) {
|
||||||
if (typeof window === "undefined") return null;
|
return createEmptyChatState();
|
||||||
|
|
||||||
try {
|
|
||||||
const storedRaw = window.localStorage.getItem(LEGACY_CHAT_STORAGE_KEY);
|
|
||||||
if (!storedRaw) return null;
|
|
||||||
|
|
||||||
const parsed = JSON.parse(storedRaw) as LegacyPersistedChatState;
|
|
||||||
if (!Array.isArray(parsed.messages)) {
|
|
||||||
window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
throw new Error(await response.text());
|
||||||
return {
|
|
||||||
messages: sanitizeMessages(parsed.messages),
|
|
||||||
sessionId: parsed.sessionId,
|
|
||||||
branchGroups: sanitizeBranchGroups(parsed.branchGroups),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[GlobalChatbox] Failed to read legacy chat state:", error);
|
|
||||||
window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
};
|
const payload = (await response.json()) as {
|
||||||
|
id: string;
|
||||||
const clearLegacyChatState = () => {
|
title?: string;
|
||||||
if (typeof window === "undefined") return;
|
is_title_manually_edited?: boolean;
|
||||||
window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY);
|
session_id?: string;
|
||||||
};
|
messages?: Message[];
|
||||||
|
is_streaming?: boolean;
|
||||||
const getMeta = async () => {
|
run_status?: string;
|
||||||
const db = await getDb();
|
|
||||||
return db.get(META_STORE, META_KEY);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setMeta = async (meta: Omit<ChatStorageMeta, "key">) => {
|
|
||||||
const db = await getDb();
|
|
||||||
await db.put(META_STORE, {
|
|
||||||
key: META_KEY,
|
|
||||||
...meta,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLatestSession = async () => {
|
|
||||||
const db = await getDb();
|
|
||||||
const sessions = await db.getAll(SESSION_STORE);
|
|
||||||
if (sessions.length === 0) return undefined;
|
|
||||||
return sessions.sort(compareSessionsByAnchorTime)[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
const migrateLegacyLocalStorage = async () => {
|
|
||||||
const meta = await getMeta();
|
|
||||||
if (meta?.migratedFromLocalStorage) return;
|
|
||||||
|
|
||||||
const legacyState = readLegacyChatState();
|
|
||||||
if (!legacyState) {
|
|
||||||
await setMeta({
|
|
||||||
activeSessionId: meta?.activeSessionId,
|
|
||||||
migratedFromLocalStorage: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasContent =
|
|
||||||
legacyState.messages.length > 0 ||
|
|
||||||
(legacyState.branchGroups?.length ?? 0) > 0 ||
|
|
||||||
Boolean(legacyState.sessionId);
|
|
||||||
|
|
||||||
if (!hasContent) {
|
|
||||||
clearLegacyChatState();
|
|
||||||
await setMeta({
|
|
||||||
activeSessionId: undefined,
|
|
||||||
migratedFromLocalStorage: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const sessionRecord: ChatSessionRecord = {
|
|
||||||
id: createId(),
|
|
||||||
title: "新对话",
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
sessionId: legacyState.sessionId,
|
|
||||||
messages: sanitizeMessages(legacyState.messages),
|
|
||||||
branchGroups: sanitizeBranchGroups(legacyState.branchGroups),
|
|
||||||
};
|
};
|
||||||
|
return {
|
||||||
const db = await getDb();
|
title: normalizeTitle(payload.title),
|
||||||
await db.put(SESSION_STORE, sessionRecord);
|
isTitleManuallyEdited: payload.is_title_manually_edited ?? false,
|
||||||
clearLegacyChatState();
|
messages: sanitizeMessages(payload.messages),
|
||||||
await setMeta({
|
sessionId: payload.session_id ?? payload.id,
|
||||||
activeSessionId: sessionRecord.id,
|
isStreaming: payload.is_streaming ?? false,
|
||||||
migratedFromLocalStorage: true,
|
runStatus: payload.run_status,
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const loadActiveChatState = async (): Promise<LoadedChatState> => {
|
|
||||||
if (typeof window === "undefined") return emptyLoadedChatState();
|
|
||||||
|
|
||||||
await migrateLegacyLocalStorage();
|
|
||||||
|
|
||||||
const meta = await getMeta();
|
|
||||||
const db = await getDb();
|
|
||||||
|
|
||||||
if (meta?.activeSessionId) {
|
|
||||||
const activeSession = await db.get(SESSION_STORE, meta.activeSessionId);
|
|
||||||
if (activeSession) {
|
|
||||||
return toLoadedChatState(activeSession);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestSession = await getLatestSession();
|
|
||||||
if (!latestSession) {
|
|
||||||
return emptyLoadedChatState();
|
|
||||||
}
|
|
||||||
|
|
||||||
await setMeta({
|
|
||||||
activeSessionId: latestSession.id,
|
|
||||||
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return toLoadedChatState(latestSession);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const saveActiveChatState = async (
|
|
||||||
state: LoadedChatState,
|
|
||||||
): Promise<string | undefined> => {
|
|
||||||
if (typeof window === "undefined") return state.storageSessionId;
|
|
||||||
|
|
||||||
const hasContent = hasChatContent(state);
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
const existingSession = state.storageSessionId
|
|
||||||
? await db.get(SESSION_STORE, state.storageSessionId)
|
|
||||||
: undefined;
|
|
||||||
const meta = await getMeta();
|
|
||||||
|
|
||||||
if (!hasContent) {
|
|
||||||
if (state.storageSessionId) {
|
|
||||||
await db.delete(SESSION_STORE, state.storageSessionId);
|
|
||||||
}
|
|
||||||
await setMeta({
|
|
||||||
activeSessionId: undefined,
|
|
||||||
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
|
||||||
});
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const storageSessionId = state.storageSessionId ?? createId();
|
|
||||||
const preferredTitle = state.title?.trim();
|
|
||||||
const finalTitle = preferredTitle || existingSession?.title || "新对话";
|
|
||||||
const shouldAnchorCreatedAtToFirstMessage =
|
|
||||||
Boolean(existingSession) && !hasChatContent(existingSession) && hasContent;
|
|
||||||
const nextRecord: ChatSessionRecord = {
|
|
||||||
id: storageSessionId,
|
|
||||||
title: finalTitle,
|
|
||||||
isTitleManuallyEdited: state.isTitleManuallyEdited ?? existingSession?.isTitleManuallyEdited ?? false,
|
|
||||||
createdAt: shouldAnchorCreatedAtToFirstMessage
|
|
||||||
? now
|
|
||||||
: existingSession?.createdAt ?? now,
|
|
||||||
updatedAt: now,
|
|
||||||
sessionId: state.sessionId,
|
|
||||||
messages: sanitizeMessages(state.messages),
|
|
||||||
branchGroups: sanitizeBranchGroups(state.branchGroups),
|
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
await db.put(SESSION_STORE, nextRecord);
|
const updateBackendChatSessionTitle = async (
|
||||||
await setMeta({
|
sessionId: string,
|
||||||
activeSessionId: storageSessionId,
|
title: string,
|
||||||
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
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());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return storageSessionId;
|
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 listChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
export const listChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||||
if (typeof window === "undefined") return [];
|
if (typeof window === "undefined") return [];
|
||||||
|
return await fetchBackendChatSessions();
|
||||||
await migrateLegacyLocalStorage();
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
const sessions = await db.getAll(SESSION_STORE);
|
|
||||||
return sessions.sort(compareSessionsByAnchorTime).map(toSessionSummary);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateChatSessionTitle = async (
|
export const updateChatSessionTitle = async (
|
||||||
storageSessionId: string,
|
sessionId: string,
|
||||||
title: string,
|
title: string,
|
||||||
options?: {
|
options?: {
|
||||||
isTitleManuallyEdited?: boolean;
|
isTitleManuallyEdited?: boolean;
|
||||||
@@ -305,82 +163,27 @@ export const updateChatSessionTitle = async (
|
|||||||
|
|
||||||
const normalizedTitle = title.trim();
|
const normalizedTitle = title.trim();
|
||||||
if (!normalizedTitle) return;
|
if (!normalizedTitle) return;
|
||||||
|
await updateBackendChatSessionTitle(
|
||||||
const db = await getDb();
|
sessionId,
|
||||||
const session = await db.get(SESSION_STORE, storageSessionId);
|
normalizedTitle,
|
||||||
if (!session) return;
|
options?.isTitleManuallyEdited,
|
||||||
|
);
|
||||||
await db.put(SESSION_STORE, {
|
|
||||||
...session,
|
|
||||||
title: normalizedTitle,
|
|
||||||
isTitleManuallyEdited:
|
|
||||||
options?.isTitleManuallyEdited ?? session.isTitleManuallyEdited ?? false,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createEmptyChatSession = async (): Promise<LoadedChatState> => {
|
export const loadChatSessionById = async (
|
||||||
if (typeof window === "undefined") return emptyLoadedChatState();
|
sessionId: string,
|
||||||
|
): Promise<LoadedChatState> => {
|
||||||
|
if (typeof window === "undefined") return createEmptyChatState();
|
||||||
|
|
||||||
await migrateLegacyLocalStorage();
|
return await fetchBackendChatSession(sessionId);
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const session: ChatSessionRecord = {
|
|
||||||
id: createId(),
|
|
||||||
title: "新对话",
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
sessionId: undefined,
|
|
||||||
messages: [],
|
|
||||||
branchGroups: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
await db.put(SESSION_STORE, session);
|
|
||||||
const meta = await getMeta();
|
|
||||||
await setMeta({
|
|
||||||
activeSessionId: session.id,
|
|
||||||
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return toLoadedChatState(session);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadChatSessionById = async (sessionId: string): Promise<LoadedChatState> => {
|
export const deleteChatSession = async (
|
||||||
if (typeof window === "undefined") return emptyLoadedChatState();
|
sessionId: string,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
await migrateLegacyLocalStorage();
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
const session = await db.get(SESSION_STORE, sessionId);
|
|
||||||
if (!session) {
|
|
||||||
return emptyLoadedChatState();
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = await getMeta();
|
|
||||||
await setMeta({
|
|
||||||
activeSessionId: session.id,
|
|
||||||
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return toLoadedChatState(session);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteChatSession = async (sessionId: string): Promise<string | undefined> => {
|
|
||||||
if (typeof window === "undefined") return undefined;
|
if (typeof window === "undefined") return undefined;
|
||||||
|
|
||||||
const db = await getDb();
|
await deleteBackendChatSession(sessionId);
|
||||||
await db.delete(SESSION_STORE, sessionId);
|
const nextActiveSession = (await listChatSessions())[0];
|
||||||
|
|
||||||
const remainingSessions = await db.getAll(SESSION_STORE);
|
|
||||||
const nextActiveSession = remainingSessions.sort(compareSessionsByAnchorTime)[0];
|
|
||||||
const meta = await getMeta();
|
|
||||||
|
|
||||||
await setMeta({
|
|
||||||
activeSessionId: nextActiveSession?.id,
|
|
||||||
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return nextActiveSession?.id;
|
return nextActiveSession?.id;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
import type {
|
||||||
|
AgentQuestionRequest,
|
||||||
|
AgentTodoUpdate,
|
||||||
|
PermissionReply,
|
||||||
|
StreamEvent,
|
||||||
|
} from "@/lib/chatStream";
|
||||||
|
import type {
|
||||||
|
AgentPermissionRequest,
|
||||||
|
ChatProgress,
|
||||||
|
Message,
|
||||||
|
} from "../GlobalChatbox.types";
|
||||||
|
import { createId } from "../GlobalChatbox.utils";
|
||||||
|
|
||||||
|
export const upsertProgress = (
|
||||||
|
progress: ChatProgress[] | undefined,
|
||||||
|
event: StreamEvent & { type: "progress" },
|
||||||
|
) => {
|
||||||
|
const next = [...(progress ?? [])];
|
||||||
|
const index = next.findIndex((item) => item.id === event.id);
|
||||||
|
const existing = index >= 0 ? next[index] : undefined;
|
||||||
|
const now = Date.now();
|
||||||
|
const startedAt = event.startedAt ?? existing?.startedAt;
|
||||||
|
const isRunning = event.status === "running";
|
||||||
|
const endedAt = isRunning ? undefined : event.endedAt ?? existing?.endedAt ?? now;
|
||||||
|
const elapsedMs = isRunning
|
||||||
|
? event.elapsedMs ??
|
||||||
|
existing?.elapsedMs ??
|
||||||
|
(startedAt !== undefined ? Math.max(0, now - startedAt) : undefined)
|
||||||
|
: undefined;
|
||||||
|
const elapsedSnapshotAt = isRunning
|
||||||
|
? event.elapsedMs !== undefined
|
||||||
|
? now
|
||||||
|
: existing?.elapsedSnapshotAt ?? now
|
||||||
|
: undefined;
|
||||||
|
const durationMs = !isRunning
|
||||||
|
? event.durationMs ??
|
||||||
|
existing?.durationMs ??
|
||||||
|
(startedAt !== undefined && endedAt !== undefined
|
||||||
|
? Math.max(0, endedAt - startedAt)
|
||||||
|
: undefined)
|
||||||
|
: undefined;
|
||||||
|
const nextItem: ChatProgress = {
|
||||||
|
id: event.id,
|
||||||
|
phase: event.phase,
|
||||||
|
status: event.status,
|
||||||
|
title: event.title,
|
||||||
|
detail: event.detail,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
elapsedMs,
|
||||||
|
elapsedSnapshotAt,
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
|
if (index >= 0) {
|
||||||
|
next[index] = nextItem;
|
||||||
|
} else {
|
||||||
|
next.push(nextItem);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
|
||||||
|
progress?.map((item) => {
|
||||||
|
if (item.status !== "running") {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
const endedAt = Date.now();
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
status: "completed" as const,
|
||||||
|
endedAt,
|
||||||
|
elapsedMs: undefined,
|
||||||
|
elapsedSnapshotAt: undefined,
|
||||||
|
durationMs:
|
||||||
|
item.durationMs ??
|
||||||
|
(item.startedAt !== undefined
|
||||||
|
? Math.max(0, endedAt - item.startedAt)
|
||||||
|
: item.elapsedMs),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
|
||||||
|
todoUpdate
|
||||||
|
? {
|
||||||
|
...todoUpdate,
|
||||||
|
todos: todoUpdate.todos.map((todo) =>
|
||||||
|
todo.status === "pending" || todo.status === "in_progress"
|
||||||
|
? {
|
||||||
|
...todo,
|
||||||
|
status: "cancelled" as const,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
: todo,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
export const upsertPermission = (
|
||||||
|
permissions: AgentPermissionRequest[] | undefined,
|
||||||
|
event: StreamEvent & { type: "permission_request" },
|
||||||
|
) => {
|
||||||
|
const next = [...(permissions ?? [])];
|
||||||
|
const index = next.findIndex((item) => item.requestId === event.requestId);
|
||||||
|
const nextItem: AgentPermissionRequest = {
|
||||||
|
requestId: event.requestId,
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
permission: event.permission,
|
||||||
|
patterns: event.patterns,
|
||||||
|
target: event.target,
|
||||||
|
always: event.always,
|
||||||
|
tool: event.tool,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
status: "pending",
|
||||||
|
};
|
||||||
|
if (index >= 0) {
|
||||||
|
next[index] = {
|
||||||
|
...next[index],
|
||||||
|
...nextItem,
|
||||||
|
status: next[index].status === "submitting" ? "submitting" : nextItem.status,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
next.push(nextItem);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["status"] => {
|
||||||
|
if (reply === "always") return "approved_always";
|
||||||
|
if (reply === "once") return "approved_once";
|
||||||
|
return "rejected";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isActionableQuestionRequest = (question: {
|
||||||
|
requestId: string;
|
||||||
|
tool?: AgentQuestionRequest["tool"];
|
||||||
|
}) => Boolean(question.requestId && question.requestId !== question.tool?.callID);
|
||||||
|
|
||||||
|
export const toQuestionRequest = (
|
||||||
|
event: StreamEvent & { type: "question_request" },
|
||||||
|
status: AgentQuestionRequest["status"] = "pending",
|
||||||
|
): AgentQuestionRequest => ({
|
||||||
|
requestId: event.requestId,
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
questions: event.questions,
|
||||||
|
tool: event.tool,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getQuestionContentSignature = (
|
||||||
|
questions: AgentQuestionRequest["questions"],
|
||||||
|
) =>
|
||||||
|
JSON.stringify(
|
||||||
|
questions.map((question) => ({
|
||||||
|
header: question.header,
|
||||||
|
question: question.question,
|
||||||
|
options: question.options.map((option) => ({
|
||||||
|
label: option.label,
|
||||||
|
description: option.description,
|
||||||
|
})),
|
||||||
|
multiple: question.multiple ?? false,
|
||||||
|
custom: question.custom !== false,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const isSameQuestionRequest = (
|
||||||
|
question: AgentQuestionRequest,
|
||||||
|
event: StreamEvent & { type: "question_request" },
|
||||||
|
) => {
|
||||||
|
if (question.requestId === event.requestId) return true;
|
||||||
|
if (question.tool?.callID && event.tool?.callID) {
|
||||||
|
return question.tool.callID === event.tool.callID;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
question.status === "pending" &&
|
||||||
|
question.sessionId === event.sessionId &&
|
||||||
|
getQuestionContentSignature(question.questions) ===
|
||||||
|
getQuestionContentSignature(event.questions)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isSameQuestionPair = (
|
||||||
|
left: AgentQuestionRequest,
|
||||||
|
right: AgentQuestionRequest,
|
||||||
|
) => {
|
||||||
|
if (left.requestId === right.requestId) return true;
|
||||||
|
if (left.tool?.callID && right.tool?.callID) {
|
||||||
|
return left.tool.callID === right.tool.callID;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
left.status === "pending" &&
|
||||||
|
right.status === "pending" &&
|
||||||
|
left.sessionId === right.sessionId &&
|
||||||
|
getQuestionContentSignature(left.questions) ===
|
||||||
|
getQuestionContentSignature(right.questions)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dedupeQuestionsAcrossMessages = (messages: Message[]) => {
|
||||||
|
const seen: AgentQuestionRequest[] = [];
|
||||||
|
let changed = false;
|
||||||
|
const nextMessages = messages.map((message) => {
|
||||||
|
if (!message.questions?.length) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
const nextQuestions = message.questions.filter((question) => {
|
||||||
|
if (seen.some((existing) => isSameQuestionPair(existing, question))) {
|
||||||
|
changed = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.push(question);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (nextQuestions.length === message.questions.length) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
questions: nextQuestions.length ? nextQuestions : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return changed ? nextMessages : messages;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertQuestionAcrossMessages = (
|
||||||
|
messages: Message[],
|
||||||
|
event: StreamEvent & { type: "question_request" },
|
||||||
|
assistantMessageId: string,
|
||||||
|
) => {
|
||||||
|
let existing: AgentQuestionRequest | undefined;
|
||||||
|
for (const message of messages) {
|
||||||
|
const match = message.questions?.find((question) =>
|
||||||
|
isSameQuestionRequest(question, event),
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
existing = match;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingStatus: AgentQuestionRequest["status"] | undefined =
|
||||||
|
existing?.status === "submitting" ? "submitting" : undefined;
|
||||||
|
const nextQuestion =
|
||||||
|
existing &&
|
||||||
|
isActionableQuestionRequest(existing) &&
|
||||||
|
!isActionableQuestionRequest(event)
|
||||||
|
? {
|
||||||
|
...existing,
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
questions: event.questions,
|
||||||
|
tool: event.tool ?? existing.tool,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
status: existingStatus ?? existing.status,
|
||||||
|
}
|
||||||
|
: toQuestionRequest(event, existingStatus ?? "pending");
|
||||||
|
const targetMessageId = existing
|
||||||
|
? messages.find((message) =>
|
||||||
|
message.questions?.some((question) => isSameQuestionRequest(question, event)),
|
||||||
|
)?.id ?? assistantMessageId
|
||||||
|
: assistantMessageId;
|
||||||
|
|
||||||
|
return messages.map((message) => {
|
||||||
|
const filteredQuestions = message.questions?.filter(
|
||||||
|
(question) => !isSameQuestionRequest(question, event),
|
||||||
|
);
|
||||||
|
if (message.id !== targetMessageId) {
|
||||||
|
return filteredQuestions?.length === message.questions?.length
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
questions: filteredQuestions?.length ? filteredQuestions : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextQuestions = [...(filteredQuestions ?? []), nextQuestion];
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
questions: nextQuestions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyQuestionResponse = (
|
||||||
|
questions: AgentQuestionRequest[] | undefined,
|
||||||
|
event: StreamEvent & { type: "question_response" },
|
||||||
|
) =>
|
||||||
|
(questions ?? []).map((question) =>
|
||||||
|
question.requestId === event.requestId
|
||||||
|
? {
|
||||||
|
...question,
|
||||||
|
status: event.rejected ? "rejected" as const : "answered" as const,
|
||||||
|
answers: event.answers ?? question.answers,
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined,
|
||||||
|
}
|
||||||
|
: question,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createTodoUpdateFromEvent = (
|
||||||
|
event: StreamEvent & { type: "todo_update" },
|
||||||
|
): AgentTodoUpdate => ({
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
messageId: event.messageId,
|
||||||
|
todos: event.todos,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const normalizeSessionTodos = (
|
||||||
|
messages: Message[],
|
||||||
|
nextTodoUpdate?: AgentTodoUpdate,
|
||||||
|
targetAssistantMessageId?: string,
|
||||||
|
) => {
|
||||||
|
let latestTodoUpdate = nextTodoUpdate;
|
||||||
|
if (!latestTodoUpdate) {
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.todos) {
|
||||||
|
latestTodoUpdate = message.todos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!latestTodoUpdate) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetMessageId =
|
||||||
|
targetAssistantMessageId ??
|
||||||
|
[...messages].reverse().find((message) => message.role === "assistant")?.id;
|
||||||
|
if (!targetMessageId) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const nextMessages = messages.map((message) => {
|
||||||
|
if (message.id === targetMessageId) {
|
||||||
|
if (message.todos === latestTodoUpdate) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
todos: latestTodoUpdate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!message.todos) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
todos: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return changed ? nextMessages : messages;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const abortOpenPermissionsAfterAbort = (
|
||||||
|
permissions: AgentPermissionRequest[] | undefined,
|
||||||
|
) => {
|
||||||
|
if (!permissions?.length) return permissions;
|
||||||
|
let changed = false;
|
||||||
|
const nextPermissions = permissions.map((permission) => {
|
||||||
|
if (
|
||||||
|
permission.status !== "pending" &&
|
||||||
|
permission.status !== "submitting" &&
|
||||||
|
permission.status !== "error"
|
||||||
|
) {
|
||||||
|
return permission;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...permission,
|
||||||
|
status: "aborted" as const,
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return changed ? nextPermissions : permissions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rejectOpenQuestionsAfterAbort = (
|
||||||
|
questions: AgentQuestionRequest[] | undefined,
|
||||||
|
) => {
|
||||||
|
if (!questions?.length) return questions;
|
||||||
|
let changed = false;
|
||||||
|
const nextQuestions = questions.map((question) => {
|
||||||
|
if (
|
||||||
|
question.status !== "pending" &&
|
||||||
|
question.status !== "submitting" &&
|
||||||
|
question.status !== "error"
|
||||||
|
) {
|
||||||
|
return question;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...question,
|
||||||
|
status: "rejected" as const,
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return changed ? nextQuestions : questions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
||||||
|
const completedProgress = completeRunningProgress(message.progress);
|
||||||
|
const cancelledTodos = cancelRunningTodos(message.todos);
|
||||||
|
const abortedPermissions = abortOpenPermissionsAfterAbort(message.permissions);
|
||||||
|
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
|
||||||
|
const hasVisibleOutput =
|
||||||
|
message.content.trim().length > 0 ||
|
||||||
|
Boolean(message.artifacts?.length) ||
|
||||||
|
Boolean(abortedPermissions?.length) ||
|
||||||
|
Boolean(rejectedQuestions?.length) ||
|
||||||
|
Boolean(completedProgress?.length) ||
|
||||||
|
Boolean(cancelledTodos);
|
||||||
|
|
||||||
|
if (!hasVisibleOutput) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content: message.content || "⚠️ **请求已中断**",
|
||||||
|
isError: true,
|
||||||
|
progress: completedProgress,
|
||||||
|
permissions: abortedPermissions,
|
||||||
|
questions: rejectedQuestions,
|
||||||
|
todos: cancelledTodos,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUserMessage = (content: string): Message => {
|
||||||
|
const id = createId();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "user",
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAssistantMessage = (): Message => ({
|
||||||
|
id: createId(),
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
});
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { useAgentChatSession } from "./useAgentChatSession";
|
||||||
|
import {
|
||||||
|
abortAgentChat,
|
||||||
|
forkAgentChat,
|
||||||
|
replyAgentPermission,
|
||||||
|
replyAgentQuestion,
|
||||||
|
resumeAgentChatStream,
|
||||||
|
streamAgentChat,
|
||||||
|
} from "@/lib/chatStream";
|
||||||
|
import type { StreamEvent } from "@/lib/chatStream";
|
||||||
|
|
||||||
|
jest.mock("@/lib/chatStream", () => ({
|
||||||
|
abortAgentChat: jest.fn(async () => undefined),
|
||||||
|
forkAgentChat: jest.fn(async () => "forked-session"),
|
||||||
|
replyAgentPermission: jest.fn(async () => undefined),
|
||||||
|
replyAgentQuestion: jest.fn(async () => undefined),
|
||||||
|
resumeAgentChatStream: jest.fn(async () => undefined),
|
||||||
|
streamAgentChat: jest.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const listChatSessions = jest.fn();
|
||||||
|
const deleteChatSession = jest.fn();
|
||||||
|
const updateChatSessionTitle = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("../chatStorage", () => ({
|
||||||
|
createEmptyChatState: jest.fn(() => ({
|
||||||
|
title: undefined,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: undefined,
|
||||||
|
})),
|
||||||
|
deleteChatSession: (...args: unknown[]) => deleteChatSession(...args),
|
||||||
|
listChatSessions: (...args: unknown[]) => listChatSessions(...args),
|
||||||
|
loadChatSessionById: jest.fn(async () => ({
|
||||||
|
title: "已存在会话",
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
})),
|
||||||
|
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useAgentChatSession", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
listChatSessions.mockReset();
|
||||||
|
deleteChatSession.mockReset();
|
||||||
|
updateChatSessionTitle.mockReset();
|
||||||
|
jest.mocked(abortAgentChat).mockReset();
|
||||||
|
jest.mocked(forkAgentChat).mockReset();
|
||||||
|
jest.mocked(replyAgentPermission).mockReset();
|
||||||
|
jest.mocked(replyAgentQuestion).mockReset();
|
||||||
|
jest.mocked(resumeAgentChatStream).mockReset();
|
||||||
|
jest.mocked(streamAgentChat).mockReset();
|
||||||
|
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
||||||
|
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
||||||
|
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
||||||
|
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
|
||||||
|
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||||
|
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
||||||
|
deleteChatSession.mockImplementation(async () => undefined);
|
||||||
|
updateChatSessionTitle.mockImplementation(async () => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAgentChatSession actions", () => {
|
||||||
|
it("tracks permission requests and submits replies", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
emitStreamEvent = onEvent;
|
||||||
|
await new Promise<void>(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
void result.current.sendPrompt("删除临时文件");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
emitStreamEvent?.({
|
||||||
|
type: "permission_request",
|
||||||
|
sessionId: "session-1",
|
||||||
|
requestId: "perm-1",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["rm *"],
|
||||||
|
target: "rm tmp.txt",
|
||||||
|
always: ["rm *"],
|
||||||
|
createdAt: 123,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages.at(-1)?.permissions).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "perm-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
status: "pending",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.replyPermission("perm-1", "once");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replyAgentPermission).toHaveBeenCalledWith("session-1", "perm-1", "once");
|
||||||
|
expect(result.current.messages.at(-1)?.permissions?.[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "perm-1",
|
||||||
|
status: "approved_once",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finalizes running progress when aborting an active prompt", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(
|
||||||
|
({ onEvent, signal }) =>
|
||||||
|
new Promise<void>((_, reject) => {
|
||||||
|
onEvent({
|
||||||
|
type: "progress",
|
||||||
|
sessionId: "session-1",
|
||||||
|
id: "request-received",
|
||||||
|
phase: "start",
|
||||||
|
status: "running",
|
||||||
|
title: "开始分析",
|
||||||
|
startedAt: 1000,
|
||||||
|
} satisfies StreamEvent);
|
||||||
|
onEvent({
|
||||||
|
type: "todo_update",
|
||||||
|
sessionId: "session-1",
|
||||||
|
todos: [
|
||||||
|
{
|
||||||
|
id: "todo-1",
|
||||||
|
content: "分析水位",
|
||||||
|
status: "in_progress",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "todo-2",
|
||||||
|
content: "生成建议",
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: 1001,
|
||||||
|
} satisfies StreamEvent);
|
||||||
|
onEvent({
|
||||||
|
type: "permission_request",
|
||||||
|
sessionId: "session-1",
|
||||||
|
requestId: "perm-abort",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["npm test"],
|
||||||
|
target: "npm test",
|
||||||
|
always: ["npm test"],
|
||||||
|
createdAt: 1002,
|
||||||
|
} satisfies StreamEvent);
|
||||||
|
onEvent({
|
||||||
|
type: "question_request",
|
||||||
|
sessionId: "session-1",
|
||||||
|
requestId: "question-abort",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "范围",
|
||||||
|
question: "请选择范围",
|
||||||
|
options: [{ label: "城区", description: "中心城区" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: 1003,
|
||||||
|
} satisfies StreamEvent);
|
||||||
|
|
||||||
|
signal?.addEventListener("abort", () => {
|
||||||
|
reject(new Error("aborted"));
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
void result.current.sendPrompt("测试中断");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isStreaming).toBe(true));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isStreaming).toBe(false));
|
||||||
|
|
||||||
|
expect(result.current.messages.at(-1)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
role: "assistant",
|
||||||
|
content: "⚠️ **请求已中断**",
|
||||||
|
isError: true,
|
||||||
|
progress: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "request-received",
|
||||||
|
status: "completed",
|
||||||
|
durationMs: expect.any(Number),
|
||||||
|
endedAt: expect.any(Number),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
todos: expect.objectContaining({
|
||||||
|
todos: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "todo-1",
|
||||||
|
status: "cancelled",
|
||||||
|
updatedAt: expect.any(Number),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "todo-2",
|
||||||
|
status: "cancelled",
|
||||||
|
updatedAt: expect.any(Number),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
permissions: [
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "perm-abort",
|
||||||
|
status: "aborted",
|
||||||
|
repliedAt: expect.any(Number),
|
||||||
|
error: undefined,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
questions: [
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "question-abort",
|
||||||
|
status: "rejected",
|
||||||
|
repliedAt: expect.any(Number),
|
||||||
|
error: undefined,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(abortAgentChat).toHaveBeenCalledWith("session-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores generated session titles after the title was edited manually", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
onEvent({
|
||||||
|
type: "session_title",
|
||||||
|
sessionId: "session-1",
|
||||||
|
title: "自动标题",
|
||||||
|
});
|
||||||
|
onEvent({
|
||||||
|
type: "done",
|
||||||
|
sessionId: "session-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.switchSession("session-loaded");
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.renameSession("session-loaded", "手动标题");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(updateChatSessionTitle).toHaveBeenCalled());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendPrompt("帮我分析一下");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.sessionTitle).toBe("手动标题");
|
||||||
|
expect(updateChatSessionTitle).not.toHaveBeenCalledWith(
|
||||||
|
"session-loaded",
|
||||||
|
"自动标题",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply a late generated title to a newly created session", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
||||||
|
let resolveStream: (() => void) | undefined;
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
emitStreamEvent = onEvent;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
resolveStream = resolve;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
void result.current.sendPrompt("帮我分析一下");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
emitStreamEvent?.({
|
||||||
|
type: "done",
|
||||||
|
sessionId: "old-session",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isStreaming).toBe(false));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.createSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.sessionTitle).toBe("新对话");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
emitStreamEvent?.({
|
||||||
|
type: "session_title",
|
||||||
|
sessionId: "old-session",
|
||||||
|
title: "旧请求标题",
|
||||||
|
});
|
||||||
|
resolveStream?.();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.sessionTitle).toBe("新对话");
|
||||||
|
expect(updateChatSessionTitle).toHaveBeenCalledWith(
|
||||||
|
"old-session",
|
||||||
|
"旧请求标题",
|
||||||
|
{ isTitleManuallyEdited: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forks a copied conversation from an assistant message", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendPrompt("第一轮");
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstAssistantMessageId = result.current.messages[1]?.id ?? "";
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.createBranch(firstAssistantMessageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(forkAgentChat).toHaveBeenCalledWith(undefined, 2);
|
||||||
|
expect(result.current.activeSessionId).toBe("forked-session");
|
||||||
|
expect(result.current.messages).toHaveLength(2);
|
||||||
|
expect(result.current.messages[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
role: "user",
|
||||||
|
content: "第一轮",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.current.messages[1]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
role: "assistant",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(streamAgentChat).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,780 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { useAgentChatSession } from "./useAgentChatSession";
|
||||||
|
import {
|
||||||
|
abortAgentChat,
|
||||||
|
forkAgentChat,
|
||||||
|
replyAgentPermission,
|
||||||
|
replyAgentQuestion,
|
||||||
|
resumeAgentChatStream,
|
||||||
|
streamAgentChat,
|
||||||
|
} from "@/lib/chatStream";
|
||||||
|
import type { StreamEvent } from "@/lib/chatStream";
|
||||||
|
|
||||||
|
jest.mock("@/lib/chatStream", () => ({
|
||||||
|
abortAgentChat: jest.fn(async () => undefined),
|
||||||
|
forkAgentChat: jest.fn(async () => "forked-session"),
|
||||||
|
replyAgentPermission: jest.fn(async () => undefined),
|
||||||
|
replyAgentQuestion: jest.fn(async () => undefined),
|
||||||
|
resumeAgentChatStream: jest.fn(async () => undefined),
|
||||||
|
streamAgentChat: jest.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const listChatSessions = jest.fn();
|
||||||
|
const deleteChatSession = jest.fn();
|
||||||
|
const updateChatSessionTitle = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("../chatStorage", () => ({
|
||||||
|
createEmptyChatState: jest.fn(() => ({
|
||||||
|
title: undefined,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: undefined,
|
||||||
|
})),
|
||||||
|
deleteChatSession: (...args: unknown[]) => deleteChatSession(...args),
|
||||||
|
listChatSessions: (...args: unknown[]) => listChatSessions(...args),
|
||||||
|
loadChatSessionById: jest.fn(async () => ({
|
||||||
|
title: "已存在会话",
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
})),
|
||||||
|
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useAgentChatSession", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
listChatSessions.mockReset();
|
||||||
|
deleteChatSession.mockReset();
|
||||||
|
updateChatSessionTitle.mockReset();
|
||||||
|
jest.mocked(abortAgentChat).mockReset();
|
||||||
|
jest.mocked(forkAgentChat).mockReset();
|
||||||
|
jest.mocked(replyAgentPermission).mockReset();
|
||||||
|
jest.mocked(replyAgentQuestion).mockReset();
|
||||||
|
jest.mocked(resumeAgentChatStream).mockReset();
|
||||||
|
jest.mocked(streamAgentChat).mockReset();
|
||||||
|
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
||||||
|
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
||||||
|
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
||||||
|
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
|
||||||
|
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||||
|
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
||||||
|
deleteChatSession.mockImplementation(async () => undefined);
|
||||||
|
updateChatSessionTitle.mockImplementation(async () => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAgentChatSession 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("does not autosave full messages after the stream is done", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
emitStreamEvent?.({
|
||||||
|
type: "token",
|
||||||
|
sessionId: "chat-stream-1",
|
||||||
|
content: "收到",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
emitStreamEvent?.({
|
||||||
|
type: "done",
|
||||||
|
sessionId: "chat-stream-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages).toEqual([
|
||||||
|
expect.objectContaining({ role: "user", content: "第一条消息" }),
|
||||||
|
expect.objectContaining({ role: "assistant", content: "收到" }),
|
||||||
|
]);
|
||||||
|
expect(result.current.activeSessionId).toBe("chat-stream-1");
|
||||||
|
} finally {
|
||||||
|
jest.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
import type { AgentApprovalMode, AgentModel, StreamEvent } from "@/lib/chatStream";
|
||||||
|
import type { AgentArtifact, Message } from "../GlobalChatbox.types";
|
||||||
|
|
||||||
|
export type UseAgentChatSessionOptions = {
|
||||||
|
projectId?: string | null;
|
||||||
|
onToolCall: (
|
||||||
|
event: StreamEvent & { type: "tool_call" },
|
||||||
|
options: {
|
||||||
|
assistantMessageId: string;
|
||||||
|
appendArtifact: (messageId: string, artifact: AgentArtifact) => void;
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
|
onBeforeSend?: () => void;
|
||||||
|
getModel?: () => AgentModel | undefined;
|
||||||
|
getApprovalMode?: () => AgentApprovalMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PromptRunOptions = {
|
||||||
|
prompt: string;
|
||||||
|
sessionIdOverride?: string;
|
||||||
|
preparedMessages?: Message[];
|
||||||
|
userMessage?: Message;
|
||||||
|
assistantMessage?: Message;
|
||||||
|
};
|
||||||
@@ -5,6 +5,11 @@ import { useCallback } from "react";
|
|||||||
import { useChatToolStore, type ChatToolAction } from "@/store/chatToolStore";
|
import { useChatToolStore, type ChatToolAction } from "@/store/chatToolStore";
|
||||||
import type { StreamEvent } from "@/lib/chatStream";
|
import type { StreamEvent } from "@/lib/chatStream";
|
||||||
import type { AgentArtifact, AgentArtifactKind } from "../GlobalChatbox.types";
|
import type { AgentArtifact, AgentArtifactKind } from "../GlobalChatbox.types";
|
||||||
|
import {
|
||||||
|
APPLY_LAYER_STYLE_TOOL,
|
||||||
|
describeApplyLayerStyle,
|
||||||
|
parseApplyLayerStylePayload,
|
||||||
|
} from "../toolCallStyleHelpers";
|
||||||
|
|
||||||
type ToolCallEvent = StreamEvent & { type: "tool_call" };
|
type ToolCallEvent = StreamEvent & { type: "tool_call" };
|
||||||
|
|
||||||
@@ -143,6 +148,46 @@ const compactNames = (names: string[]) => {
|
|||||||
: names.join(", ");
|
: 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 = (
|
const buildLocateArtifact = (
|
||||||
tool: string,
|
tool: string,
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
@@ -185,6 +230,18 @@ const buildToolAction = (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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]) {
|
if (tool === "locate_features" || LOCATE_TOOL_CONFIG[tool]) {
|
||||||
const locate = buildLocateArtifact(tool, params);
|
const locate = buildLocateArtifact(tool, params);
|
||||||
return {
|
return {
|
||||||
@@ -248,6 +305,23 @@ const buildToolAction = (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
action: null,
|
action: null,
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
|
|||||||
@@ -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} · 应用样式`;
|
||||||
|
};
|
||||||
@@ -59,6 +59,23 @@ interface TimelineProps {
|
|||||||
schemeName?: string;
|
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> = ({
|
const Timeline: React.FC<TimelineProps> = ({
|
||||||
disableDateSelection = false,
|
disableDateSelection = false,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -445,7 +462,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<LocalizationProvider
|
||||||
dateAdapter={AdapterDayjs}
|
dateAdapter={AdapterDayjs}
|
||||||
adapterLocale="zh-cn"
|
adapterLocale="zh-cn"
|
||||||
@@ -481,6 +498,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
onClick={handleDayStepBackward}
|
onClick={handleDayStepBackward}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={disableDateSelection}
|
disabled={disableDateSelection}
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<FiSkipBack />
|
<FiSkipBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -517,6 +535,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
selectedDateTime.toDateString() ===
|
selectedDateTime.toDateString() ===
|
||||||
new Date().toDateString()
|
new Date().toDateString()
|
||||||
}
|
}
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<FiSkipForward />
|
<FiSkipForward />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -545,6 +564,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleStepBackward}
|
onClick={handleStepBackward}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<TbArrowBackUp />
|
<TbArrowBackUp />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -555,6 +575,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={isPlaying ? handlePause : handlePlay}
|
onClick={isPlaying ? handlePause : handlePlay}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause /> : <PlayArrow />}
|
{isPlaying ? <Pause /> : <PlayArrow />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -565,6 +586,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleStepForward}
|
onClick={handleStepForward}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<TbArrowForwardUp />
|
<TbArrowForwardUp />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -575,6 +597,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<Stop />
|
<Stop />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ const LayerControl: React.FC = () => {
|
|||||||
const deckLayers = data?.deckLayers ?? (deckLayer ? [deckLayer] : []);
|
const deckLayers = data?.deckLayers ?? (deckLayer ? [deckLayer] : []);
|
||||||
const isContourLayerAvailable = data?.isContourLayerAvailable;
|
const isContourLayerAvailable = data?.isContourLayerAvailable;
|
||||||
const isWaterflowLayerAvailable = data?.isWaterflowLayerAvailable;
|
const isWaterflowLayerAvailable = data?.isWaterflowLayerAvailable;
|
||||||
|
const showContourLayer = data?.showContourLayer;
|
||||||
|
const showWaterflowLayer = data?.showWaterflowLayer;
|
||||||
const setShowWaterflowLayer = data?.setShowWaterflowLayer;
|
const setShowWaterflowLayer = data?.setShowWaterflowLayer;
|
||||||
const setShowContourLayer = data?.setShowContourLayer;
|
const setShowContourLayer = data?.setShowContourLayer;
|
||||||
|
|
||||||
@@ -46,6 +48,14 @@ const LayerControl: React.FC = () => {
|
|||||||
if (!map || !data) return [];
|
if (!map || !data) return [];
|
||||||
|
|
||||||
const items: LayerItem[] = [];
|
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) => {
|
map.getLayers().getArray().forEach((layer) => {
|
||||||
if (
|
if (
|
||||||
@@ -56,7 +66,7 @@ const LayerControl: React.FC = () => {
|
|||||||
const value = layer.get("value");
|
const value = layer.get("value");
|
||||||
const name = layer.get("name");
|
const name = layer.get("name");
|
||||||
if (value) {
|
if (value) {
|
||||||
items.push({
|
upsertLayerItem({
|
||||||
id: value,
|
id: value,
|
||||||
name: name || value,
|
name: name || value,
|
||||||
visible: layer.getVisible(),
|
visible: layer.getVisible(),
|
||||||
@@ -80,7 +90,7 @@ const LayerControl: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push({
|
upsertLayerItem({
|
||||||
id: layer.props.id,
|
id: layer.props.id,
|
||||||
name: layer.props.name,
|
name: layer.props.name,
|
||||||
visible:
|
visible:
|
||||||
@@ -91,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
|
return items
|
||||||
.filter((item) => LAYER_ORDER.includes(item.id))
|
.filter((item) => LAYER_ORDER.includes(item.id))
|
||||||
.sort((a, b) => LAYER_ORDER.indexOf(a.id) - LAYER_ORDER.indexOf(b.id));
|
.sort((a, b) => LAYER_ORDER.indexOf(a.id) - LAYER_ORDER.indexOf(b.id));
|
||||||
@@ -100,6 +134,8 @@ const LayerControl: React.FC = () => {
|
|||||||
deckLayer,
|
deckLayer,
|
||||||
isContourLayerAvailable,
|
isContourLayerAvailable,
|
||||||
isWaterflowLayerAvailable,
|
isWaterflowLayerAvailable,
|
||||||
|
showContourLayer,
|
||||||
|
showWaterflowLayer,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -126,7 +162,7 @@ const LayerControl: React.FC = () => {
|
|||||||
.filter((layer) => layer.get("value") === item.id)
|
.filter((layer) => layer.get("value") === item.id)
|
||||||
.forEach((layer) => layer.setVisible(checked));
|
.forEach((layer) => layer.setVisible(checked));
|
||||||
});
|
});
|
||||||
} else if (item.type === "deck" && deckLayers.length > 0) {
|
} else if (item.type === "deck") {
|
||||||
deckLayers.forEach((targetDeckLayer) => {
|
deckLayers.forEach((targetDeckLayer) => {
|
||||||
targetDeckLayer.setDeckLayerVisible(item.id, checked);
|
targetDeckLayer.setDeckLayerVisible(item.id, checked);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -39,6 +39,23 @@ interface TimelineProps {
|
|||||||
schemeType?: string;
|
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_CURRENT_TIME = (_: any) => undefined;
|
||||||
const NOOP_SET_SELECTED_DATE = (_: any) => undefined;
|
const NOOP_SET_SELECTED_DATE = (_: any) => undefined;
|
||||||
|
|
||||||
@@ -68,6 +85,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
const isCompareMode = data?.isCompareMode ?? false;
|
const isCompareMode = data?.isCompareMode ?? false;
|
||||||
const junctionText = data?.junctionText ?? "";
|
const junctionText = data?.junctionText ?? "";
|
||||||
const pipeText = data?.pipeText ?? "";
|
const pipeText = data?.pipeText ?? "";
|
||||||
|
const setForceStyleAutoApplyVersion = data?.setForceStyleAutoApplyVersion;
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒
|
const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒
|
||||||
@@ -605,7 +623,10 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
// 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变
|
// 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变
|
||||||
const calculationDate = selectedDate;
|
const calculationDate = selectedDate;
|
||||||
const calculationTime = currentTime;
|
const calculationTime = currentTime;
|
||||||
const calculationDateStr = calculationDate.toISOString().split("T")[0];
|
const calculationDateTime = currentTimeToDate(
|
||||||
|
calculationDate,
|
||||||
|
calculationTime
|
||||||
|
);
|
||||||
|
|
||||||
setIsCalculating(true);
|
setIsCalculating(true);
|
||||||
// 显示处理中的通知
|
// 显示处理中的通知
|
||||||
@@ -617,8 +638,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
name: NETWORK_NAME,
|
name: NETWORK_NAME,
|
||||||
simulation_date: calculationDateStr, // YYYY-MM-DD
|
start_time: dayjs(calculationDateTime).format("YYYY-MM-DDTHH:mm:ssZ"),
|
||||||
start_time: `${formatTime(calculationTime)}:00`, // HH:MM:00
|
|
||||||
duration: calculatedInterval,
|
duration: calculatedInterval,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -633,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?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "重新计算成功",
|
message: "重新计算成功",
|
||||||
});
|
});
|
||||||
// 清空当天当前时刻及之后的缓存并重新获取数据
|
// 清空当天当前时刻及之后的缓存并重新获取数据
|
||||||
clearCacheAndRefetch(calculationDate, calculationTime);
|
clearCacheAndRefetch(calculationDate, calculationTime);
|
||||||
|
setForceStyleAutoApplyVersion?.((prev) => prev + 1);
|
||||||
} else {
|
} else {
|
||||||
|
const errorMessage =
|
||||||
|
result?.detail || result?.message || "重新计算失败";
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "重新计算失败",
|
message: errorMessage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -665,7 +690,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
<Draggable nodeRef={draggableRef} handle=".drag-handle">
|
<Draggable nodeRef={draggableRef} handle=".drag-handle">
|
||||||
<div
|
<div
|
||||||
ref={draggableRef}
|
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">
|
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
||||||
<Paper
|
<Paper
|
||||||
@@ -723,6 +748,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
onClick={handleDayStepBackward}
|
onClick={handleDayStepBackward}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={disableDateSelection}
|
disabled={disableDateSelection}
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<FiSkipBack />
|
<FiSkipBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -757,6 +783,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
selectedDate.toDateString() ===
|
selectedDate.toDateString() ===
|
||||||
new Date().toDateString()
|
new Date().toDateString()
|
||||||
}
|
}
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<FiSkipForward />
|
<FiSkipForward />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -785,6 +812,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleStepBackward}
|
onClick={handleStepBackward}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<TbRewindBackward15 />
|
<TbRewindBackward15 />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -795,6 +823,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={isPlaying ? handlePause : handlePlay}
|
onClick={isPlaying ? handlePause : handlePlay}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause /> : <PlayArrow />}
|
{isPlaying ? <Pause /> : <PlayArrow />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -805,6 +834,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleStepForward}
|
onClick={handleStepForward}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<TbRewindForward15 />
|
<TbRewindForward15 />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -815,6 +845,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<Stop />
|
<Stop />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import VectorLayer from "ol/layer/Vector";
|
|||||||
import { Style, Stroke, Fill, Circle } from "ol/style";
|
import { Style, Stroke, Fill, Circle } from "ol/style";
|
||||||
import Feature from "ol/Feature";
|
import Feature from "ol/Feature";
|
||||||
import StyleEditorPanel from "./StyleEditorPanel";
|
import StyleEditorPanel from "./StyleEditorPanel";
|
||||||
import { LayerStyleState } from "./StyleEditorPanel";
|
import { createDefaultLayerStyleStates } from "./styleEditorPresets";
|
||||||
|
import { LayerStyleState } from "./styleEditorTypes";
|
||||||
import StyleLegend from "./StyleLegend"; // 引入图例组件
|
import StyleLegend from "./StyleLegend"; // 引入图例组件
|
||||||
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
|
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
buildFeatureProperties,
|
buildFeatureProperties,
|
||||||
} from "./toolbarFeatureHelpers";
|
} from "./toolbarFeatureHelpers";
|
||||||
import { useToolbarChatActions } from "./useToolbarChatActions";
|
import { useToolbarChatActions } from "./useToolbarChatActions";
|
||||||
|
import { useStyleEditor } from "./useStyleEditor";
|
||||||
|
|
||||||
import { config } from "@/config/config";
|
import { config } from "@/config/config";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
@@ -80,92 +82,27 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
endTime?: string;
|
endTime?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// 样式状态管理 - 在 Toolbar 中管理,带有默认样式
|
||||||
|
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>(
|
||||||
|
() => createDefaultLayerStyleStates()
|
||||||
|
);
|
||||||
|
const styleEditor = useStyleEditor({
|
||||||
|
layerStyleStates,
|
||||||
|
setLayerStyleStates,
|
||||||
|
});
|
||||||
|
|
||||||
useToolbarChatActions({
|
useToolbarChatActions({
|
||||||
setHighlightFeatures,
|
setHighlightFeatures,
|
||||||
setChatPanelFeatureInfos,
|
setChatPanelFeatureInfos,
|
||||||
setChatPanelType,
|
setChatPanelType,
|
||||||
setChatPanelTimeRange,
|
setChatPanelTimeRange,
|
||||||
setShowHistoryPanel,
|
setShowHistoryPanel,
|
||||||
|
setShowStyleEditor,
|
||||||
setActiveTools,
|
setActiveTools,
|
||||||
|
applyExternalStyle: styleEditor.applyExternalStyle,
|
||||||
|
resetExternalStyle: styleEditor.resetExternalStyle,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 样式状态管理 - 在 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 activeLegendConfigs = layerStyleStates
|
const activeLegendConfigs = layerStyleStates
|
||||||
.filter((state) => state.isActive && state.legendConfig.property)
|
.filter((state) => state.isActive && state.legendConfig.property)
|
||||||
@@ -515,8 +452,21 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
{showDrawPanel && map && <DrawPanel />}
|
{showDrawPanel && map && <DrawPanel />}
|
||||||
<div style={{ display: showStyleEditor ? "block" : "none" }}>
|
<div style={{ display: showStyleEditor ? "block" : "none" }}>
|
||||||
<StyleEditorPanel
|
<StyleEditorPanel
|
||||||
layerStyleStates={layerStyleStates}
|
isReady={styleEditor.isReady}
|
||||||
setLayerStyleStates={setLayerStyleStates}
|
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>
|
</div>
|
||||||
<ToolbarHistoryPanel
|
<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;
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } fr
|
|||||||
import Feature from "ol/Feature";
|
import Feature from "ol/Feature";
|
||||||
import { GeoJSON } from "ol/format";
|
import { GeoJSON } from "ol/format";
|
||||||
import Point from "ol/geom/Point";
|
import Point from "ol/geom/Point";
|
||||||
|
import { transform } from "ol/proj";
|
||||||
import { bbox, featureCollection } from "@turf/turf";
|
import { bbox, featureCollection } from "@turf/turf";
|
||||||
|
|
||||||
import { useChatToolActionHandler } from "@/hooks/useChatToolActionHandler";
|
import { useChatToolActionHandler } from "@/hooks/useChatToolActionHandler";
|
||||||
@@ -13,6 +14,7 @@ import { apiFetch } from "@/lib/apiFetch";
|
|||||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
import { config } from "@/config/config";
|
import { config } from "@/config/config";
|
||||||
import { useMap } from "../MapComponent";
|
import { useMap } from "../MapComponent";
|
||||||
|
import type { DefaultLayerStyleId, StyleConfig } from "./styleEditorTypes";
|
||||||
|
|
||||||
type UseToolbarChatActionsParams = {
|
type UseToolbarChatActionsParams = {
|
||||||
setHighlightFeatures: Dispatch<SetStateAction<Feature[]>>;
|
setHighlightFeatures: Dispatch<SetStateAction<Feature[]>>;
|
||||||
@@ -22,7 +24,13 @@ type UseToolbarChatActionsParams = {
|
|||||||
SetStateAction<{ startTime?: string; endTime?: string } | null>
|
SetStateAction<{ startTime?: string; endTime?: string } | null>
|
||||||
>;
|
>;
|
||||||
setShowHistoryPanel: Dispatch<SetStateAction<boolean>>;
|
setShowHistoryPanel: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setShowStyleEditor: Dispatch<SetStateAction<boolean>>;
|
||||||
setActiveTools: Dispatch<SetStateAction<string[]>>;
|
setActiveTools: Dispatch<SetStateAction<string[]>>;
|
||||||
|
applyExternalStyle: (
|
||||||
|
layerId: DefaultLayerStyleId,
|
||||||
|
styleConfig?: Partial<StyleConfig>
|
||||||
|
) => void;
|
||||||
|
resetExternalStyle: (layerId: DefaultLayerStyleId) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useToolbarChatActions = ({
|
export const useToolbarChatActions = ({
|
||||||
@@ -31,7 +39,10 @@ export const useToolbarChatActions = ({
|
|||||||
setChatPanelType,
|
setChatPanelType,
|
||||||
setChatPanelTimeRange,
|
setChatPanelTimeRange,
|
||||||
setShowHistoryPanel,
|
setShowHistoryPanel,
|
||||||
|
setShowStyleEditor,
|
||||||
setActiveTools,
|
setActiveTools,
|
||||||
|
applyExternalStyle,
|
||||||
|
resetExternalStyle,
|
||||||
}: UseToolbarChatActionsParams) => {
|
}: UseToolbarChatActionsParams) => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const chatJunctionRenderCleanupRef = useRef<(() => void) | null>(null);
|
const chatJunctionRenderCleanupRef = useRef<(() => void) | null>(null);
|
||||||
@@ -100,6 +111,18 @@ export const useToolbarChatActions = ({
|
|||||||
locateFeatures(action.ids, action.layer, action.geometryKind);
|
locateFeatures(action.ids, action.layer, action.geometryKind);
|
||||||
break;
|
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": {
|
case "view_history": {
|
||||||
setChatPanelFeatureInfos(action.featureInfos);
|
setChatPanelFeatureInfos(action.featureInfos);
|
||||||
setChatPanelType(action.dataType);
|
setChatPanelType(action.dataType);
|
||||||
@@ -206,17 +229,30 @@ export const useToolbarChatActions = ({
|
|||||||
})();
|
})();
|
||||||
break;
|
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,
|
disposeChatJunctionRender,
|
||||||
map,
|
map,
|
||||||
|
resetExternalStyle,
|
||||||
setActiveTools,
|
setActiveTools,
|
||||||
setChatPanelFeatureInfos,
|
setChatPanelFeatureInfos,
|
||||||
setChatPanelTimeRange,
|
setChatPanelTimeRange,
|
||||||
setChatPanelType,
|
setChatPanelType,
|
||||||
setHighlightFeatures,
|
setHighlightFeatures,
|
||||||
setShowHistoryPanel,
|
setShowHistoryPanel,
|
||||||
|
setShowStyleEditor,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -65,8 +65,10 @@ interface DataContextType {
|
|||||||
setShowPipeTextLayer?: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowPipeTextLayer?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setShowJunctionId?: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowJunctionId?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setShowPipeId?: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowPipeId?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
showContourLayer?: boolean;
|
||||||
setShowContourLayer?: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowContourLayer?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
isContourLayerAvailable?: boolean;
|
isContourLayerAvailable?: boolean;
|
||||||
|
showWaterflowLayer?: boolean;
|
||||||
setShowWaterflowLayer?: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowWaterflowLayer?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setContourLayerAvailable?: React.Dispatch<React.SetStateAction<boolean>>;
|
setContourLayerAvailable?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
isWaterflowLayerAvailable?: boolean;
|
isWaterflowLayerAvailable?: boolean;
|
||||||
@@ -83,6 +85,8 @@ interface DataContextType {
|
|||||||
maps?: OlMap[];
|
maps?: OlMap[];
|
||||||
diameterRange?: [number, number];
|
diameterRange?: [number, number];
|
||||||
elevationRange?: [number, number];
|
elevationRange?: [number, number];
|
||||||
|
forceStyleAutoApplyVersion?: number;
|
||||||
|
setForceStyleAutoApplyVersion?: React.Dispatch<React.SetStateAction<number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跨组件传递
|
// 跨组件传递
|
||||||
@@ -182,7 +186,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
const [showPipeId, setShowPipeId] = useState(false); // 控制管道ID显示
|
const [showPipeId, setShowPipeId] = useState(false); // 控制管道ID显示
|
||||||
const [showContourLayer, setShowContourLayer] = useState(false); // 控制等高线图层显示
|
const [showContourLayer, setShowContourLayer] = useState(false); // 控制等高线图层显示
|
||||||
const [junctionText, setJunctionText] = useState("pressure");
|
const [junctionText, setJunctionText] = useState("pressure");
|
||||||
const [pipeText, setPipeText] = useState("flow");
|
const [pipeText, setPipeText] = useState("velocity");
|
||||||
const [contours, setContours] = useState<any[]>([]);
|
const [contours, setContours] = useState<any[]>([]);
|
||||||
const flowAnimation = useRef(false); // 添加动画控制标志
|
const flowAnimation = useRef(false); // 添加动画控制标志
|
||||||
const [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示
|
const [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示
|
||||||
@@ -261,6 +265,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
const [elevationRange, setElevationRange] = useState<
|
const [elevationRange, setElevationRange] = useState<
|
||||||
[number, number] | undefined
|
[number, number] | undefined
|
||||||
>();
|
>();
|
||||||
|
const [forceStyleAutoApplyVersion, setForceStyleAutoApplyVersion] =
|
||||||
|
useState(0);
|
||||||
|
|
||||||
const toggleCompareMode = useCallback(() => {
|
const toggleCompareMode = useCallback(() => {
|
||||||
setCompareMode((prev) => !prev);
|
setCompareMode((prev) => !prev);
|
||||||
@@ -1504,8 +1510,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
setShowPipeId,
|
setShowPipeId,
|
||||||
showJunctionId,
|
showJunctionId,
|
||||||
showPipeId,
|
showPipeId,
|
||||||
|
showContourLayer,
|
||||||
setShowContourLayer,
|
setShowContourLayer,
|
||||||
isContourLayerAvailable,
|
isContourLayerAvailable,
|
||||||
|
showWaterflowLayer,
|
||||||
setContourLayerAvailable,
|
setContourLayerAvailable,
|
||||||
isWaterflowLayerAvailable,
|
isWaterflowLayerAvailable,
|
||||||
setWaterflowLayerAvailable,
|
setWaterflowLayerAvailable,
|
||||||
@@ -1522,6 +1530,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
maps,
|
maps,
|
||||||
diameterRange,
|
diameterRange,
|
||||||
elevationRange,
|
elevationRange,
|
||||||
|
forceStyleAutoApplyVersion,
|
||||||
|
setForceStyleAutoApplyVersion,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MapContext.Provider value={map}>
|
<MapContext.Provider value={map}>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { fetchAgentModels } from "./chatModels";
|
||||||
|
|
||||||
|
const apiFetch = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("@/lib/apiFetch", () => ({
|
||||||
|
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("fetchAgentModels", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads model options and backend default model", async () => {
|
||||||
|
apiFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
default_model: "deepseek/deepseek-v4-flash",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "deepseek/deepseek-v4-flash",
|
||||||
|
label: "快速",
|
||||||
|
description: "快速回答和任务执行",
|
||||||
|
icon: "bolt",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fetchAgentModels()).resolves.toEqual({
|
||||||
|
defaultModel: "deepseek/deepseek-v4-flash",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "deepseek/deepseek-v4-flash",
|
||||||
|
label: "快速",
|
||||||
|
description: "快速回答和任务执行",
|
||||||
|
icon: "bolt",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(apiFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/api/v1/agent/chat/models"),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "GET",
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first option when default model is omitted", async () => {
|
||||||
|
apiFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
models: [{ id: "provider/model", label: "Model" }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fetchAgentModels()).resolves.toEqual({
|
||||||
|
defaultModel: "provider/model",
|
||||||
|
models: [{ id: "provider/model", label: "Model" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { config } from "@config/config";
|
||||||
|
|
||||||
|
import type { AgentModel } from "./chatStream";
|
||||||
|
|
||||||
|
export type AgentModelIcon = "bolt" | "sparkle";
|
||||||
|
|
||||||
|
export type AgentModelOption = {
|
||||||
|
id: AgentModel;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: AgentModelIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentModelConfig = {
|
||||||
|
defaultModel?: AgentModel;
|
||||||
|
models: AgentModelOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
const normalizeModelOption = (value: unknown): AgentModelOption | null => {
|
||||||
|
if (!isObjectRecord(value) || typeof value.id !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const id = value.id.trim();
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const label =
|
||||||
|
typeof value.label === "string" && value.label.trim()
|
||||||
|
? value.label.trim()
|
||||||
|
: id;
|
||||||
|
const description =
|
||||||
|
typeof value.description === "string" && value.description.trim()
|
||||||
|
? value.description.trim()
|
||||||
|
: undefined;
|
||||||
|
const icon =
|
||||||
|
value.icon === "bolt" || value.icon === "sparkle" ? value.icon : undefined;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchAgentModels = async (): Promise<AgentModelConfig> => {
|
||||||
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/models`, {
|
||||||
|
method: "GET",
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
default_model?: unknown;
|
||||||
|
models?: unknown[];
|
||||||
|
};
|
||||||
|
const models = (payload.models ?? [])
|
||||||
|
.map(normalizeModelOption)
|
||||||
|
.filter((model): model is AgentModelOption => Boolean(model));
|
||||||
|
const defaultModel =
|
||||||
|
typeof payload.default_model === "string" && payload.default_model.trim()
|
||||||
|
? payload.default_model.trim()
|
||||||
|
: models[0]?.id;
|
||||||
|
return {
|
||||||
|
defaultModel,
|
||||||
|
models,
|
||||||
|
};
|
||||||
|
};
|
||||||
+219
-11
@@ -1,4 +1,13 @@
|
|||||||
import { abortAgentChat, forkAgentChat, streamAgentChat } from "./chatStream";
|
import {
|
||||||
|
abortAgentChat,
|
||||||
|
forkAgentChat,
|
||||||
|
rejectAgentQuestion,
|
||||||
|
replyAgentPermission,
|
||||||
|
replyAgentQuestion,
|
||||||
|
type StreamEvent,
|
||||||
|
resumeAgentChatStream,
|
||||||
|
streamAgentChat,
|
||||||
|
} from "./chatStream";
|
||||||
import { ReadableStream } from "stream/web";
|
import { ReadableStream } from "stream/web";
|
||||||
import { TextEncoder, TextDecoder } from "util";
|
import { TextEncoder, TextDecoder } from "util";
|
||||||
|
|
||||||
@@ -51,7 +60,7 @@ describe("streamAgentChat", () => {
|
|||||||
|
|
||||||
await streamAgentChat({
|
await streamAgentChat({
|
||||||
message: "hi",
|
message: "hi",
|
||||||
model: "deepseek/deepseek-v4-pro",
|
model: "provider/model",
|
||||||
onEvent: (event) => events.push(event),
|
onEvent: (event) => events.push(event),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,7 +73,8 @@ describe("streamAgentChat", () => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: "hi",
|
message: "hi",
|
||||||
session_id: undefined,
|
session_id: undefined,
|
||||||
model: "deepseek/deepseek-v4-pro",
|
model: "provider/model",
|
||||||
|
approval_mode: undefined,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -76,6 +86,51 @@ describe("streamAgentChat", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("parses progress events", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
apiFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -103,21 +158,16 @@ describe("streamAgentChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses legacy tool_call arguments when params is empty", async () => {
|
it("parses tool_call arguments when params is empty", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
apiFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
body: makeStream([
|
body: makeStream([
|
||||||
'event: tool_call\ndata: {"conversationId":"agent-1e75dd01-29e","tool":"locate_features","params":{},"arguments":"{\\"ids\\":[\\"142902\\"],\\"feature_type\\":\\"junction\\"}"}\n\n',
|
'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',
|
'event: done\ndata: {"session_id":"agent-1e75dd01-29e"}\n\n',
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const events: Array<{
|
const events: StreamEvent[] = [];
|
||||||
type: string;
|
|
||||||
sessionId?: string;
|
|
||||||
tool?: string;
|
|
||||||
params?: Record<string, unknown>;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
await streamAgentChat({
|
await streamAgentChat({
|
||||||
message: "hi",
|
message: "hi",
|
||||||
@@ -132,6 +182,106 @@ describe("streamAgentChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("emits error when response is not ok", async () => {
|
it("emits error when response is not ok", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
apiFetch.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -205,6 +355,64 @@ describe("streamAgentChat", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("calls fork endpoint and returns new session id", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
apiFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
+496
-94
@@ -1,11 +1,66 @@
|
|||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
|
|
||||||
export type AgentModel =
|
export type AgentModel = string;
|
||||||
| "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 =
|
export type StreamEvent =
|
||||||
|
| {
|
||||||
|
type: "state";
|
||||||
|
sessionId: string;
|
||||||
|
messages: unknown[];
|
||||||
|
isStreaming: boolean;
|
||||||
|
runStatus?: string;
|
||||||
|
}
|
||||||
| { type: "token"; sessionId: string; content: string }
|
| { type: "token"; sessionId: string; content: string }
|
||||||
| { type: "done"; sessionId: string; totalDurationMs?: number }
|
| { type: "done"; sessionId: string; totalDurationMs?: number }
|
||||||
| { type: "session_title"; sessionId: string; title: string }
|
| { type: "session_title"; sessionId: string; title: string }
|
||||||
@@ -34,12 +89,61 @@ export type StreamEvent =
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
tool: string;
|
tool: string;
|
||||||
params: Record<string, unknown>;
|
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 = {
|
type StreamOptions = {
|
||||||
message: string;
|
message: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
model?: AgentModel;
|
model?: AgentModel;
|
||||||
|
approvalMode?: AgentApprovalMode;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onEvent: (event: StreamEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResumeStreamOptions = {
|
||||||
|
sessionId: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
onEvent: (event: StreamEvent) => void;
|
onEvent: (event: StreamEvent) => void;
|
||||||
};
|
};
|
||||||
@@ -87,10 +191,272 @@ const resolveToolParams = (
|
|||||||
return isObjectRecord(params) ? params : {};
|
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 ({
|
export const streamAgentChat = async ({
|
||||||
message,
|
message,
|
||||||
sessionId,
|
sessionId,
|
||||||
model,
|
model,
|
||||||
|
approvalMode,
|
||||||
signal,
|
signal,
|
||||||
onEvent,
|
onEvent,
|
||||||
}: StreamOptions) => {
|
}: StreamOptions) => {
|
||||||
@@ -109,6 +475,7 @@ export const streamAgentChat = async ({
|
|||||||
message,
|
message,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
model,
|
model,
|
||||||
|
approval_mode: approvalMode,
|
||||||
}),
|
}),
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
userHeaderMode: "include",
|
||||||
@@ -144,99 +511,52 @@ export const streamAgentChat = async ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
await readStreamEvents(response, onEvent);
|
||||||
const decoder = new TextDecoder("utf-8");
|
};
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
while (true) {
|
export const resumeAgentChatStream = async ({
|
||||||
const { done, value } = await reader.read();
|
sessionId,
|
||||||
if (done) break;
|
signal,
|
||||||
|
onEvent,
|
||||||
buffer += decoder.decode(value, { stream: true });
|
}: ResumeStreamOptions) => {
|
||||||
const blocks = buffer.split("\n\n");
|
let response: Response;
|
||||||
buffer = blocks.pop() ?? "";
|
try {
|
||||||
|
response = await apiFetch(
|
||||||
for (const block of blocks) {
|
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/stream`,
|
||||||
const { event, data } = parseEventBlock(block);
|
{
|
||||||
if (!event || !data) continue;
|
method: "GET",
|
||||||
|
signal,
|
||||||
try {
|
headers: {
|
||||||
const parsed = JSON.parse(data) as {
|
Accept: "text/event-stream",
|
||||||
session_id?: string;
|
},
|
||||||
conversationId?: string;
|
projectHeaderMode: "include",
|
||||||
content?: string;
|
userHeaderMode: "include",
|
||||||
message?: string;
|
skipAuthRedirect: true,
|
||||||
detail?: string;
|
},
|
||||||
tool?: string;
|
);
|
||||||
params?: Record<string, unknown>;
|
} catch (error) {
|
||||||
arguments?: unknown;
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
id?: string;
|
onEvent({
|
||||||
phase?: string;
|
type: "error",
|
||||||
status?: "running" | "completed" | "error";
|
sessionId,
|
||||||
title?: string;
|
message: "network request failed",
|
||||||
started_at?: number;
|
detail,
|
||||||
ended_at?: number;
|
});
|
||||||
elapsed_ms?: number;
|
return;
|
||||||
duration_ms?: number;
|
|
||||||
total_duration_ms?: number;
|
|
||||||
};
|
|
||||||
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 ?? parsed.conversationId ?? "",
|
|
||||||
tool: parsed.tool ?? "",
|
|
||||||
params: resolveToolParams(parsed.params, parsed.arguments),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
onEvent({
|
|
||||||
type: "error",
|
|
||||||
message: "invalid SSE data payload",
|
|
||||||
detail: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
const detail = await response.text();
|
||||||
|
onEvent({
|
||||||
|
type: "error",
|
||||||
|
sessionId,
|
||||||
|
message: "stream request failed",
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await readStreamEvents(response, onEvent);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const abortAgentChat = async (sessionId?: string) => {
|
export const abortAgentChat = async (sessionId?: string) => {
|
||||||
@@ -263,6 +583,88 @@ export const abortAgentChat = async (sessionId?: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) => {
|
export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => {
|
||||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/fork`, {
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/fork`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
import type { DefaultLayerStyleId, StyleConfig } from "@components/olmap/core/Controls/styleEditorTypes";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Chat Tool Action Store */
|
/* Chat Tool Action Store */
|
||||||
/* Decouples chat tool calls from map/panel execution. */
|
/* Decouples chat tool calls from map/panel execution. */
|
||||||
@@ -13,6 +15,13 @@ export type ChatToolAction =
|
|||||||
layer: string;
|
layer: string;
|
||||||
geometryKind: "point" | "line";
|
geometryKind: "point" | "line";
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "zoom_to_map";
|
||||||
|
coordinate: [number, number];
|
||||||
|
sourceCrs?: "EPSG:3857" | "EPSG:4326";
|
||||||
|
zoom?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "view_history";
|
type: "view_history";
|
||||||
featureInfos: [string, string][];
|
featureInfos: [string, string][];
|
||||||
@@ -39,6 +48,12 @@ export type ChatToolAction =
|
|||||||
type: "render_junctions";
|
type: "render_junctions";
|
||||||
renderRef: string;
|
renderRef: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "apply_layer_style";
|
||||||
|
layerId: DefaultLayerStyleId;
|
||||||
|
resetToDefault: boolean;
|
||||||
|
styleConfig?: Partial<StyleConfig>;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ChatToolState {
|
interface ChatToolState {
|
||||||
|
|||||||
Reference in New Issue
Block a user