21 Commits

Author SHA1 Message Date
jiang d80a071987 删除 copilot 自述文件
Build Push and Deploy / docker-image (push) Successful in 13s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-09 18:24:37 +08:00
jiang 216c7b1ab9 docs: add repository guidelines 2026-06-09 18:18:22 +08:00
jiang 7d966a5e91 feat(map): add coordinate zoom action 2026-06-09 17:55:17 +08:00
jiang 22afdbf2e8 fix(chat): 移除旧代码设计
Build Push and Deploy / docker-image (push) Successful in 3m42s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 20:25:48 +08:00
jiang ed9828befe fix(chat): hide actions while streaming
Build Push and Deploy / deploy-fallback-log (push) Has been cancelled
Build Push and Deploy / docker-image (push) Has been cancelled
2026-06-08 20:16:58 +08:00
jiang 968d798a2a fix(chat): hide raw permission metadata
Build Push and Deploy / docker-image (push) Failing after 42s
Build Push and Deploy / deploy-fallback-log (push) Successful in 0s
2026-06-08 20:12:08 +08:00
jiang 7da0ed0e39 fix(chat): mark aborted permissions
Build Push and Deploy / docker-image (push) Successful in 1m1s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 19:54:25 +08:00
jiang 166b45e529 fix(chat): normalize loaded messages
Build Push and Deploy / docker-image (push) Successful in 1m34s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 19:47:13 +08:00
jiang e5f13c3d46 fix(chat): remove regenerate action
Build Push and Deploy / docker-image (push) Successful in 1m7s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 19:33:06 +08:00
jiang 36cdb1df8d refactor(chat): split oversized chat modules 2026-06-08 19:23:46 +08:00
jiang 865e425748 feat(chat): refine shared todo card 2026-06-08 19:14:30 +08:00
jiang 3a36c693cd fix(chat): update question abort state 2026-06-08 18:39:45 +08:00
jiang b23cb6acdd fix(chat): wire question and todo cards 2026-06-08 18:10:28 +08:00
jiang 2691f42581 refactor: simplify chat fork flow
Build Push and Deploy / docker-image (push) Successful in 1m29s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 16:07:39 +08:00
jiang 34fd5bfb1a fix(chat): guard generated title events 2026-06-08 15:13:21 +08:00
jiang 40cc355fff fix(chat): 重新生成前撤销旧消息
Build Push and Deploy / docker-image (push) Successful in 1m47s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 14:38:52 +08:00
jiang f7cd5ebfa7 feat(chat): 添加权限批准模式切换 2026-06-08 14:14:52 +08:00
jiang d31565d52c fix(chat): 优化权限请求折叠状态
Build Push and Deploy / docker-image (push) Successful in 2m28s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 13:44:23 +08:00
jiang e32823e4b5 feat: add permission request UI
Build Push and Deploy / docker-image (push) Successful in 1m2s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 13:32:50 +08:00
jiang 5fc1812d53 fix(chat): 修复 abort 后 progress 仍显示工作中的问题
Build Push and Deploy / docker-image (push) Successful in 7s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-05 13:08:56 +08:00
jiang 709b029c4e fix(chat):建立连接前进行 token 有效性验证 2026-06-05 13:06:20 +08:00
28 changed files with 4654 additions and 1255 deletions
-60
View File
@@ -1,60 +0,0 @@
# Copilot Instructions for TJWaterFrontend_Refine
## Environment Setup
1. **Node.js**: Ensure you have Node.js v18 or later installed.
2. **Dependencies**: Run `npm install` to install all project dependencies.
3. **Environment Variables**: Create a `.env.local` file in the root directory with
Using bash setup dependencies:
```bash
npm install
```
## Build, Test, and Lint
- **Dev Server**: `npm run dev` (Runs with increased memory limit: `--max_old_space_size=4096`)
- **Build**: `npm run build`
- **Lint**: `npm run lint` (ESLint)
- **Test**: `npm run test` (Jest)
- Run a specific test file: `npm run test -- <path/to/file>`
- Run a specific test case: `npm run test -- -t 'test name'`
## High-Level Architecture
- **Framework**: **Next.js 16 (App Router)** integrated with **Refine** (`@refinedev/core`).
- **Routing**:
- Routes are defined in `src/app`.
- Refine resources (e.g., `/network-simulation`, `/hydraulic-simulation/*`) map directly to these routes.
- Configuration is central in `src/app/_refine_context.tsx`.
- **State Management**:
- **Global App State**: **Zustand** (`src/store`).
- **Server State**: Managed by Refine hooks (`useList`, `useOne`, etc.) via **React Query**.
- **Authentication**:
- **NextAuth.js** handling Keycloak integration.
- Session token is synced to Zustand (`useAuthStore`) in `RefineContext`.
- **Data Layer**:
- Custom Data Provider: `src/providers/data-provider`.
- API Utilities: `src/lib/api.ts`, `src/lib/apiFetch.ts`.
- **UI & Styling**:
- **Material UI (MUI)**: Primary component library (`@mui/material`, `@refinedev/mui`).
- **Tailwind CSS v4**: Utility classes for layout and custom styling (`@tailwindcss/postcss`).
- **Mapping**: OpenLayers (`ol`), deck.gl, Turf.js.
- **Charts**: ECharts, MUI X Charts.
## Key Conventions
- **Refine Integration**:
- Use Refine hooks (`useTable`, `useForm`, `useNavigation`) for data-heavy components.
- Resources are defined in the `<Refine>` component in `src/app/_refine_context.tsx`.
- **Project Structure**:
- `src/components/`: Grouped by feature (e.g., `olmap`, `project`) or common UI elements.
- `src/lib/`: Utility functions and API helpers.
- `src/providers/`: Refine providers (data, etc.).
- **Imports**:
- Use absolute imports with `@/` alias (e.g., `@/components`, `@/store`, `@/lib`).
- _Note_: `@libs` alias in tsconfig points to non-existent `src/libs` folder; prefer `@/lib`.
- **Styling**:
- Prefer MUI components for standard UI elements.
- Use Tailwind utility classes for layout and custom overrides.
+41
View File
@@ -0,0 +1,41 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository is the TJWater web frontend built with Refine, Next.js, React, and MUI. Application source lives under the existing Next.js project folders. Reuse established page, component, provider, map, and chat patterns instead of adding parallel structures. Static assets and public files should remain in the existing asset/public locations. Build output (`.next/`), dependency folders, and local caches are generated and must not be edited by hand.
Deployment files are `Dockerfile`, `docker-compose.yml`, and `.gitea/workflows/package.yml`.
## Build, Test, and Development Commands
Use npm and Node 20 or newer:
```bash
npm install
npm run dev
npm run lint
npm test
npm run test:coverage
npm run build
npm run start
```
`npm run dev` starts the Refine/Next development server. `npm run lint` runs ESLint. `npm test` runs Jest. `npm run build` creates the production build.
## Coding Style & Naming Conventions
Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for components, `camelCase` for variables/functions, and descriptive feature-oriented filenames. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused.
## Testing Guidelines
Tests use Jest with React Testing Library. Name tests `*.test.ts` or `*.test.tsx` near the related code when possible. Add tests for user-visible behavior, state transitions, route guards, data transforms, and map/chat interactions. Run `npm test` or `npm run test:coverage` before larger PRs.
## Commit & Pull Request Guidelines
History uses Conventional Commit messages such as `feat(map): add coordinate zoom action` and `fix(chat): hide raw permission metadata`, with occasional Chinese summaries. Prefer `feat(scope):`, `fix(scope):`, or `refactor(scope):`.
PRs should include a UI/behavior summary, verification commands, screenshots for visual changes, and notes for changed environment variables or backend API expectations.
## Security & Configuration Tips
Do not commit `.env`, `.next/`, `node_modules/`, local caches, or private map/API tokens. Public build-time variables should be documented; sensitive values belong in Gitea secrets.
+98 -1
View File
@@ -26,7 +26,9 @@ 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 { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
export type AgentComposerHandle = { export type AgentComposerHandle = {
focus: () => void; focus: () => void;
@@ -48,6 +50,8 @@ type AgentComposerProps = {
onStopListening: () => void; onStopListening: () => void;
selectedModel: AgentModel; selectedModel: AgentModel;
onModelChange: (model: AgentModel) => void; onModelChange: (model: AgentModel) => void;
approvalMode: AgentApprovalMode;
onApprovalModeChange: (mode: AgentApprovalMode) => void;
}; };
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
@@ -62,6 +66,8 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
onStopListening, onStopListening,
selectedModel, selectedModel,
onModelChange, onModelChange,
approvalMode,
onApprovalModeChange,
}, ref) { }, ref) {
const theme = useTheme(); const theme = useTheme();
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null); const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
@@ -245,6 +251,97 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, 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">
@@ -0,0 +1,27 @@
"use client";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
export const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, "");
export const MarkdownBlock = ({ children }: { children: string }) => {
const handleCopy = React.useCallback((event: React.ClipboardEvent<HTMLDivElement>) => {
const selectedText = window.getSelection()?.toString();
if (!selectedText) return;
event.preventDefault();
event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText));
}, []);
return (
<div className={markdownStyles.markdown} onCopy={handleCopy}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
</div>
);
};
@@ -0,0 +1,605 @@
"use client";
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
Box,
Button,
Chip,
CircularProgress,
Collapse,
IconButton,
Stack,
Typography,
alpha,
useTheme,
} from "@mui/material";
import type { Theme } from "@mui/material/styles";
import TerminalRounded from "@mui/icons-material/TerminalRounded";
import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import BlockRounded from "@mui/icons-material/BlockRounded";
import PushPinRounded from "@mui/icons-material/PushPinRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
import type { PermissionReply } from "@/lib/chatStream";
import type { Message } from "./GlobalChatbox.types";
const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => {
if (permission.permission === "external_directory") return "访问工作区外目录";
if (permission.permission === "bash") return "执行终端命令";
if (permission.permission === "edit") return "修改文件内容";
return permission.permission || "工具权限请求";
};
const getPermissionPrimaryValue = (
permission: NonNullable<Message["permissions"]>[number],
) => {
if (typeof permission.target === "string" && permission.target.trim()) {
return permission.target.trim();
}
return permission.patterns[0] ?? permission.permission;
};
const PermissionIcon = ({
permission,
}: {
permission: NonNullable<Message["permissions"]>[number];
}) => {
if (permission.permission === "bash") {
return <TerminalRounded sx={{ fontSize: 22 }} />;
}
if (permission.permission === "external_directory") {
return <FolderOpenRounded sx={{ fontSize: 22 }} />;
}
return <VerifiedUserRounded sx={{ fontSize: 22 }} />;
};
const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[number]["status"]) => {
if (status === "approved_always") return "已始终允许";
if (status === "approved_once") return "已允许一次";
if (status === "rejected") return "已拒绝";
if (status === "aborted") return "已中断";
if (status === "error") return "提交失败";
if (status === "submitting") return "提交中";
return "等待确认";
};
const pendingPermissionColor = "#f9a825";
const approvedOncePermissionColor = "#00838f";
const getPermissionStatusColor = (
status: NonNullable<Message["permissions"]>[number]["status"],
theme: Theme,
) => {
if (status === "approved_once") return approvedOncePermissionColor;
if (status === "approved_always") return theme.palette.success.main;
if (status === "rejected" || status === "error") return theme.palette.error.main;
if (status === "aborted") return theme.palette.text.secondary;
return pendingPermissionColor;
};
const getPermissionStatusTextColor = (
status: NonNullable<Message["permissions"]>[number]["status"],
theme: Theme,
) => {
if (status === "approved_once") return "#006c78";
if (status === "approved_always") return theme.palette.success.dark;
if (status === "rejected" || status === "error") return theme.palette.error.main;
if (status === "aborted") return theme.palette.text.secondary;
return "#8a5a00";
};
const PermissionRequestCard = ({
permission,
isRunning,
onReply,
}: {
permission: NonNullable<Message["permissions"]>[number];
isRunning: boolean;
onReply: (requestId: string, reply: PermissionReply) => void;
}) => {
const theme = useTheme();
const isPending =
isRunning && (permission.status === "pending" || permission.status === "error");
const isSubmitting = isRunning && permission.status === "submitting";
const primaryValue = getPermissionPrimaryValue(permission);
const accentColor = getPermissionStatusColor(permission.status, theme);
const statusTextColor = getPermissionStatusTextColor(permission.status, theme);
const statusLabel = getPermissionStatusLabel(permission.status);
return (
<Box
sx={{
borderRadius: 3,
overflow: "hidden",
border: `1px solid ${alpha("#fff", 0.72)}`,
bgcolor: alpha("#fff", 0.5),
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
backdropFilter: "blur(20px)",
position: "relative",
"&::before": {
content: '""',
position: "absolute",
inset: "10px auto 10px 0",
width: 3,
borderRadius: "0 999px 999px 0",
bgcolor: accentColor,
},
}}
>
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{
px: 1.5,
py: 1.25,
pl: 1.75,
borderBottom: `1px solid ${alpha("#000", 0.05)}`,
}}
>
<Box
sx={{
width: 32,
height: 32,
borderRadius: "50%",
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: accentColor,
bgcolor: alpha(accentColor, 0.1),
border: `1px solid ${alpha(accentColor, 0.16)}`,
}}
>
<PermissionIcon permission={permission} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
{getPermissionTitle(permission)}
</Typography>
</Box>
<Chip
size="small"
label={statusLabel}
sx={{
height: 24,
fontSize: "0.7rem",
fontWeight: 800,
borderRadius: "12px",
bgcolor: alpha(accentColor, 0.12),
color: statusTextColor,
"& .MuiChip-label": { px: 1 },
}}
/>
</Stack>
<Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}>
<Box
sx={{
px: 1.25,
py: 1,
borderRadius: 2.5,
bgcolor: alpha("#000", 0.025),
border: `1px solid ${alpha("#000", 0.045)}`,
}}
>
<Typography variant="caption" color="text.secondary" fontWeight={800}>
</Typography>
<Typography
variant="body2"
color="text.primary"
fontFamily={permission.permission === "bash" ? "monospace" : undefined}
sx={{
mt: 0.25,
lineHeight: 1.55,
wordBreak: "break-word",
whiteSpace: "pre-wrap",
}}
>
{primaryValue}
</Typography>
</Box>
</Stack>
{permission.error ? (
<Box sx={{ px: 1.5, pb: isPending || isSubmitting ? 1 : 1.35, pl: 1.75 }}>
<Typography
variant="caption"
color="error.main"
sx={{
display: "block",
px: 1.25,
py: 0.75,
borderRadius: 2,
bgcolor: alpha(theme.palette.error.main, 0.06),
wordBreak: "break-word",
}}
>
{permission.error}
</Typography>
</Box>
) : null}
{isPending || isSubmitting ? (
<Stack
direction="row"
spacing={1}
flexWrap="wrap"
useFlexGap
sx={{ px: 1.5, pb: 1.35, pl: 1.75, pt: 0 }}
>
<Button
size="small"
variant="contained"
disableElevation
disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "once")}
startIcon={
isSubmitting ? (
<CircularProgress size={14} color="inherit" />
) : (
<CheckCircleRounded fontSize="small" />
)
}
sx={{
minWidth: 94,
height: 34,
borderRadius: "17px",
bgcolor: "#00838f",
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`,
"&:hover": {
bgcolor: "#006c78",
boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`,
},
}}
>
</Button>
<Button
size="small"
variant="outlined"
disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "always")}
startIcon={<PushPinRounded fontSize="small" />}
sx={{
height: 34,
borderRadius: "17px",
px: 1.5,
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
color: "#00838f",
borderColor: alpha("#00838f", 0.24),
bgcolor: alpha("#fff", 0.45),
"&:hover": {
borderColor: alpha("#00838f", 0.36),
bgcolor: alpha("#00838f", 0.08),
},
}}
>
</Button>
<Button
size="small"
color="error"
variant="outlined"
disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "reject")}
startIcon={<BlockRounded fontSize="small" />}
sx={{
height: 34,
borderRadius: "17px",
px: 1.5,
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
borderColor: alpha(theme.palette.error.main, 0.22),
bgcolor: alpha("#fff", 0.45),
"&:hover": {
borderColor: alpha(theme.palette.error.main, 0.34),
bgcolor: alpha(theme.palette.error.main, 0.07),
},
}}
>
</Button>
</Stack>
) : null}
</Box>
);
};
export const PermissionRequestGroup = ({
permissions,
isRunning,
onReply,
}: {
permissions: NonNullable<Message["permissions"]>;
isRunning: boolean;
onReply: (requestId: string, reply: PermissionReply) => void;
}) => {
const theme = useTheme();
const onceCount = permissions.filter((permission) => permission.status === "approved_once").length;
const alwaysCount = permissions.filter((permission) => permission.status === "approved_always").length;
const rejectedCount = permissions.filter((permission) => permission.status === "rejected").length;
const abortedCount = permissions.filter((permission) => permission.status === "aborted").length;
const pendingCount = permissions.filter(
(permission) =>
permission.status === "pending" ||
permission.status === "submitting" ||
permission.status === "error",
).length;
const hasPendingPermissions = pendingCount > 0;
const [expanded, setExpanded] = React.useState(false);
const latestPermissions = permissions.slice(-3);
const pendingPermissions = permissions.filter(
(permission) =>
permission.status === "pending" ||
permission.status === "submitting" ||
permission.status === "error",
);
const summaryItems = [
{ label: "共", value: permissions.length, color: theme.palette.text.secondary },
{ label: "允许一次", value: onceCount, color: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) },
{ label: "始终允许", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) },
{ label: "拒绝", value: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) },
{ label: "中断", value: abortedCount, color: getPermissionStatusColor("aborted", theme), textColor: getPermissionStatusTextColor("aborted", theme) },
];
const chipColor =
pendingCount > 0
? getPermissionStatusColor("pending", theme)
: abortedCount > 0
? getPermissionStatusColor("aborted", theme)
: rejectedCount > 0
? getPermissionStatusColor("rejected", theme)
: getPermissionStatusColor("approved_always", theme);
const chipTextColor =
pendingCount > 0
? getPermissionStatusTextColor("pending", theme)
: abortedCount > 0
? getPermissionStatusTextColor("aborted", theme)
: rejectedCount > 0
? getPermissionStatusTextColor("rejected", theme)
: getPermissionStatusTextColor("approved_always", theme);
return (
<Box
sx={{
borderRadius: 3,
overflow: "hidden",
border: `1px solid ${alpha("#fff", 0.72)}`,
bgcolor: alpha("#fff", 0.46),
boxShadow: `0 8px 24px ${alpha("#000", 0.045)}`,
backdropFilter: "blur(20px)",
}}
>
<Stack
direction="row"
alignItems="center"
spacing={1}
role="button"
tabIndex={0}
onClick={() => setExpanded((value) => !value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded((value) => !value);
}
}}
sx={{
px: 1.5,
py: 1.15,
cursor: "pointer",
transition: "background-color 0.2s ease",
"&:hover": { bgcolor: alpha("#000", 0.025) },
}}
>
<Box
sx={{
width: 30,
height: 30,
borderRadius: "50%",
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: chipColor,
bgcolor: alpha(chipColor, 0.1),
border: `1px solid ${alpha(chipColor, 0.15)}`,
}}
>
<VerifiedUserRounded sx={{ fontSize: 18 }} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
</Typography>
<Stack
direction="row"
flexWrap="wrap"
gap={0.6}
sx={{ mt: 0.55, maxHeight: 48, overflow: "hidden" }}
>
{summaryItems.map((item) => (
<Box
key={item.label}
component="span"
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.45,
height: 22,
px: 0.8,
borderRadius: "11px",
bgcolor: alpha(item.color, 0.08),
border: `1px solid ${alpha(item.color, 0.12)}`,
color: "textColor" in item ? item.textColor : item.color,
fontSize: "0.7rem",
fontWeight: 800,
lineHeight: 1,
whiteSpace: "nowrap",
}}
>
<Box
component="span"
sx={{
color: "textColor" in item ? item.textColor : item.color,
fontWeight: 700,
}}
>
{item.label}
</Box>
<Box component="span">{item.value} </Box>
</Box>
))}
</Stack>
</Box>
{isRunning && pendingCount > 0 ? (
<Chip
size="small"
label={`待确认 ${pendingCount}`}
sx={{
height: 24,
borderRadius: "12px",
fontSize: "0.7rem",
fontWeight: 800,
color: chipTextColor,
bgcolor: alpha(chipColor, 0.1),
"& .MuiChip-label": { px: 1 },
}}
/>
) : null}
<IconButton
size="small"
aria-label={expanded ? "收起权限请求" : "展开权限请求"}
sx={{
width: 28,
height: 28,
color: "text.secondary",
bgcolor: alpha("#000", 0.035),
"&:hover": { bgcolor: alpha("#000", 0.07) },
}}
>
{expanded ? (
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
) : (
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
)}
</IconButton>
</Stack>
{!expanded && isRunning && !hasPendingPermissions && latestPermissions.length > 0 ? (
<Stack spacing={0} sx={{ px: 1.5, pb: 1.25 }}>
{latestPermissions.map((permission, index) => {
const primaryValue = getPermissionPrimaryValue(permission);
const isLast = index === latestPermissions.length - 1;
const itemColor = getPermissionStatusColor(permission.status, theme);
const itemTextColor = getPermissionStatusTextColor(permission.status, theme);
return (
<Stack
key={permission.requestId}
direction="row"
spacing={1}
alignItems="center"
sx={{
py: 0.8,
borderTop: index === 0 ? `1px solid ${alpha(chipColor, 0.1)}` : "none",
borderBottom: isLast ? "none" : `1px solid ${alpha("#000", 0.045)}`,
}}
>
<Box
sx={{
width: 24,
height: 24,
borderRadius: "50%",
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: itemColor,
bgcolor: alpha(itemColor, 0.08),
}}
>
<PermissionIcon permission={permission} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="caption" color="text.primary" fontWeight={750} noWrap sx={{ display: "block" }}>
{getPermissionTitle(permission)}
</Typography>
<Typography
variant="caption"
color="text.secondary"
noWrap
title={primaryValue}
sx={{
display: "block",
fontFamily: permission.permission === "bash" ? "monospace" : undefined,
}}
>
{primaryValue}
</Typography>
</Box>
<Chip
size="small"
label={getPermissionStatusLabel(permission.status)}
sx={{
height: 22,
borderRadius: "11px",
fontSize: "0.68rem",
fontWeight: 800,
color: itemTextColor,
bgcolor: alpha(itemColor, 0.08),
"& .MuiChip-label": { px: 0.85 },
}}
/>
</Stack>
);
})}
</Stack>
) : null}
<AnimatePresence initial={false}>
{!expanded && isRunning && hasPendingPermissions ? (
<motion.div
key="pending-permissions"
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -8, height: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ overflow: "hidden" }}
>
<Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}>
{pendingPermissions.map((permission) => (
<PermissionRequestCard
key={permission.requestId}
permission={permission}
isRunning={isRunning}
onReply={onReply}
/>
))}
</Stack>
</motion.div>
) : null}
</AnimatePresence>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}>
{permissions.map((permission) => (
<PermissionRequestCard
key={permission.requestId}
permission={permission}
isRunning={isRunning}
onReply={onReply}
/>
))}
</Stack>
</Collapse>
</Box>
);
};
@@ -0,0 +1,564 @@
"use client";
import React from "react";
import {
Box,
Button,
Checkbox,
Chip,
CircularProgress,
Collapse,
FormControlLabel,
Stack,
TextField,
Typography,
alpha,
useTheme,
} from "@mui/material";
import type { Theme } from "@mui/material/styles";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
import HelpOutlineRounded from "@mui/icons-material/HelpOutlineRounded";
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
import type { Message } from "./GlobalChatbox.types";
const getQuestionStatusLabel = (
status: NonNullable<Message["questions"]>[number]["status"],
) => {
if (status === "answered") return "已回答";
if (status === "rejected") return "已跳过";
if (status === "error") return "提交失败";
if (status === "submitting") return "提交中";
return "等待回答";
};
const getQuestionStatusColor = (
status: NonNullable<Message["questions"]>[number]["status"],
theme: Theme,
) => {
if (status === "answered") return theme.palette.success.main;
if (status === "rejected") return theme.palette.text.secondary;
if (status === "error") return theme.palette.error.main;
return "#0288d1";
};
const QuestionRequestCard = ({
questionRequest,
onReply,
onReject,
}: {
questionRequest: NonNullable<Message["questions"]>[number];
onReply: (requestId: string, answers: string[][]) => void;
onReject: (requestId: string) => void;
}) => {
const theme = useTheme();
const isEditable =
questionRequest.status === "pending" || questionRequest.status === "error";
const isSubmitting = questionRequest.status === "submitting";
const statusColor = getQuestionStatusColor(questionRequest.status, theme);
const [selected, setSelected] = React.useState<Record<number, string[]>>({});
const [customSelected, setCustomSelected] = React.useState<Record<number, boolean>>({});
const [custom, setCustom] = React.useState<Record<number, string>>({});
const answers = React.useMemo(
() =>
questionRequest.questions.map((question, index) => {
const selectedAnswers = selected[index] ?? [];
const isCustomSelected =
customSelected[index] === true ||
(question.custom !== false && question.options.length === 0);
const customAnswer = custom[index]?.trim();
return isCustomSelected && customAnswer
? [...selectedAnswers, customAnswer]
: selectedAnswers;
}),
[custom, customSelected, questionRequest.questions, selected],
);
const canSubmit =
isEditable &&
questionRequest.questions.length > 0 &&
questionRequest.questions.every((_, index) => {
const answer = answers[index] ?? [];
return answer.some((item) => item.trim().length > 0);
});
const answerSummary = (questionRequest.answers ?? [])
.map((answer) => answer.join("、"))
.filter(Boolean)
.join("");
return (
<Box
sx={{
borderRadius: 3,
overflow: "hidden",
border: `1px solid ${alpha("#fff", 0.72)}`,
bgcolor: alpha("#fff", 0.52),
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
backdropFilter: "blur(20px)",
position: "relative",
"&::before": {
content: '""',
position: "absolute",
inset: "10px auto 10px 0",
width: 3,
borderRadius: "0 999px 999px 0",
bgcolor: statusColor,
},
}}
>
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{
px: 1.5,
py: 1.25,
pl: 1.75,
borderBottom: `1px solid ${alpha("#000", 0.05)}`,
}}
>
<Box
sx={{
width: 32,
height: 32,
borderRadius: "50%",
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: statusColor,
bgcolor: alpha(statusColor, 0.1),
border: `1px solid ${alpha(statusColor, 0.16)}`,
}}
>
<HelpOutlineRounded sx={{ fontSize: 21 }} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
</Typography>
</Box>
<Chip
size="small"
label={getQuestionStatusLabel(questionRequest.status)}
sx={{
height: 24,
fontSize: "0.7rem",
fontWeight: 800,
borderRadius: "12px",
bgcolor: alpha(statusColor, 0.12),
color: statusColor,
"& .MuiChip-label": { px: 1 },
}}
/>
</Stack>
<Stack spacing={1.3} sx={{ px: 1.5, py: 1.35, pl: 1.75 }}>
{questionRequest.questions.map((question, index) => {
const selectedAnswers = selected[index] ?? [];
const isCustomEnabled = question.custom !== false;
const isCustomSelected =
customSelected[index] === true ||
(isCustomEnabled && question.options.length === 0);
const setQuestionAnswers = (nextAnswers: string[]) => {
setSelected((current) => ({
...current,
[index]: nextAnswers,
}));
};
const setQuestionCustomSelected = (checked: boolean) => {
setCustomSelected((current) => ({
...current,
[index]: checked,
}));
};
return (
<Box
key={`${question.header}-${index}`}
sx={{
px: 1.25,
py: 1,
borderRadius: 2.5,
bgcolor: alpha("#000", 0.025),
border: `1px solid ${alpha("#000", 0.045)}`,
}}
>
<Typography variant="caption" color="text.secondary" fontWeight={800}>
{question.header || `问题 ${index + 1}`}
</Typography>
<Typography
variant="body2"
color="text.primary"
sx={{ mt: 0.35, lineHeight: 1.55, wordBreak: "break-word" }}
>
{question.question}
</Typography>
{question.options.length ? (
<Stack spacing={0.75} sx={{ mt: 1 }}>
{question.options.map((option) => {
const checked = selectedAnswers.includes(option.label);
if (question.multiple) {
return (
<FormControlLabel
key={option.label}
disabled={!isEditable || isSubmitting}
control={
<Checkbox
size="small"
checked={checked}
onChange={(event) => {
if (event.target.checked) {
setQuestionAnswers([...selectedAnswers, option.label]);
} else {
setQuestionAnswers(
selectedAnswers.filter((item) => item !== option.label),
);
}
}}
/>
}
label={
<Box>
<Typography variant="body2" fontWeight={750}>
{option.label}
</Typography>
{option.description ? (
<Typography variant="caption" color="text.secondary">
{option.description}
</Typography>
) : null}
</Box>
}
sx={{ alignItems: "flex-start", m: 0 }}
/>
);
}
return (
<Button
key={option.label}
size="small"
variant={checked ? "contained" : "outlined"}
disabled={!isEditable || isSubmitting}
onClick={() => {
setQuestionAnswers([option.label]);
setQuestionCustomSelected(false);
}}
startIcon={
checked ? (
<CheckCircleRounded fontSize="small" />
) : (
<RadioButtonUncheckedRounded fontSize="small" />
)
}
sx={{
justifyContent: "flex-start",
minHeight: 38,
borderRadius: 2,
textTransform: "none",
fontWeight: 800,
bgcolor: checked ? "#0288d1" : alpha("#fff", 0.45),
borderColor: checked ? "#0288d1" : alpha("#0288d1", 0.22),
"&:hover": {
bgcolor: checked ? "#0277bd" : alpha("#0288d1", 0.08),
},
}}
>
<Box sx={{ textAlign: "left", minWidth: 0 }}>
<Typography variant="body2" fontWeight={800}>
{option.label}
</Typography>
{option.description ? (
<Typography
variant="caption"
sx={{ display: "block", opacity: checked ? 0.86 : 0.72 }}
>
{option.description}
</Typography>
) : null}
</Box>
</Button>
);
})}
{isCustomEnabled ? (
question.multiple ? (
<FormControlLabel
disabled={!isEditable || isSubmitting}
control={
<Checkbox
size="small"
checked={isCustomSelected}
onChange={(event) =>
setQuestionCustomSelected(event.target.checked)
}
sx={{
p: 0.5,
color: alpha("#0288d1", 0.55),
"&.Mui-checked": { color: "#0288d1" },
}}
/>
}
label={
<Stack direction="row" spacing={0.75} alignItems="center">
<EditNoteRounded sx={{ fontSize: 18, color: "#0288d1" }} />
<Typography variant="body2" fontWeight={800}>
</Typography>
</Stack>
}
sx={{
alignItems: "center",
minHeight: 38,
m: 0,
px: 0.75,
py: 0.25,
borderRadius: 2,
border: `1px solid ${
isCustomSelected ? "#0288d1" : alpha("#0288d1", 0.18)
}`,
bgcolor: isCustomSelected
? alpha("#0288d1", 0.1)
: alpha("#fff", 0.45),
transition: "background-color 0.18s ease, border-color 0.18s ease",
"&:hover": {
bgcolor: isCustomSelected
? alpha("#0288d1", 0.13)
: alpha("#0288d1", 0.07),
},
"& .MuiFormControlLabel-label": {
color: isCustomSelected ? "#0277bd" : "text.primary",
},
}}
/>
) : (
<Button
size="small"
variant={isCustomSelected ? "contained" : "outlined"}
disabled={!isEditable || isSubmitting}
onClick={() => {
setQuestionAnswers([]);
setQuestionCustomSelected(true);
}}
startIcon={
isCustomSelected ? (
<CheckCircleRounded fontSize="small" />
) : (
<EditNoteRounded fontSize="small" />
)
}
sx={{
justifyContent: "flex-start",
minHeight: 38,
borderRadius: 2,
textTransform: "none",
fontWeight: 800,
bgcolor: isCustomSelected ? "#0288d1" : alpha("#fff", 0.45),
borderColor: isCustomSelected
? "#0288d1"
: alpha("#0288d1", 0.22),
"&:hover": {
bgcolor: isCustomSelected
? "#0277bd"
: alpha("#0288d1", 0.08),
},
}}
>
<Box sx={{ textAlign: "left", minWidth: 0 }}>
<Typography variant="body2" fontWeight={800}>
</Typography>
</Box>
</Button>
)
) : null}
</Stack>
) : null}
<Collapse in={isCustomEnabled && isCustomSelected} timeout="auto" unmountOnExit>
<Box
sx={{
mt: 0.85,
px: 1.15,
py: 0.85,
borderRadius: 2.5,
bgcolor: alpha("#fff", 0.62),
border: `1px solid ${alpha("#fff", 0.82)}`,
boxShadow: `0 8px 22px ${alpha("#000", 0.045)}, 0 0 0 1px ${alpha("#0288d1", 0.05)} inset`,
backdropFilter: "blur(18px)",
}}
>
<TextField
multiline
minRows={2}
maxRows={5}
fullWidth
variant="standard"
disabled={!isEditable || isSubmitting}
value={custom[index] ?? ""}
onChange={(event) =>
setCustom((current) => ({
...current,
[index]: event.target.value,
}))
}
placeholder="输入自定义回答"
InputProps={{
disableUnderline: true,
sx: {
alignItems: "flex-start",
fontSize: "0.88rem",
lineHeight: 1.55,
fontWeight: 500,
color: "text.primary",
"& textarea::placeholder": {
color: alpha(theme.palette.text.primary, 0.38),
opacity: 1,
},
},
}}
/>
</Box>
</Collapse>
</Box>
);
})}
{questionRequest.status === "answered" ? (
<Typography
variant="caption"
color="success.main"
sx={{
display: "block",
px: 1.25,
py: 0.75,
borderRadius: 2,
bgcolor: alpha(theme.palette.success.main, 0.07),
wordBreak: "break-word",
}}
>
{answerSummary ? `${answerSummary}` : ""}
</Typography>
) : null}
{questionRequest.status === "rejected" ? (
<Typography
variant="caption"
color="text.secondary"
sx={{
display: "block",
px: 1.25,
py: 0.75,
borderRadius: 2,
bgcolor: alpha("#000", 0.035),
}}
>
</Typography>
) : null}
{questionRequest.error ? (
<Typography
variant="caption"
color="error.main"
sx={{
display: "block",
px: 1.25,
py: 0.75,
borderRadius: 2,
bgcolor: alpha(theme.palette.error.main, 0.06),
wordBreak: "break-word",
}}
>
{questionRequest.error}
</Typography>
) : null}
</Stack>
{isEditable || isSubmitting ? (
<Stack
direction="row"
spacing={1}
flexWrap="wrap"
useFlexGap
sx={{ px: 1.5, pb: 1.35, pl: 1.75 }}
>
<Button
size="small"
variant="outlined"
disabled={isSubmitting}
onClick={() => onReject(questionRequest.requestId)}
sx={{
height: 34,
borderRadius: "17px",
px: 1.5,
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
color: "text.secondary",
borderColor: alpha(theme.palette.text.secondary, 0.22),
bgcolor: alpha("#fff", 0.45),
}}
>
</Button>
<Button
size="small"
variant="contained"
disableElevation
disabled={!canSubmit || isSubmitting}
onClick={() => onReply(questionRequest.requestId, answers)}
startIcon={
isSubmitting ? (
<CircularProgress size={14} color="inherit" />
) : (
<CheckCircleRounded fontSize="small" />
)
}
sx={{
minWidth: 104,
height: 34,
borderRadius: "17px",
bgcolor: "#0288d1",
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
boxShadow: `0 4px 12px ${alpha("#0288d1", 0.24)}`,
"&:hover": {
bgcolor: "#0277bd",
boxShadow: `0 6px 16px ${alpha("#0288d1", 0.28)}`,
},
}}
>
</Button>
</Stack>
) : null}
</Box>
);
};
export const QuestionRequestGroup = ({
questions,
onReply,
onReject,
}: {
questions: NonNullable<Message["questions"]>;
onReply: (requestId: string, answers: string[][]) => void;
onReject: (requestId: string) => void;
}) => (
<Stack spacing={1}>
{questions.map((question) => (
<QuestionRequestCard
key={question.requestId}
questionRequest={question}
onReply={onReply}
onReject={onReject}
/>
))}
</Stack>
);
+308
View File
@@ -0,0 +1,308 @@
"use client";
import React from "react";
import {
Box,
Chip,
CircularProgress,
Collapse,
IconButton,
Stack,
Typography,
alpha,
useTheme,
} from "@mui/material";
import AssignmentTurnedInRounded from "@mui/icons-material/AssignmentTurnedInRounded";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import BlockRounded from "@mui/icons-material/BlockRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
import type { Message } from "./GlobalChatbox.types";
export const TodoPlanCard = ({
todoUpdate,
}: {
todoUpdate: NonNullable<Message["todos"]>;
}) => {
const theme = useTheme();
const total = todoUpdate.todos.length;
const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length;
const running = todoUpdate.todos.find((todo) => todo.status === "in_progress");
const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length;
const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length;
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
const isAborted = cancelled > 0 && completed + cancelled === total;
const canCollapse = total > 4;
const [expanded, setExpanded] = React.useState(!canCollapse && !isAborted);
const pinnedTodos = canCollapse ? todoUpdate.todos.slice(0, 4) : todoUpdate.todos;
const collapsibleTodos = canCollapse ? todoUpdate.todos.slice(4) : [];
const hiddenCount = expanded ? 0 : collapsibleTodos.length;
const latestUpdatedAt = Math.max(
todoUpdate.createdAt,
...todoUpdate.todos
.map((todo) => todo.updatedAt ?? todo.createdAt ?? 0)
.filter((value) => value > 0),
);
const updatedAtLabel =
latestUpdatedAt > 0
? new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
}).format(new Date(latestUpdatedAt))
: undefined;
const getTodoVisual = (status: NonNullable<Message["todos"]>["todos"][number]["status"]) => {
if (status === "completed") {
return { icon: <CheckCircleRounded sx={{ fontSize: 17 }} />, color: theme.palette.success.main, label: "完成" };
}
if (status === "in_progress") {
return { icon: <CircularProgress size={15} thickness={5} />, color: "#0288d1", label: "进行中" };
}
if (status === "cancelled") {
return { icon: <BlockRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.disabled, label: "中止" };
}
return { icon: <RadioButtonUncheckedRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.secondary, label: "待办" };
};
const getPriorityLabel = (priority: NonNullable<Message["todos"]>["todos"][number]["priority"]) => {
if (priority === "high") return { label: "高优先级", color: "#8a5a00" };
if (priority === "medium") return { label: "中优先级", color: "#9a6a16" };
if (priority === "low") return { label: "低优先级", color: "#8d7960" };
return undefined;
};
const statusSummary = isAborted
? `${completed} 完成 / ${cancelled} 中止`
: [
completed ? `${completed} 完成` : null,
running ? "1 进行中" : null,
pending ? `${pending} 待办` : null,
cancelled ? `${cancelled} 中止` : null,
].filter(Boolean).join(" / ") || "等待任务";
const renderTodoRow = (
todo: NonNullable<Message["todos"]>["todos"][number],
index: number,
) => {
const visual = getTodoVisual(todo.status);
const priority = getPriorityLabel(todo.priority);
return (
<Stack
key={`${todo.id}-${index}`}
direction="row"
alignItems="flex-start"
spacing={1}
sx={{
py: 0.8,
borderTop: `1px solid ${alpha("#00838f", 0.08)}`,
color: todo.status === "cancelled" ? "text.disabled" : "text.primary",
}}
>
<Box
sx={{
width: 24,
height: 24,
borderRadius: 1.25,
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: visual.color,
bgcolor: alpha(visual.color, 0.08),
mt: 0.1,
}}
>
{visual.icon}
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="body2"
sx={{
minWidth: 0,
wordBreak: "break-word",
lineHeight: 1.45,
textDecoration: todo.status === "cancelled" ? "line-through" : undefined,
}}
>
{todo.content}
</Typography>
</Box>
<Stack direction="row" spacing={0.5} sx={{ flex: "0 0 auto" }}>
{priority ? (
<Chip
size="small"
label={priority.label}
sx={{
height: 22,
borderRadius: "11px",
fontSize: "0.66rem",
fontWeight: 800,
color: priority.color,
bgcolor: alpha(priority.color, 0.045),
border: `1px solid ${alpha(priority.color, 0.16)}`,
"& .MuiChip-label": { px: 0.75 },
}}
/>
) : null}
<Chip
size="small"
label={visual.label}
sx={{
height: 22,
borderRadius: "11px",
fontSize: "0.66rem",
fontWeight: 800,
color: visual.color,
bgcolor: alpha(visual.color, 0.08),
"& .MuiChip-label": { px: 0.75 },
}}
/>
</Stack>
</Stack>
);
};
if (total === 0) {
return null;
}
return (
<Box
sx={{
borderRadius: 2,
overflow: "hidden",
border: `1px solid ${alpha("#00838f", 0.16)}`,
bgcolor: alpha("#f8fbfc", 0.82),
}}
>
<Stack
spacing={1}
role="button"
tabIndex={0}
onClick={() => {
if (canCollapse) {
setExpanded((value) => !value);
}
}}
onKeyDown={(event) => {
if (canCollapse && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
setExpanded((value) => !value);
}
}}
sx={{
px: 1.4,
py: 1.15,
cursor: canCollapse ? "pointer" : "default",
transition: "background-color 0.2s ease",
"&:hover": canCollapse ? { bgcolor: alpha("#00838f", 0.035) } : undefined,
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<Box
sx={{
width: 28,
height: 28,
borderRadius: 1.5,
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: "#00838f",
bgcolor: alpha("#00838f", 0.1),
border: `1px solid ${alpha("#00838f", 0.14)}`,
}}
>
<AssignmentTurnedInRounded sx={{ fontSize: 18 }} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Stack direction="row" alignItems="center" spacing={0.75}>
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
</Typography>
<Chip
size="small"
label={running ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"}
sx={{
height: 20,
borderRadius: "10px",
fontSize: "0.66rem",
fontWeight: 800,
color: running ? "#0277bd" : isAborted ? "text.secondary" : "#00838f",
bgcolor: alpha(running ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08),
"& .MuiChip-label": { px: 0.75 },
}}
/>
</Stack>
<Typography variant="caption" color="text.secondary">
{statusSummary}{updatedAtLabel ? ` · ${updatedAtLabel} 更新` : ""}
</Typography>
</Box>
{canCollapse ? (
<IconButton
size="small"
aria-label={expanded ? "收起会话任务" : "展开会话任务"}
sx={{
width: 28,
height: 28,
color: "text.secondary",
bgcolor: alpha("#000", 0.035),
"&:hover": { bgcolor: alpha("#000", 0.07) },
}}
>
{expanded ? (
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
) : (
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
)}
</IconButton>
) : null}
</Stack>
<Box
sx={{
height: 6,
borderRadius: 999,
overflow: "hidden",
bgcolor: alpha("#00838f", 0.1),
}}
>
<Box
sx={{
width: `${progress}%`,
height: "100%",
borderRadius: 999,
bgcolor: isAborted ? theme.palette.text.disabled : "#00838f",
transition: "width 0.25s ease",
}}
/>
</Box>
</Stack>
<Stack spacing={0} sx={{ px: 1.4, pb: 1.1 }}>
{pinnedTodos.map((todo, index) => renderTodoRow(todo, index))}
{canCollapse ? (
<Collapse in={expanded} timeout={220} unmountOnExit={false}>
<Stack spacing={0}>
{collapsibleTodos.map((todo, index) =>
renderTodoRow(todo, index + pinnedTodos.length),
)}
</Stack>
</Collapse>
) : null}
{hiddenCount > 0 ? (
<Typography
variant="caption"
color="text.secondary"
sx={{
pt: 0.8,
borderTop: `1px solid ${alpha("#00838f", 0.08)}`,
}}
>
{hiddenCount}
</Typography>
) : null}
</Stack>
</Box>
);
};
+106 -290
View File
@@ -2,13 +2,10 @@
import Image from "next/image"; import Image from "next/image";
import React, { useMemo } 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,72 +15,68 @@ 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 { ChatInlineChart } from "./ChatInlineChart";
import type { ChatChartSeries } 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 }) => (
<div className={markdownStyles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
</div>
);
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 [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 = useMemo( const parsedAssistantSections = useMemo(
() => () =>
@@ -112,185 +105,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>
); );
} }
@@ -359,6 +200,26 @@ 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,
@@ -418,7 +279,7 @@ export const AgentTurn = React.memo(
</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 }}
@@ -444,7 +305,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) } }}
@@ -452,16 +315,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>
@@ -472,87 +335,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>
+4 -5
View File
@@ -33,8 +33,6 @@ jest.mock("./AgentTurn", () => ({
describe("AgentWorkspace", () => { describe("AgentWorkspace", () => {
const defaultProps = { const defaultProps = {
branchGroups: [],
branchTransition: null,
bottomRef: { current: null }, bottomRef: { current: null },
speakingMessageId: null, speakingMessageId: null,
speechState: "idle" as const, speechState: "idle" as const,
@@ -43,9 +41,10 @@ describe("AgentWorkspace", () => {
onResumeSpeech: jest.fn(), onResumeSpeech: jest.fn(),
onStopSpeech: jest.fn(), onStopSpeech: jest.fn(),
isTtsSupported: false, isTtsSupported: false,
onRegenerate: jest.fn(), onCreateBranch: jest.fn(),
onEditResubmit: jest.fn(), onReplyPermission: jest.fn(),
onCycleBranch: jest.fn(), onReplyQuestion: jest.fn(),
onRejectQuestion: jest.fn(),
}; };
beforeEach(() => { beforeEach(() => {
+53 -101
View File
@@ -11,18 +11,14 @@ 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,
BranchState,
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;
bottomRef: React.RefObject<HTMLDivElement | null>; bottomRef: React.RefObject<HTMLDivElement | null>;
speakingMessageId: string | null; speakingMessageId: string | null;
@@ -32,14 +28,15 @@ 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 = { type TurnListProps = {
messages: Message[]; messages: Message[];
branchGroups: BranchGroup[]; isStreaming: boolean;
speakingMessageId: string | null; speakingMessageId: string | null;
speechState: SpeechState; speechState: SpeechState;
onSpeak: (messageId: string, text: string) => void; onSpeak: (messageId: string, text: string) => void;
@@ -47,9 +44,10 @@ type TurnListProps = {
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;
}; };
const sameMessages = (left: Message[], right: Message[]) => const sameMessages = (left: Message[], right: Message[]) =>
@@ -58,7 +56,7 @@ const sameMessages = (left: Message[], right: Message[]) =>
const TurnListInner = ({ const TurnListInner = ({
messages, messages,
branchGroups, isStreaming,
speakingMessageId, speakingMessageId,
speechState, speechState,
onSpeak, onSpeak,
@@ -66,44 +64,30 @@ const TurnListInner = ({
onResumeSpeech, onResumeSpeech,
onStopSpeech, onStopSpeech,
isTtsSupported, isTtsSupported,
onRegenerate, onCreateBranch,
onEditResubmit, onReplyPermission,
onCycleBranch, onReplyQuestion,
onRejectQuestion,
}: TurnListProps) => { }: TurnListProps) => {
const branchStateByRootId = React.useMemo(() => {
const next = new Map<string, BranchState>();
branchGroups.forEach((group) => {
if (group.branches.length > 1) {
next.set(group.rootMessageId, {
activeIndex: group.activeIndex,
total: group.branches.length,
});
}
});
return next;
}, [branchGroups]);
return ( return (
<> <>
{messages.map((message) => { {messages.map((message) => (
const rootMessageId = message.branchRootId ?? message.id; <AgentTurn
return ( key={message.id}
<AgentTurn message={message}
key={rootMessageId} isStreaming={isStreaming}
message={message} messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
branchState={branchStateByRootId.get(rootMessageId)} onSpeak={onSpeak}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} onPause={onPauseSpeech}
onSpeak={onSpeak} onResume={onResumeSpeech}
onPause={onPauseSpeech} onStopSpeech={onStopSpeech}
onResume={onResumeSpeech} isTtsSupported={isTtsSupported}
onStopSpeech={onStopSpeech} onCreateBranch={onCreateBranch}
isTtsSupported={isTtsSupported} onReplyPermission={onReplyPermission}
onRegenerate={onRegenerate} onReplyQuestion={onReplyQuestion}
onEditResubmit={onEditResubmit} onRejectQuestion={onRejectQuestion}
onCycleBranch={onCycleBranch} />
/> ))}
);
})}
</> </>
); );
}; };
@@ -112,7 +96,7 @@ const TurnList = React.memo(
TurnListInner, TurnListInner,
(prevProps, nextProps) => (prevProps, nextProps) =>
sameMessages(prevProps.messages, nextProps.messages) && sameMessages(prevProps.messages, nextProps.messages) &&
prevProps.branchGroups === nextProps.branchGroups && prevProps.isStreaming === nextProps.isStreaming &&
prevProps.speakingMessageId === nextProps.speakingMessageId && prevProps.speakingMessageId === nextProps.speakingMessageId &&
prevProps.speechState === nextProps.speechState && prevProps.speechState === nextProps.speechState &&
prevProps.onSpeak === nextProps.onSpeak && prevProps.onSpeak === nextProps.onSpeak &&
@@ -120,9 +104,10 @@ const TurnList = React.memo(
prevProps.onResumeSpeech === nextProps.onResumeSpeech && prevProps.onResumeSpeech === nextProps.onResumeSpeech &&
prevProps.onStopSpeech === nextProps.onStopSpeech && prevProps.onStopSpeech === nextProps.onStopSpeech &&
prevProps.isTtsSupported === nextProps.isTtsSupported && prevProps.isTtsSupported === nextProps.isTtsSupported &&
prevProps.onRegenerate === nextProps.onRegenerate && prevProps.onCreateBranch === nextProps.onCreateBranch &&
prevProps.onEditResubmit === nextProps.onEditResubmit && prevProps.onReplyPermission === nextProps.onReplyPermission &&
prevProps.onCycleBranch === nextProps.onCycleBranch, prevProps.onReplyQuestion === nextProps.onReplyQuestion &&
prevProps.onRejectQuestion === nextProps.onRejectQuestion,
); );
TurnList.displayName = "TurnList"; TurnList.displayName = "TurnList";
@@ -243,8 +228,6 @@ const EmptyState = () => {
export const AgentWorkspace = ({ export const AgentWorkspace = ({
messages, messages,
branchGroups,
branchTransition,
isStreaming, isStreaming,
bottomRef, bottomRef,
speakingMessageId, speakingMessageId,
@@ -254,9 +237,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]
@@ -267,18 +251,12 @@ export const AgentWorkspace = ({
(!latestAssistant || (!latestAssistant ||
(latestAssistant.content.trim().length === 0 && (latestAssistant.content.trim().length === 0 &&
!(latestAssistant.artifacts?.length))); !(latestAssistant.artifacts?.length)));
const stableMessages = branchTransition
? messages.slice(0, branchTransition.parentCount)
: messages;
const transitionMessages = branchTransition
? messages.slice(branchTransition.parentCount)
: [];
const streamingMessage = const streamingMessage =
!branchTransition && isStreaming && messages.at(-1)?.role === "assistant" isStreaming && messages.at(-1)?.role === "assistant"
? messages.at(-1) ? messages.at(-1)
: undefined; : undefined;
const historyMessages = const historyMessages =
streamingMessage !== undefined ? messages.slice(0, -1) : stableMessages; streamingMessage !== undefined ? messages.slice(0, -1) : messages;
return ( return (
<Box <Box
@@ -300,7 +278,7 @@ export const AgentWorkspace = ({
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<TurnList <TurnList
messages={historyMessages} messages={historyMessages}
branchGroups={branchGroups} isStreaming={isStreaming}
speakingMessageId={speakingMessageId} speakingMessageId={speakingMessageId}
speechState={speechState} speechState={speechState}
onSpeak={onSpeak} onSpeak={onSpeak}
@@ -308,15 +286,16 @@ export const AgentWorkspace = ({
onResumeSpeech={onResumeSpeech} onResumeSpeech={onResumeSpeech}
onStopSpeech={onStopSpeech} onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported} isTtsSupported={isTtsSupported}
onRegenerate={onRegenerate} onCreateBranch={onCreateBranch}
onEditResubmit={onEditResubmit} onReplyPermission={onReplyPermission}
onCycleBranch={onCycleBranch} onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/> />
{streamingMessage ? ( {streamingMessage ? (
<TurnList <TurnList
messages={[streamingMessage]} messages={[streamingMessage]}
branchGroups={branchGroups} isStreaming={isStreaming}
speakingMessageId={speakingMessageId} speakingMessageId={speakingMessageId}
speechState={speechState} speechState={speechState}
onSpeak={onSpeak} onSpeak={onSpeak}
@@ -324,39 +303,12 @@ export const AgentWorkspace = ({
onResumeSpeech={onResumeSpeech} onResumeSpeech={onResumeSpeech}
onStopSpeech={onStopSpeech} onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported} isTtsSupported={isTtsSupported}
onRegenerate={onRegenerate} onCreateBranch={onCreateBranch}
onEditResubmit={onEditResubmit} onReplyPermission={onReplyPermission}
onCycleBranch={onCycleBranch} onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/> />
) : null} ) : null}
{branchTransition ? (
<AnimatePresence initial={false} mode="wait">
<motion.div
key={`${branchTransition.rootMessageId}:${branchTransition.activeBranchId}:${branchTransition.nonce}`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.18, ease: "easeOut" }}
style={{ display: "flex", flexDirection: "column", gap: 16 }}
>
<TurnList
messages={transitionMessages}
branchGroups={branchGroups}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={onSpeak}
onPauseSpeech={onPauseSpeech}
onResumeSpeech={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
onRegenerate={onRegenerate}
onEditResubmit={onEditResubmit}
onCycleBranch={onCycleBranch}
/>
</motion.div>
</AnimatePresence>
) : null}
</Box> </Box>
) : null} ) : null}
+56
View File
@@ -118,6 +118,12 @@ const TOOL_META: Record<string, ToolMeta> = {
actionLabel: "定位到地图", actionLabel: "定位到地图",
color: "#3ba272", color: "#3ba272",
}, },
zoom_to_map: {
label: "缩放到坐标",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "缩放到地图",
color: "#0ea5e9",
},
view_history: { view_history: {
label: "查看计算结果", label: "查看计算结果",
icon: <TimelineRounded sx={{ fontSize: 18 }} />, icon: <TimelineRounded sx={{ fontSize: 18 }} />,
@@ -176,6 +182,46 @@ function normalizeLocateIds(params: Record<string, unknown>): string[] {
return []; return [];
} }
function readFiniteNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function buildZoomTo3857Action(
params: Record<string, unknown>,
): Extract<ChatToolAction, { type: "zoom_to_map" }> | null {
const rawCoordinate = params.coordinate ?? params.coordinates ?? params.center;
const tuple = Array.isArray(rawCoordinate)
? rawCoordinate
: [params.x ?? params.lon ?? params.longitude, params.y ?? params.lat ?? params.latitude];
const x = readFiniteNumber(tuple[0]);
const y = readFiniteNumber(tuple[1]);
if (x === null || y === null) {
return null;
}
const zoom = readFiniteNumber(params.zoom);
const durationMs = readFiniteNumber(params.duration_ms ?? params.durationMs);
const rawSourceCrs = params.source_crs ?? params.sourceCrs ?? params.crs;
const normalizedSourceCrs =
typeof rawSourceCrs === "string" ? rawSourceCrs.trim().toUpperCase() : "";
const sourceCrs =
normalizedSourceCrs === "EPSG:4326" ? "EPSG:4326" : "EPSG:3857";
return {
type: "zoom_to_map",
coordinate: [x, y],
sourceCrs,
zoom: zoom ?? undefined,
durationMs: durationMs ?? undefined,
};
}
function getToolDescription(toolCall: ToolCall): string { function getToolDescription(toolCall: ToolCall): string {
const { params } = toolCall; const { params } = toolCall;
const resolveScadaFeatureInfos = (): [string, string][] => { const resolveScadaFeatureInfos = (): [string, string][] => {
@@ -281,6 +327,14 @@ function getToolDescription(toolCall: ToolCall): string {
case "render_junctions": { case "render_junctions": {
return (params.render_ref as string | undefined) ?? "渲染引用"; return (params.render_ref as string | undefined) ?? "渲染引用";
} }
case "zoom_to_map": {
const action = buildZoomTo3857Action(params);
if (!action) {
return "地图坐标";
}
const zoom = action.zoom === undefined ? "" : ` · zoom ${action.zoom}`;
return `${action.coordinate[0]}, ${action.coordinate[1]} · ${action.sourceCrs}${zoom}`;
}
case APPLY_LAYER_STYLE_TOOL: { case APPLY_LAYER_STYLE_TOOL: {
const payload = parseApplyLayerStylePayload(params); const payload = parseApplyLayerStylePayload(params);
return payload ? describeApplyLayerStyle(payload) : "图层样式"; return payload ? describeApplyLayerStyle(payload) : "图层样式";
@@ -341,6 +395,8 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
(params.end as string | undefined), (params.end as string | undefined),
}); });
switch (toolCall.tool) { switch (toolCall.tool) {
case "zoom_to_map":
return buildZoomTo3857Action(params);
case "locate_features": { case "locate_features": {
const featureTypeRaw = params.feature_type; const featureTypeRaw = params.feature_type;
const featureType = const featureType =
+47 -16
View File
@@ -7,8 +7,10 @@ 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 type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
import { useProjectStore } from "@/store/projectStore"; import { useProjectStore } from "@/store/projectStore";
import { AgentComposer, type AgentComposerHandle } from "./AgentComposer"; import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
import { AgentHeader } from "./AgentHeader"; import { AgentHeader } from "./AgentHeader";
@@ -25,14 +27,18 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [width, setWidth] = useState(520); const [width, setWidth] = useState(520);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
const [selectedModel, setSelectedModel] = useState<AgentModel>( const [selectedModel, setSelectedModel] = useState<AgentModel>(
"deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-pro",
); );
const [approvalMode, setApprovalMode] =
useState<AgentApprovalMode>("request");
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const composerRef = useRef<AgentComposerHandle | null>(null); const composerRef = useRef<AgentComposerHandle | null>(null);
const hasResetForOpenRef = useRef(false); const hasResetForOpenRef = useRef(false);
const theme = useTheme(); const theme = useTheme();
const { open: openNotification } = useNotification();
const currentProjectId = useProjectStore((state) => state.currentProjectId); const currentProjectId = useProjectStore((state) => state.currentProjectId);
const { const {
@@ -61,16 +67,15 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
messages, messages,
chatSessions, chatSessions,
activeSessionId, activeSessionId,
branchGroups,
branchTransition,
isHydrating, isHydrating,
isStreaming, isStreaming,
sessionTitle, sessionTitle,
sendPrompt, sendPrompt,
regenerate, createBranch,
editAndResubmit,
cycleBranch,
abort, abort,
replyPermission,
replyQuestion,
rejectQuestion,
createSession, createSession,
renameSession, renameSession,
removeSession, removeSession,
@@ -80,6 +85,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
onToolCall: handleToolCall, onToolCall: handleToolCall,
onBeforeSend: stopListening, onBeforeSend: stopListening,
getModel: () => selectedModel, getModel: () => selectedModel,
getApprovalMode: () => approvalMode,
}); });
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
@@ -108,10 +114,34 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [createSession, isHydrating, open, scrollToBottom]); }, [createSession, isHydrating, open, scrollToBottom]);
const handleSend = useCallback((prompt: string) => { const handleSend = useCallback(async (prompt: string) => {
if (isStreaming) return; if (isStreaming || isCheckingAuth) return;
void sendPrompt(prompt);
}, [isStreaming, sendPrompt]); setIsCheckingAuth(true);
try {
const accessToken = await getAccessToken();
if (!accessToken) {
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();
@@ -312,8 +342,6 @@ 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}
bottomRef={bottomRef} bottomRef={bottomRef}
speakingMessageId={speakingMessageId} speakingMessageId={speakingMessageId}
@@ -323,14 +351,15 @@ 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
ref={composerRef} ref={composerRef}
isHydrating={isHydrating} isHydrating={isHydrating || isCheckingAuth}
isStreaming={isStreaming} isStreaming={isStreaming}
isListening={isListening} isListening={isListening}
isSttSupported={isSttSupported} isSttSupported={isSttSupported}
@@ -341,6 +370,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
onStopListening={stopListening} onStopListening={stopListening}
selectedModel={selectedModel} selectedModel={selectedModel}
onModelChange={setSelectedModel} onModelChange={setSelectedModel}
approvalMode={approvalMode}
onApprovalModeChange={setApprovalMode}
/> />
</Box> </Box>
</Box> </Box>
+34 -29
View File
@@ -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 = {
@@ -80,7 +86,6 @@ export type LoadedChatState = {
title?: string; title?: string;
isTitleManuallyEdited?: boolean; isTitleManuallyEdited?: boolean;
messages: Message[]; messages: Message[];
branchGroups: BranchGroup[];
isStreaming?: boolean; isStreaming?: boolean;
runStatus?: string; 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([]);
});
});
+62 -12
View File
@@ -1,4 +1,8 @@
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)}`;
@@ -29,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),
})),
}));
-3
View File
@@ -21,7 +21,6 @@ describe("chatStorage backend-only persistence", () => {
title: undefined, title: undefined,
messages: [], messages: [],
sessionId: undefined, sessionId: undefined,
branchGroups: [],
}); });
expect(apiFetch).not.toHaveBeenCalled(); expect(apiFetch).not.toHaveBeenCalled();
}); });
@@ -60,11 +59,9 @@ describe("chatStorage backend-only persistence", () => {
id: "message-2", id: "message-2",
role: "user", role: "user",
content: "第一条消息", content: "第一条消息",
branchRootId: "message-2",
}, },
], ],
sessionId: undefined, sessionId: undefined,
branchGroups: [],
}, },
); );
+1 -11
View File
@@ -2,12 +2,11 @@ import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config"; import { config } from "@config/config";
import type { import type {
BranchGroup,
ChatSessionSummary, ChatSessionSummary,
LoadedChatState, LoadedChatState,
Message, Message,
} from "./GlobalChatbox.types"; } from "./GlobalChatbox.types";
import { cloneBranchGroups, cloneMessages } from "./GlobalChatbox.utils"; import { cloneMessages } from "./GlobalChatbox.utils";
type BackendSessionPayload = { type BackendSessionPayload = {
id?: string; id?: string;
@@ -23,22 +22,16 @@ export const createEmptyChatState = (): LoadedChatState => ({
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: { const hasChatContent = (state: {
messages: Message[]; messages: Message[];
branchGroups: BranchGroup[];
sessionId?: string; sessionId?: string;
}) => }) =>
state.messages.length > 0 || state.messages.length > 0 ||
state.branchGroups.length > 0 ||
Boolean(state.sessionId); Boolean(state.sessionId);
const compareSessionsByAnchorTime = ( const compareSessionsByAnchorTime = (
@@ -107,7 +100,6 @@ const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatSta
is_title_manually_edited?: boolean; is_title_manually_edited?: boolean;
session_id?: string; session_id?: string;
messages?: Message[]; messages?: Message[];
branch_groups?: BranchGroup[];
is_streaming?: boolean; is_streaming?: boolean;
run_status?: string; run_status?: string;
}; };
@@ -116,7 +108,6 @@ const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatSta
isTitleManuallyEdited: payload.is_title_manually_edited ?? false, isTitleManuallyEdited: payload.is_title_manually_edited ?? false,
messages: sanitizeMessages(payload.messages), messages: sanitizeMessages(payload.messages),
sessionId: payload.session_id ?? payload.id, sessionId: payload.session_id ?? payload.id,
branchGroups: sanitizeBranchGroups(payload.branch_groups),
isStreaming: payload.is_streaming ?? false, isStreaming: payload.is_streaming ?? false,
runStatus: payload.run_status, runStatus: payload.run_status,
}; };
@@ -167,7 +158,6 @@ const saveBackendChatState = async (
title: normalizeTitle(state.title), title: normalizeTitle(state.title),
is_title_manually_edited: state.isTitleManuallyEdited ?? false, is_title_manually_edited: state.isTitleManuallyEdited ?? false,
messages: sanitizeMessages(state.messages), messages: sanitizeMessages(state.messages),
branch_groups: sanitizeBranchGroups(state.branchGroups),
}), }),
projectHeaderMode: "include", projectHeaderMode: "include",
userHeaderMode: "include", userHeaderMode: "include",
@@ -0,0 +1,456 @@
import type {
AgentQuestionRequest,
AgentTodoUpdate,
PermissionReply,
StreamEvent,
} from "@/lib/chatStream";
import type {
AgentPermissionRequest,
ChatProgress,
LoadedChatState,
Message,
} from "../GlobalChatbox.types";
import { createId } from "../GlobalChatbox.utils";
export const createPersistedStateKey = (state: LoadedChatState) =>
JSON.stringify({
title: state.title ?? null,
isTitleManuallyEdited: state.isTitleManuallyEdited ?? false,
sessionId: state.sessionId ?? null,
messages: state.messages,
});
export const upsertProgress = (
progress: ChatProgress[] | undefined,
event: StreamEvent & { type: "progress" },
) => {
const next = [...(progress ?? [])];
const index = next.findIndex((item) => item.id === event.id);
const existing = index >= 0 ? next[index] : undefined;
const now = Date.now();
const startedAt = event.startedAt ?? existing?.startedAt;
const isRunning = event.status === "running";
const endedAt = isRunning ? undefined : event.endedAt ?? existing?.endedAt ?? now;
const elapsedMs = isRunning
? event.elapsedMs ??
existing?.elapsedMs ??
(startedAt !== undefined ? Math.max(0, now - startedAt) : undefined)
: undefined;
const elapsedSnapshotAt = isRunning
? event.elapsedMs !== undefined
? now
: existing?.elapsedSnapshotAt ?? now
: undefined;
const durationMs = !isRunning
? event.durationMs ??
existing?.durationMs ??
(startedAt !== undefined && endedAt !== undefined
? Math.max(0, endedAt - startedAt)
: undefined)
: undefined;
const nextItem: ChatProgress = {
id: event.id,
phase: event.phase,
status: event.status,
title: event.title,
detail: event.detail,
startedAt,
endedAt,
elapsedMs,
elapsedSnapshotAt,
durationMs,
};
if (index >= 0) {
next[index] = nextItem;
} else {
next.push(nextItem);
}
return next;
};
export const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
progress?.map((item) => {
if (item.status !== "running") {
return item;
}
const endedAt = Date.now();
return {
...item,
status: "completed" as const,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs:
item.durationMs ??
(item.startedAt !== undefined
? Math.max(0, endedAt - item.startedAt)
: item.elapsedMs),
};
});
export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
todoUpdate
? {
...todoUpdate,
todos: todoUpdate.todos.map((todo) =>
todo.status === "pending" || todo.status === "in_progress"
? {
...todo,
status: "cancelled" as const,
updatedAt: Date.now(),
}
: todo,
),
}
: undefined;
export const upsertPermission = (
permissions: AgentPermissionRequest[] | undefined,
event: StreamEvent & { type: "permission_request" },
) => {
const next = [...(permissions ?? [])];
const index = next.findIndex((item) => item.requestId === event.requestId);
const nextItem: AgentPermissionRequest = {
requestId: event.requestId,
sessionId: event.sessionId,
permission: event.permission,
patterns: event.patterns,
target: event.target,
always: event.always,
tool: event.tool,
createdAt: event.createdAt,
status: "pending",
};
if (index >= 0) {
next[index] = {
...next[index],
...nextItem,
status: next[index].status === "submitting" ? "submitting" : nextItem.status,
};
} else {
next.push(nextItem);
}
return next;
};
export const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["status"] => {
if (reply === "always") return "approved_always";
if (reply === "once") return "approved_once";
return "rejected";
};
export const isActionableQuestionRequest = (question: {
requestId: string;
tool?: AgentQuestionRequest["tool"];
}) => Boolean(question.requestId && question.requestId !== question.tool?.callID);
export const toQuestionRequest = (
event: StreamEvent & { type: "question_request" },
status: AgentQuestionRequest["status"] = "pending",
): AgentQuestionRequest => ({
requestId: event.requestId,
sessionId: event.sessionId,
questions: event.questions,
tool: event.tool,
createdAt: event.createdAt,
status,
});
export const getQuestionContentSignature = (
questions: AgentQuestionRequest["questions"],
) =>
JSON.stringify(
questions.map((question) => ({
header: question.header,
question: question.question,
options: question.options.map((option) => ({
label: option.label,
description: option.description,
})),
multiple: question.multiple ?? false,
custom: question.custom !== false,
})),
);
export const isSameQuestionRequest = (
question: AgentQuestionRequest,
event: StreamEvent & { type: "question_request" },
) => {
if (question.requestId === event.requestId) return true;
if (question.tool?.callID && event.tool?.callID) {
return question.tool.callID === event.tool.callID;
}
return (
question.status === "pending" &&
question.sessionId === event.sessionId &&
getQuestionContentSignature(question.questions) ===
getQuestionContentSignature(event.questions)
);
};
export const isSameQuestionPair = (
left: AgentQuestionRequest,
right: AgentQuestionRequest,
) => {
if (left.requestId === right.requestId) return true;
if (left.tool?.callID && right.tool?.callID) {
return left.tool.callID === right.tool.callID;
}
return (
left.status === "pending" &&
right.status === "pending" &&
left.sessionId === right.sessionId &&
getQuestionContentSignature(left.questions) ===
getQuestionContentSignature(right.questions)
);
};
export const dedupeQuestionsAcrossMessages = (messages: Message[]) => {
const seen: AgentQuestionRequest[] = [];
let changed = false;
const nextMessages = messages.map((message) => {
if (!message.questions?.length) {
return message;
}
const nextQuestions = message.questions.filter((question) => {
if (seen.some((existing) => isSameQuestionPair(existing, question))) {
changed = true;
return false;
}
seen.push(question);
return true;
});
if (nextQuestions.length === message.questions.length) {
return message;
}
return {
...message,
questions: nextQuestions.length ? nextQuestions : undefined,
};
});
return changed ? nextMessages : messages;
};
export const upsertQuestionAcrossMessages = (
messages: Message[],
event: StreamEvent & { type: "question_request" },
assistantMessageId: string,
) => {
let existing: AgentQuestionRequest | undefined;
for (const message of messages) {
const match = message.questions?.find((question) =>
isSameQuestionRequest(question, event),
);
if (match) {
existing = match;
break;
}
}
const existingStatus: AgentQuestionRequest["status"] | undefined =
existing?.status === "submitting" ? "submitting" : undefined;
const nextQuestion =
existing &&
isActionableQuestionRequest(existing) &&
!isActionableQuestionRequest(event)
? {
...existing,
sessionId: event.sessionId,
questions: event.questions,
tool: event.tool ?? existing.tool,
createdAt: event.createdAt,
status: existingStatus ?? existing.status,
}
: toQuestionRequest(event, existingStatus ?? "pending");
const targetMessageId = existing
? messages.find((message) =>
message.questions?.some((question) => isSameQuestionRequest(question, event)),
)?.id ?? assistantMessageId
: assistantMessageId;
return messages.map((message) => {
const filteredQuestions = message.questions?.filter(
(question) => !isSameQuestionRequest(question, event),
);
if (message.id !== targetMessageId) {
return filteredQuestions?.length === message.questions?.length
? message
: {
...message,
questions: filteredQuestions?.length ? filteredQuestions : undefined,
};
}
const nextQuestions = [...(filteredQuestions ?? []), nextQuestion];
return {
...message,
questions: nextQuestions,
};
});
};
export const applyQuestionResponse = (
questions: AgentQuestionRequest[] | undefined,
event: StreamEvent & { type: "question_response" },
) =>
(questions ?? []).map((question) =>
question.requestId === event.requestId
? {
...question,
status: event.rejected ? "rejected" as const : "answered" as const,
answers: event.answers ?? question.answers,
repliedAt: Date.now(),
error: undefined,
}
: question,
);
export const createTodoUpdateFromEvent = (
event: StreamEvent & { type: "todo_update" },
): AgentTodoUpdate => ({
sessionId: event.sessionId,
messageId: event.messageId,
todos: event.todos,
createdAt: event.createdAt,
});
export const normalizeSessionTodos = (
messages: Message[],
nextTodoUpdate?: AgentTodoUpdate,
targetAssistantMessageId?: string,
) => {
let latestTodoUpdate = nextTodoUpdate;
if (!latestTodoUpdate) {
for (const message of messages) {
if (message.todos) {
latestTodoUpdate = message.todos;
}
}
}
if (!latestTodoUpdate) {
return messages;
}
const targetMessageId =
targetAssistantMessageId ??
[...messages].reverse().find((message) => message.role === "assistant")?.id;
if (!targetMessageId) {
return messages;
}
let changed = false;
const nextMessages = messages.map((message) => {
if (message.id === targetMessageId) {
if (message.todos === latestTodoUpdate) {
return message;
}
changed = true;
return {
...message,
todos: latestTodoUpdate,
};
}
if (!message.todos) {
return message;
}
changed = true;
return {
...message,
todos: undefined,
};
});
return changed ? nextMessages : messages;
};
export const abortOpenPermissionsAfterAbort = (
permissions: AgentPermissionRequest[] | undefined,
) => {
if (!permissions?.length) return permissions;
let changed = false;
const nextPermissions = permissions.map((permission) => {
if (
permission.status !== "pending" &&
permission.status !== "submitting" &&
permission.status !== "error"
) {
return permission;
}
changed = true;
return {
...permission,
status: "aborted" as const,
repliedAt: Date.now(),
error: undefined,
};
});
return changed ? nextPermissions : permissions;
};
export const rejectOpenQuestionsAfterAbort = (
questions: AgentQuestionRequest[] | undefined,
) => {
if (!questions?.length) return questions;
let changed = false;
const nextQuestions = questions.map((question) => {
if (
question.status !== "pending" &&
question.status !== "submitting" &&
question.status !== "error"
) {
return question;
}
changed = true;
return {
...question,
status: "rejected" as const,
repliedAt: Date.now(),
error: undefined,
};
});
return changed ? nextQuestions : questions;
};
export const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
const completedProgress = completeRunningProgress(message.progress);
const cancelledTodos = cancelRunningTodos(message.todos);
const abortedPermissions = abortOpenPermissionsAfterAbort(message.permissions);
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
const hasVisibleOutput =
message.content.trim().length > 0 ||
Boolean(message.artifacts?.length) ||
Boolean(abortedPermissions?.length) ||
Boolean(rejectedQuestions?.length) ||
Boolean(completedProgress?.length) ||
Boolean(cancelledTodos);
if (!hasVisibleOutput) {
return message;
}
return {
...message,
content: message.content || "⚠️ **请求已中断**",
isError: true,
progress: completedProgress,
permissions: abortedPermissions,
questions: rejectedQuestions,
todos: cancelledTodos,
};
};
export const createUserMessage = (content: string): Message => {
const id = createId();
return {
id,
role: "user",
content,
};
};
export const createAssistantMessage = (): Message => ({
id: createId(),
role: "assistant",
content: "",
});
@@ -0,0 +1,401 @@
"use client";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useAgentChatSession } from "./useAgentChatSession";
import {
abortAgentChat,
forkAgentChat,
replyAgentPermission,
replyAgentQuestion,
resumeAgentChatStream,
streamAgentChat,
} from "@/lib/chatStream";
import type { StreamEvent } from "@/lib/chatStream";
jest.mock("@/lib/chatStream", () => ({
abortAgentChat: jest.fn(async () => undefined),
forkAgentChat: jest.fn(async () => "forked-session"),
replyAgentPermission: jest.fn(async () => undefined),
replyAgentQuestion: jest.fn(async () => undefined),
resumeAgentChatStream: jest.fn(async () => undefined),
streamAgentChat: jest.fn(async () => undefined),
}));
const listChatSessions = jest.fn();
const deleteChatSession = jest.fn();
const saveActiveChatState = jest.fn();
const updateChatSessionTitle = jest.fn();
jest.mock("../chatStorage", () => ({
createEmptyChatState: jest.fn(() => ({
title: undefined,
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
})),
deleteChatSession: (...args: unknown[]) => deleteChatSession(...args),
listChatSessions: (...args: unknown[]) => listChatSessions(...args),
loadChatSessionById: jest.fn(async () => ({
title: "已存在会话",
isTitleManuallyEdited: false,
messages: [],
sessionId: "session-loaded",
})),
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
}));
describe("useAgentChatSession", () => {
beforeEach(() => {
listChatSessions.mockReset();
deleteChatSession.mockReset();
saveActiveChatState.mockReset();
updateChatSessionTitle.mockReset();
jest.mocked(abortAgentChat).mockReset();
jest.mocked(forkAgentChat).mockReset();
jest.mocked(replyAgentPermission).mockReset();
jest.mocked(replyAgentQuestion).mockReset();
jest.mocked(resumeAgentChatStream).mockReset();
jest.mocked(streamAgentChat).mockReset();
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
deleteChatSession.mockImplementation(async () => undefined);
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
updateChatSessionTitle.mockImplementation(async () => undefined);
});
describe("useAgentChatSession actions", () => {
it("tracks permission requests and submits replies", async () => {
listChatSessions.mockResolvedValue([]);
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
emitStreamEvent = onEvent;
await new Promise<void>(() => undefined);
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
await act(async () => {
void result.current.sendPrompt("删除临时文件");
await Promise.resolve();
});
act(() => {
emitStreamEvent?.({
type: "permission_request",
sessionId: "session-1",
requestId: "perm-1",
permission: "bash",
patterns: ["rm *"],
target: "rm tmp.txt",
always: ["rm *"],
createdAt: 123,
});
});
expect(result.current.messages.at(-1)?.permissions).toEqual([
expect.objectContaining({
requestId: "perm-1",
sessionId: "session-1",
status: "pending",
}),
]);
await act(async () => {
await result.current.replyPermission("perm-1", "once");
});
expect(replyAgentPermission).toHaveBeenCalledWith("session-1", "perm-1", "once");
expect(result.current.messages.at(-1)?.permissions?.[0]).toEqual(
expect.objectContaining({
requestId: "perm-1",
status: "approved_once",
}),
);
});
it("finalizes running progress when aborting an active prompt", async () => {
listChatSessions.mockResolvedValue([]);
jest.mocked(streamAgentChat).mockImplementationOnce(
({ onEvent, signal }) =>
new Promise<void>((_, reject) => {
onEvent({
type: "progress",
sessionId: "session-1",
id: "request-received",
phase: "start",
status: "running",
title: "开始分析",
startedAt: 1000,
} satisfies StreamEvent);
onEvent({
type: "todo_update",
sessionId: "session-1",
todos: [
{
id: "todo-1",
content: "分析水位",
status: "in_progress",
},
{
id: "todo-2",
content: "生成建议",
status: "pending",
},
],
createdAt: 1001,
} satisfies StreamEvent);
onEvent({
type: "permission_request",
sessionId: "session-1",
requestId: "perm-abort",
permission: "bash",
patterns: ["npm test"],
target: "npm test",
always: ["npm test"],
createdAt: 1002,
} satisfies StreamEvent);
onEvent({
type: "question_request",
sessionId: "session-1",
requestId: "question-abort",
questions: [
{
header: "范围",
question: "请选择范围",
options: [{ label: "城区", description: "中心城区" }],
},
],
createdAt: 1003,
} satisfies StreamEvent);
signal?.addEventListener("abort", () => {
reject(new Error("aborted"));
});
}),
);
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
act(() => {
void result.current.sendPrompt("测试中断");
});
await waitFor(() => expect(result.current.isStreaming).toBe(true));
act(() => {
result.current.abort();
});
await waitFor(() => expect(result.current.isStreaming).toBe(false));
expect(result.current.messages.at(-1)).toEqual(
expect.objectContaining({
role: "assistant",
content: "⚠️ **请求已中断**",
isError: true,
progress: [
expect.objectContaining({
id: "request-received",
status: "completed",
durationMs: expect.any(Number),
endedAt: expect.any(Number),
}),
],
todos: expect.objectContaining({
todos: [
expect.objectContaining({
id: "todo-1",
status: "cancelled",
updatedAt: expect.any(Number),
}),
expect.objectContaining({
id: "todo-2",
status: "cancelled",
updatedAt: expect.any(Number),
}),
],
}),
permissions: [
expect.objectContaining({
requestId: "perm-abort",
status: "aborted",
repliedAt: expect.any(Number),
error: undefined,
}),
],
questions: [
expect.objectContaining({
requestId: "question-abort",
status: "rejected",
repliedAt: expect.any(Number),
error: undefined,
}),
],
}),
);
expect(abortAgentChat).toHaveBeenCalledWith("session-1");
});
it("ignores generated session titles after the title was edited manually", async () => {
listChatSessions.mockResolvedValue([]);
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "session_title",
sessionId: "session-1",
title: "自动标题",
});
onEvent({
type: "done",
sessionId: "session-1",
});
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
await act(async () => {
await result.current.switchSession("session-loaded");
});
await act(async () => {
await result.current.renameSession("session-loaded", "手动标题");
});
await waitFor(() => expect(updateChatSessionTitle).toHaveBeenCalled());
await act(async () => {
await result.current.sendPrompt("帮我分析一下");
});
expect(result.current.sessionTitle).toBe("手动标题");
expect(updateChatSessionTitle).not.toHaveBeenCalledWith(
"session-loaded",
"自动标题",
expect.anything(),
);
});
it("does not apply a late generated title to a newly created session", async () => {
listChatSessions.mockResolvedValue([]);
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
let resolveStream: (() => void) | undefined;
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
emitStreamEvent = onEvent;
await new Promise<void>((resolve) => {
resolveStream = resolve;
});
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
await act(async () => {
void result.current.sendPrompt("帮我分析一下");
await Promise.resolve();
});
act(() => {
emitStreamEvent?.({
type: "done",
sessionId: "old-session",
});
});
await waitFor(() => expect(result.current.isStreaming).toBe(false));
act(() => {
result.current.createSession();
});
expect(result.current.sessionTitle).toBe("新对话");
await act(async () => {
emitStreamEvent?.({
type: "session_title",
sessionId: "old-session",
title: "旧请求标题",
});
resolveStream?.();
await Promise.resolve();
});
expect(result.current.sessionTitle).toBe("新对话");
expect(updateChatSessionTitle).toHaveBeenCalledWith(
"old-session",
"旧请求标题",
{ isTitleManuallyEdited: false },
);
});
it("forks a copied conversation from an assistant message", async () => {
listChatSessions.mockResolvedValue([]);
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
await act(async () => {
await result.current.sendPrompt("第一轮");
});
const firstAssistantMessageId = result.current.messages[1]?.id ?? "";
await act(async () => {
await result.current.createBranch(firstAssistantMessageId);
});
expect(forkAgentChat).toHaveBeenCalledWith(undefined, 2);
expect(result.current.activeSessionId).toBe("forked-session");
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0]).toEqual(
expect.objectContaining({
role: "user",
content: "第一轮",
}),
);
expect(result.current.messages[1]).toEqual(
expect.objectContaining({
role: "assistant",
}),
);
expect(streamAgentChat).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,791 @@
"use client";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useAgentChatSession } from "./useAgentChatSession";
import {
abortAgentChat,
forkAgentChat,
replyAgentPermission,
replyAgentQuestion,
resumeAgentChatStream,
streamAgentChat,
} from "@/lib/chatStream";
import type { StreamEvent } from "@/lib/chatStream";
jest.mock("@/lib/chatStream", () => ({
abortAgentChat: jest.fn(async () => undefined),
forkAgentChat: jest.fn(async () => "forked-session"),
replyAgentPermission: jest.fn(async () => undefined),
replyAgentQuestion: jest.fn(async () => undefined),
resumeAgentChatStream: jest.fn(async () => undefined),
streamAgentChat: jest.fn(async () => undefined),
}));
const listChatSessions = jest.fn();
const deleteChatSession = jest.fn();
const saveActiveChatState = jest.fn();
const updateChatSessionTitle = jest.fn();
jest.mock("../chatStorage", () => ({
createEmptyChatState: jest.fn(() => ({
title: undefined,
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
})),
deleteChatSession: (...args: unknown[]) => deleteChatSession(...args),
listChatSessions: (...args: unknown[]) => listChatSessions(...args),
loadChatSessionById: jest.fn(async () => ({
title: "已存在会话",
isTitleManuallyEdited: false,
messages: [],
sessionId: "session-loaded",
})),
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
}));
describe("useAgentChatSession", () => {
beforeEach(() => {
listChatSessions.mockReset();
deleteChatSession.mockReset();
saveActiveChatState.mockReset();
updateChatSessionTitle.mockReset();
jest.mocked(abortAgentChat).mockReset();
jest.mocked(forkAgentChat).mockReset();
jest.mocked(replyAgentPermission).mockReset();
jest.mocked(replyAgentQuestion).mockReset();
jest.mocked(resumeAgentChatStream).mockReset();
jest.mocked(streamAgentChat).mockReset();
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
deleteChatSession.mockImplementation(async () => undefined);
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
updateChatSessionTitle.mockImplementation(async () => undefined);
});
describe("useAgentChatSession lifecycle and resume", () => {
it("does not add a new empty session to history until there is actual chat content", async () => {
listChatSessions.mockResolvedValue([]);
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
act(() => {
void result.current.createSession();
});
await waitFor(() => expect(result.current.sessionTitle).toBe("新对话"));
expect(result.current.chatSessions).toEqual([]);
expect(result.current.activeSessionId).toBeUndefined();
expect(result.current.messages).toEqual([]);
expect(result.current.isStreaming).toBe(false);
expect(listChatSessions).toHaveBeenCalledTimes(1);
});
it("keeps existing history entries when creating a blank new session", async () => {
listChatSessions.mockResolvedValue([
{
id: "session-1",
title: "已有会话",
createdAt: 1,
updatedAt: 1,
},
]);
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
act(() => {
void result.current.createSession();
});
expect(result.current.chatSessions).toEqual([
{
id: "session-1",
title: "已有会话",
createdAt: 1,
updatedAt: 1,
},
]);
});
it("removes a deleted history entry before the backend delete finishes", async () => {
const initialSessions = [
{
id: "session-1",
title: "第一段会话",
createdAt: 2,
updatedAt: 2,
},
{
id: "session-2",
title: "第二段会话",
createdAt: 1,
updatedAt: 1,
},
];
let resolveDelete: ((nextActiveSessionId?: string) => void) | undefined;
listChatSessions.mockResolvedValue(initialSessions);
deleteChatSession.mockImplementationOnce(
() =>
new Promise<string | undefined>((resolve) => {
resolveDelete = resolve;
}),
);
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
act(() => {
void result.current.removeSession("session-2");
});
expect(result.current.chatSessions).toEqual([
expect.objectContaining({ id: "session-1" }),
]);
listChatSessions.mockResolvedValue([
{
id: "session-1",
title: "第一段会话",
createdAt: 2,
updatedAt: 2,
},
]);
await act(async () => {
resolveDelete?.();
await Promise.resolve();
});
await waitFor(() =>
expect(result.current.chatSessions).toEqual([
expect.objectContaining({ id: "session-1" }),
]),
);
});
it("persists a new conversation only after the stream is done", async () => {
listChatSessions.mockResolvedValue([]);
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
emitStreamEvent = onEvent;
await new Promise<void>(() => undefined);
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
jest.useFakeTimers();
try {
await act(async () => {
void result.current.sendPrompt("第一条消息");
await Promise.resolve();
});
expect(result.current.isStreaming).toBe(true);
await act(async () => {
jest.advanceTimersByTime(200);
});
expect(saveActiveChatState).not.toHaveBeenCalled();
act(() => {
emitStreamEvent?.({
type: "token",
sessionId: "chat-stream-1",
content: "收到",
});
});
await act(async () => {
jest.advanceTimersByTime(200);
});
expect(saveActiveChatState).not.toHaveBeenCalled();
act(() => {
emitStreamEvent?.({
type: "done",
sessionId: "chat-stream-1",
});
});
await act(async () => {
jest.advanceTimersByTime(200);
});
await waitFor(() => expect(saveActiveChatState).toHaveBeenCalledTimes(1));
expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({
sessionId: "chat-stream-1",
messages: [
expect.objectContaining({ role: "user", content: "第一条消息" }),
expect.objectContaining({ role: "assistant", content: "收到" }),
],
});
} finally {
jest.useRealTimers();
}
});
it("shows shared todo state only on the latest assistant message in a session", async () => {
listChatSessions.mockResolvedValue([]);
jest.mocked(streamAgentChat)
.mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "todo_update",
sessionId: "session-1",
todos: [
{
id: "todo-1",
content: "创建任务列表",
status: "in_progress",
},
],
createdAt: 1000,
});
onEvent({
type: "done",
sessionId: "session-1",
});
})
.mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "todo_update",
sessionId: "session-1",
todos: [
{
id: "todo-1",
content: "创建任务列表",
status: "completed",
},
{
id: "todo-2",
content: "更新任务状态",
status: "in_progress",
},
],
createdAt: 2000,
});
onEvent({
type: "done",
sessionId: "session-1",
});
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
await act(async () => {
await result.current.sendPrompt("创建任务");
});
await waitFor(() => expect(result.current.isStreaming).toBe(false));
await act(async () => {
await result.current.sendPrompt("更新任务");
});
await waitFor(() => expect(result.current.isStreaming).toBe(false));
const assistantMessages = result.current.messages.filter(
(message) => message.role === "assistant",
);
expect(assistantMessages).toHaveLength(2);
expect(assistantMessages[0].todos).toBeUndefined();
expect(assistantMessages[1].todos).toEqual(
expect.objectContaining({
sessionId: "session-1",
createdAt: 2000,
todos: [
expect.objectContaining({
id: "todo-1",
status: "completed",
}),
expect.objectContaining({
id: "todo-2",
status: "in_progress",
}),
],
}),
);
});
it("hydrates a backend streaming session and resumes its stream", async () => {
listChatSessions.mockResolvedValue([
{
id: "session-streaming",
title: "运行中",
createdAt: 1,
updatedAt: 2,
isStreaming: true,
runStatus: "running",
},
]);
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
expect(result.current.isStreaming).toBe(true);
expect(result.current.activeSessionId).toBe("session-loaded");
expect(resumeAgentChatStream).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "session-loaded",
}),
);
});
it("updates resumed messages from state, token, and done events", async () => {
listChatSessions.mockResolvedValue([
{
id: "session-streaming",
title: "运行中",
createdAt: 1,
updatedAt: 2,
isStreaming: true,
},
]);
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "state",
sessionId: "session-loaded",
messages: [
{ id: "u1", role: "user", content: "继续分析" },
{ id: "a1", role: "assistant", content: "已有" },
],
isStreaming: true,
runStatus: "running",
});
onEvent({
type: "token",
sessionId: "session-loaded",
content: "输出",
});
onEvent({
type: "done",
sessionId: "session-loaded",
});
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
await waitFor(() => expect(result.current.isStreaming).toBe(false));
expect(result.current.messages).toEqual([
expect.objectContaining({ id: "u1", role: "user", content: "继续分析" }),
expect.objectContaining({ id: "a1", role: "assistant", content: "已有输出" }),
]);
});
it("applies question responses to the message that owns the request", async () => {
listChatSessions.mockResolvedValue([
{
id: "session-streaming",
title: "运行中",
createdAt: 1,
updatedAt: 2,
isStreaming: true,
},
]);
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "state",
sessionId: "session-loaded",
messages: [
{ id: "u1", role: "user", content: "继续分析" },
{
id: "a1",
role: "assistant",
content: "需要确认",
questions: [
{
requestId: "q-1",
sessionId: "session-loaded",
questions: [
{
header: "范围",
question: "选择范围",
options: [],
custom: true,
},
],
createdAt: 123,
status: "pending",
},
],
},
{ id: "a2", role: "assistant", content: "后续消息" },
],
isStreaming: true,
runStatus: "running",
});
onEvent({
type: "question_response",
sessionId: "session-loaded",
requestId: "q-1",
answers: [["城区"]],
rejected: false,
});
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
expect(result.current.messages[1].questions?.[0]).toEqual(
expect.objectContaining({
requestId: "q-1",
status: "answered",
answers: [["城区"]],
}),
);
expect(result.current.messages[2].questions).toBeUndefined();
});
it("deduplicates question requests across assistant messages", async () => {
listChatSessions.mockResolvedValue([
{
id: "session-streaming",
title: "运行中",
createdAt: 1,
updatedAt: 2,
isStreaming: true,
},
]);
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "state",
sessionId: "session-loaded",
messages: [
{ id: "u1", role: "user", content: "继续分析" },
{
id: "a1",
role: "assistant",
content: "需要确认",
questions: [
{
requestId: "question-1",
sessionId: "session-loaded",
questions: [
{
header: "测试问题",
question: "你觉得这个 question 工具好用吗?",
options: [
{
label: "非常好用",
description: "交互清晰,选项方便",
},
],
},
],
tool: {
messageID: "message-1",
callID: "call-1",
},
createdAt: 123,
status: "pending",
},
],
},
{ id: "a2", role: "assistant", content: "后续消息" },
],
isStreaming: true,
runStatus: "running",
});
onEvent({
type: "question_request",
sessionId: "session-loaded",
requestId: "call-1",
questions: [
{
header: "测试问题",
question: "你觉得这个 question 工具好用吗?",
options: [
{
label: "非常好用",
description: "交互清晰,选项方便",
},
],
},
],
tool: {
messageID: "message-1",
callID: "call-1",
},
createdAt: 456,
});
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
const allQuestions = result.current.messages.flatMap(
(message) => message.questions ?? [],
);
expect(allQuestions).toHaveLength(1);
expect(result.current.messages[1].questions?.[0]).toEqual(
expect.objectContaining({
requestId: "question-1",
tool: expect.objectContaining({ callID: "call-1" }),
}),
);
expect(result.current.messages[2].questions).toBeUndefined();
});
it("keeps the actionable question request id when a tool-part duplicate arrives later", async () => {
listChatSessions.mockResolvedValue([
{
id: "session-streaming",
title: "运行中",
createdAt: 1,
updatedAt: 2,
isStreaming: true,
},
]);
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "state",
sessionId: "session-loaded",
messages: [
{ id: "u1", role: "user", content: "继续分析" },
{
id: "a1",
role: "assistant",
content: "需要确认",
questions: [
{
requestId: "question-1",
sessionId: "session-loaded",
questions: [
{
header: "测试问题",
question: "你觉得这个 question 工具好用吗?",
options: [
{
label: "非常好用",
description: "交互清晰,选项方便",
},
],
},
],
tool: {
messageID: "message-1",
callID: "call-1",
},
createdAt: 123,
status: "pending",
},
],
},
],
isStreaming: true,
runStatus: "running",
});
onEvent({
type: "question_request",
sessionId: "session-loaded",
requestId: "call-1",
questions: [
{
header: "测试问题",
question: "你觉得这个 question 工具好用吗?",
options: [
{
label: "非常好用",
description: "交互清晰,选项方便",
},
],
},
],
tool: {
messageID: "message-1",
callID: "call-1",
},
createdAt: 456,
});
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
const allQuestions = result.current.messages.flatMap(
(message) => message.questions ?? [],
);
expect(allQuestions).toHaveLength(1);
expect(allQuestions[0]).toEqual(
expect.objectContaining({
requestId: "question-1",
tool: expect.objectContaining({ callID: "call-1" }),
}),
);
});
it("deduplicates persisted duplicate questions from state events", async () => {
listChatSessions.mockResolvedValue([
{
id: "session-streaming",
title: "运行中",
createdAt: 1,
updatedAt: 2,
isStreaming: true,
},
]);
const duplicateQuestion = {
sessionId: "session-loaded",
questions: [
{
header: "测试问题",
question: "你觉得这个 question 工具好用吗?",
options: [
{
label: "非常好用",
description: "交互清晰,选项方便",
},
],
},
],
tool: {
messageID: "message-1",
callID: "call-1",
},
createdAt: 123,
status: "pending" as const,
};
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "state",
sessionId: "session-loaded",
messages: [
{ id: "u1", role: "user", content: "继续分析" },
{
id: "a1",
role: "assistant",
content: "需要确认",
questions: [{ ...duplicateQuestion, requestId: "question-1" }],
},
{
id: "a2",
role: "assistant",
content: "后续消息",
questions: [{ ...duplicateQuestion, requestId: "call-1" }],
},
],
isStreaming: true,
runStatus: "running",
});
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
expect(
result.current.messages.flatMap((message) => message.questions ?? []),
).toHaveLength(1);
expect(result.current.messages[1].questions).toHaveLength(1);
expect(result.current.messages[2].questions).toBeUndefined();
});
it("aborts a resumed streaming session through the backend abort endpoint", async () => {
listChatSessions.mockResolvedValue([
{
id: "session-streaming",
title: "运行中",
createdAt: 1,
updatedAt: 2,
isStreaming: true,
},
]);
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async () => {
await new Promise<void>(() => undefined);
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isStreaming).toBe(true));
act(() => {
result.current.abort();
});
expect(abortAgentChat).toHaveBeenCalledWith("session-loaded");
});
});
});
@@ -1,334 +1,2 @@
"use client"; // Tests for useAgentChatSession are split by behavior boundary.
// See useAgentChatSession.lifecycle.test.tsx and useAgentChatSession.actions.test.tsx.
import { act, renderHook, waitFor } from "@testing-library/react";
import { useAgentChatSession } from "./useAgentChatSession";
import { abortAgentChat, 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"),
resumeAgentChatStream: jest.fn(async () => undefined),
streamAgentChat: jest.fn(async () => undefined),
}));
const listChatSessions = jest.fn();
const saveActiveChatState = jest.fn();
const updateChatSessionTitle = jest.fn();
jest.mock("../chatStorage", () => ({
createEmptyChatState: jest.fn(() => ({
title: undefined,
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
branchGroups: [],
})),
deleteChatSession: jest.fn(async () => undefined),
listChatSessions: (...args: unknown[]) => listChatSessions(...args),
loadChatSessionById: jest.fn(async () => ({
title: "已存在会话",
isTitleManuallyEdited: false,
messages: [],
sessionId: "session-loaded",
branchGroups: [],
})),
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
}));
describe("useAgentChatSession", () => {
beforeEach(() => {
listChatSessions.mockReset();
saveActiveChatState.mockReset();
updateChatSessionTitle.mockReset();
jest.mocked(abortAgentChat).mockReset();
jest.mocked(resumeAgentChatStream).mockReset();
jest.mocked(streamAgentChat).mockReset();
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
});
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("persists a new conversation only after the stream is done", async () => {
listChatSessions.mockResolvedValue([]);
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
emitStreamEvent = onEvent;
await new Promise<void>(() => undefined);
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
jest.useFakeTimers();
try {
await act(async () => {
void result.current.sendPrompt("第一条消息");
await Promise.resolve();
});
expect(result.current.isStreaming).toBe(true);
await act(async () => {
jest.advanceTimersByTime(200);
});
expect(saveActiveChatState).not.toHaveBeenCalled();
act(() => {
emitStreamEvent?.({
type: "token",
sessionId: "chat-stream-1",
content: "收到",
});
});
await act(async () => {
jest.advanceTimersByTime(200);
});
expect(saveActiveChatState).not.toHaveBeenCalled();
act(() => {
emitStreamEvent?.({
type: "done",
sessionId: "chat-stream-1",
});
});
await act(async () => {
jest.advanceTimersByTime(200);
});
await waitFor(() => expect(saveActiveChatState).toHaveBeenCalledTimes(1));
expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({
sessionId: "chat-stream-1",
messages: [
expect.objectContaining({ role: "user", content: "第一条消息" }),
expect.objectContaining({ role: "assistant", content: "收到" }),
],
});
} finally {
jest.useRealTimers();
}
});
it("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("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");
});
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(),
);
});
});
+381 -383
View File
@@ -2,158 +2,20 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
abortAgentChat, import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
forkAgentChat, import type { AgentArtifact, ChatSessionSummary, LoadedChatState, Message } from "../GlobalChatbox.types";
resumeAgentChatStream, import { cloneMessages } from "../GlobalChatbox.utils";
streamAgentChat, import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, saveActiveChatState, updateChatSessionTitle } from "../chatStorage";
} from "@/lib/chatStream"; import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createPersistedStateKey, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
import type { AgentModel, StreamEvent } from "@/lib/chatStream"; import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
import type {
AgentArtifact,
BranchGroup,
BranchTransition,
ChatProgress,
ChatSessionSummary,
LoadedChatState,
Message,
} from "../GlobalChatbox.types";
import {
cloneBranchGroups,
cloneMessages,
createId,
} from "../GlobalChatbox.utils";
import {
createEmptyChatState,
deleteChatSession,
listChatSessions,
loadChatSessionById,
saveActiveChatState,
updateChatSessionTitle,
} from "../chatStorage";
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;
};
type PromptRunOptions = {
prompt: string;
sessionIdOverride?: string;
preparedMessages?: Message[];
userMessage?: Message;
assistantMessage?: Message;
};
const createPersistedStateKey = (state: LoadedChatState) =>
JSON.stringify({
title: state.title ?? null,
isTitleManuallyEdited: state.isTitleManuallyEdited ?? false,
sessionId: state.sessionId ?? null,
messages: state.messages,
branchGroups: state.branchGroups,
});
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;
};
const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
progress?.map((item) => {
if (item.status !== "running") {
return item;
}
const endedAt = Date.now();
return {
...item,
status: "completed" as const,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs:
item.durationMs ??
(item.startedAt !== undefined
? Math.max(0, endedAt - item.startedAt)
: item.elapsedMs),
};
});
const createUserMessage = (content: string, branchRootId?: string): Message => {
const id = createId();
return {
id,
role: "user",
content,
branchRootId: branchRootId ?? id,
};
};
const createAssistantMessage = (): Message => ({
id: createId(),
role: "assistant",
content: "",
});
const messagesEqual = (left: Message[], right: Message[]) =>
JSON.stringify(left) === JSON.stringify(right);
export const useAgentChatSession = ({ export const useAgentChatSession = ({
projectId, projectId,
onToolCall, onToolCall,
onBeforeSend, onBeforeSend,
getModel, getModel,
getApprovalMode,
}: UseAgentChatSessionOptions) => { }: UseAgentChatSessionOptions) => {
const hydrationCompletedRef = useRef(false); const hydrationCompletedRef = useRef(false);
const hydrationNonceRef = useRef(0); const hydrationNonceRef = useRef(0);
@@ -162,9 +24,7 @@ export const useAgentChatSession = ({
const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined); const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined);
const [isSessionTitleManuallyEdited, setIsSessionTitleManuallyEdited] = useState(false); const [isSessionTitleManuallyEdited, setIsSessionTitleManuallyEdited] = useState(false);
const [sessionId, setSessionId] = useState<string | undefined>(undefined); const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [branchGroups, setBranchGroups] = useState<BranchGroup[]>([]);
const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]); const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
const [branchTransition, setBranchTransition] = useState<BranchTransition | null>(null);
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
const [isHydrating, setIsHydrating] = useState(true); const [isHydrating, setIsHydrating] = useState(true);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
@@ -180,7 +40,6 @@ export const useAgentChatSession = ({
title: undefined, title: undefined,
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [], messages: [],
branchGroups: [],
}), }),
); );
@@ -192,6 +51,7 @@ export const useAgentChatSession = ({
messagesRef.current = messages; messagesRef.current = messages;
}, [messages]); }, [messages]);
useEffect(() => { useEffect(() => {
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited; isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
}, [isSessionTitleManuallyEdited]); }, [isSessionTitleManuallyEdited]);
@@ -210,17 +70,14 @@ export const useAgentChatSession = ({
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [], messages: [],
sessionId: undefined, sessionId: undefined,
branchGroups: [],
}); });
hydrationCompletedRef.current = true; hydrationCompletedRef.current = true;
hydrationNonceRef.current += 1; hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1; titleUpdateNonceRef.current += 1;
setBranchTransition(null);
setMessages([]); setMessages([]);
setSessionTitle(undefined); setSessionTitle(undefined);
setIsSessionTitleManuallyEdited(false); setIsSessionTitleManuallyEdited(false);
setSessionId(undefined); setSessionId(undefined);
setBranchGroups([]);
setChatSessions([]); setChatSessions([]);
setIsHydrating(false); setIsHydrating(false);
return; return;
@@ -240,11 +97,12 @@ export const useAgentChatSession = ({
hydrationNonceRef.current += 1; hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1; titleUpdateNonceRef.current += 1;
setMessages(loadedState.messages); setMessages(
normalizeSessionTodos(dedupeQuestionsAcrossMessages(loadedState.messages)),
);
setSessionTitle(loadedState.title); setSessionTitle(loadedState.title);
setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false); setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false);
setSessionId(loadedState.sessionId); setSessionId(loadedState.sessionId);
setBranchGroups(loadedState.branchGroups);
setChatSessions(sessions); setChatSessions(sessions);
if ( if (
loadedState.sessionId && loadedState.sessionId &&
@@ -282,7 +140,6 @@ export const useAgentChatSession = ({
isTitleManuallyEdited: isSessionTitleManuallyEdited, isTitleManuallyEdited: isSessionTitleManuallyEdited,
messages, messages,
sessionId, sessionId,
branchGroups,
}; };
const currentStateKey = createPersistedStateKey(state); const currentStateKey = createPersistedStateKey(state);
@@ -312,46 +169,7 @@ export const useAgentChatSession = ({
return () => { return () => {
window.clearTimeout(persistTimer); window.clearTimeout(persistTimer);
}; };
}, [branchGroups, isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]); }, [isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]);
useEffect(() => {
setBranchGroups((prev) => {
let changed = false;
const next = prev.map((group) => {
const rootMessage = messages[group.parentCount];
if (
!rootMessage ||
rootMessage.role !== "user" ||
(rootMessage.branchRootId ?? rootMessage.id) !== group.rootMessageId
) {
return group;
}
const activeBranch = group.branches[group.activeIndex];
if (!activeBranch) {
return group;
}
const nextSuffix = cloneMessages(messages.slice(group.parentCount));
if (
activeBranch.sessionId === sessionId &&
messagesEqual(activeBranch.messages, nextSuffix)
) {
return group;
}
changed = true;
const branches = group.branches.map((branch, index) =>
index === group.activeIndex
? { ...branch, sessionId, messages: nextSuffix }
: branch,
);
return { ...group, branches };
});
return changed ? next : prev;
});
}, [messages, sessionId]);
const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => { const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => {
setMessages((prev) => setMessages((prev) =>
@@ -380,19 +198,58 @@ export const useAgentChatSession = ({
assistantMessageId?: string; assistantMessageId?: string;
}, },
) => { ) => {
if ("sessionId" in event && event.sessionId && event.sessionId !== sessionIdRef.current) { if (
event.type !== "session_title" &&
"sessionId" in event &&
event.sessionId &&
event.sessionId !== sessionIdRef.current
) {
sessionIdRef.current = event.sessionId; sessionIdRef.current = event.sessionId;
setSessionId(event.sessionId); setSessionId(event.sessionId);
} }
if (event.type === "state") { if (event.type === "state") {
const nextMessages = cloneMessages(event.messages as Message[]); const nextMessages = normalizeSessionTodos(
dedupeQuestionsAcrossMessages(cloneMessages(event.messages as Message[])),
);
messagesRef.current = nextMessages; messagesRef.current = nextMessages;
setMessages(nextMessages); setMessages(nextMessages);
setIsStreaming(event.isStreaming); setIsStreaming(event.isStreaming);
return; return;
} }
if (event.type === "session_title") {
const nextTitle = event.title.trim();
if (nextTitle && !isSessionTitleManuallyEditedRef.current) {
const currentSessionId = sessionIdRef.current;
const targetSessionId = event.sessionId || currentSessionId;
if (targetSessionId === currentSessionId) {
setSessionTitle(nextTitle);
lastPersistedStateKeyRef.current = createPersistedStateKey({
sessionId: targetSessionId,
title: nextTitle,
isTitleManuallyEdited: false,
messages: messagesRef.current,
});
}
if (targetSessionId) {
const currentNonce = ++titleUpdateNonceRef.current;
void updateChatSessionTitle(targetSessionId, nextTitle, {
isTitleManuallyEdited: false,
})
.then(() => listChatSessions())
.then((sessions) => {
if (titleUpdateNonceRef.current !== currentNonce) return;
setChatSessions(sessions);
})
.catch((error) => {
console.error("[GlobalChatbox] Failed to persist session title:", error);
});
}
}
return;
}
const assistantMessageId = getLastAssistantMessageId(options?.assistantMessageId); const assistantMessageId = getLastAssistantMessageId(options?.assistantMessageId);
if (!assistantMessageId) { if (!assistantMessageId) {
return; return;
@@ -423,26 +280,61 @@ export const useAgentChatSession = ({
assistantMessageId, assistantMessageId,
appendArtifact, appendArtifact,
}); });
} else if (event.type === "session_title") { } else if (event.type === "permission_request") {
const nextTitle = event.title.trim(); setMessages((prev) =>
if (nextTitle && !isSessionTitleManuallyEditedRef.current) { prev.map((message) =>
setSessionTitle(nextTitle); message.id === assistantMessageId
const currentSessionId = sessionIdRef.current; ? {
if (currentSessionId) { ...message,
const currentNonce = ++titleUpdateNonceRef.current; permissions: upsertPermission(message.permissions, event),
void updateChatSessionTitle(currentSessionId, nextTitle, { }
isTitleManuallyEdited: false, : message,
}) ),
.then(() => listChatSessions()) );
.then((sessions) => { } else if (event.type === "permission_response") {
if (titleUpdateNonceRef.current !== currentNonce) return; setMessages((prev) =>
setChatSessions(sessions); prev.map((message) => {
}) if (message.id !== assistantMessageId || !message.permissions?.length) {
.catch((error) => { return message;
console.error("[GlobalChatbox] Failed to persist session title:", error); }
}); return {
} ...message,
} permissions: message.permissions.map((permission) =>
permission.requestId === event.requestId
? {
...permission,
status: toPermissionStatus(event.reply),
repliedAt: Date.now(),
error: undefined,
}
: permission,
),
};
}),
);
} else if (event.type === "question_request") {
setMessages((prev) =>
upsertQuestionAcrossMessages(prev, event, assistantMessageId),
);
} else if (event.type === "question_response") {
setMessages((prev) =>
prev.map((message) =>
message.questions?.some((question) => question.requestId === event.requestId)
? {
...message,
questions: applyQuestionResponse(message.questions, event),
}
: message,
),
);
} else if (event.type === "todo_update") {
setMessages((prev) =>
normalizeSessionTodos(
prev,
createTodoUpdateFromEvent(event),
assistantMessageId,
),
);
} else if (event.type === "done") { } else if (event.type === "done") {
setMessages((prev) => setMessages((prev) =>
prev.map((message) => { prev.map((message) => {
@@ -472,6 +364,7 @@ export const useAgentChatSession = ({
content: message.content || `⚠️ **错误:** ${event.message}`, content: message.content || `⚠️ **错误:** ${event.message}`,
isError: true, isError: true,
progress: completeRunningProgress(message.progress), progress: completeRunningProgress(message.progress),
todos: cancelRunningTodos(message.todos),
} }
: message, : message,
), ),
@@ -523,7 +416,6 @@ export const useAgentChatSession = ({
await cancelPromiseRef.current?.catch(() => undefined); await cancelPromiseRef.current?.catch(() => undefined);
onBeforeSend?.(); onBeforeSend?.();
setBranchTransition(null);
const nextUserMessage = userMessage ?? createUserMessage(prompt); const nextUserMessage = userMessage ?? createUserMessage(prompt);
const nextAssistantMessage = assistantMessage ?? createAssistantMessage(); const nextAssistantMessage = assistantMessage ?? createAssistantMessage();
@@ -548,6 +440,7 @@ export const useAgentChatSession = ({
message: prompt, message: prompt,
sessionId: sessionIdOverride ?? sessionIdRef.current, sessionId: sessionIdOverride ?? sessionIdRef.current,
model: getModel?.(), model: getModel?.(),
approvalMode: getApprovalMode?.(),
signal: controller.signal, signal: controller.signal,
onEvent: (event) => onEvent: (event) =>
applyStreamEvent(event, { applyStreamEvent(event, {
@@ -560,11 +453,7 @@ export const useAgentChatSession = ({
prev prev
.map((message) => .map((message) =>
message.id === nextAssistantMessage.id message.id === nextAssistantMessage.id
? { ? finalizeAssistantMessageAfterAbort(message)
...message,
content: message.content || "⚠️ **请求已中断**",
isError: true,
}
: message, : message,
) )
.filter( .filter(
@@ -574,7 +463,8 @@ export const useAgentChatSession = ({
message.role === "assistant" && message.role === "assistant" &&
message.content.trim().length === 0 && message.content.trim().length === 0 &&
!(message.artifacts?.length) && !(message.artifacts?.length) &&
!(message.progress?.length) !(message.progress?.length) &&
!message.todos
), ),
), ),
); );
@@ -598,13 +488,32 @@ export const useAgentChatSession = ({
setIsStreaming(false); setIsStreaming(false);
} }
}, },
[applyStreamEvent, getModel, isHydrating, isStreaming, messages, onBeforeSend], [
applyStreamEvent,
getApprovalMode,
getModel,
isHydrating,
isStreaming,
messages,
onBeforeSend,
],
); );
const abort = useCallback(() => { const abort = useCallback(() => {
const controller = abortRef.current; const controller = abortRef.current;
controller?.abort(); controller?.abort();
setIsStreaming(false); setIsStreaming(false);
const assistantMessageId = getLastAssistantMessageId();
if (assistantMessageId) {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? finalizeAssistantMessageAfterAbort(message)
: message,
),
);
}
const cancelPromise = abortAgentChat(sessionIdRef.current).catch((error) => { const cancelPromise = abortAgentChat(sessionIdRef.current).catch((error) => {
console.error("[GlobalChatbox] Failed to abort agent session:", error); console.error("[GlobalChatbox] Failed to abort agent session:", error);
@@ -615,14 +524,221 @@ export const useAgentChatSession = ({
} }
}); });
cancelPromiseRef.current = trackedCancelPromise; cancelPromiseRef.current = trackedCancelPromise;
}, []); }, [getLastAssistantMessageId]);
const replyPermission = useCallback(
async (requestId: string, reply: PermissionReply) => {
const target = messagesRef.current
.flatMap((message) => message.permissions ?? [])
.find((permission) => permission.requestId === requestId);
if (!target || target.status === "submitting") {
return;
}
setMessages((prev) =>
prev.map((message) =>
!message.permissions?.some((permission) => permission.requestId === requestId)
? message
: {
...message,
permissions: message.permissions.map((permission) =>
permission.requestId === requestId
? { ...permission, status: "submitting", error: undefined }
: permission,
),
},
),
);
try {
await replyAgentPermission(target.sessionId, requestId, reply);
setMessages((prev) =>
prev.map((message) =>
!message.permissions?.some((permission) => permission.requestId === requestId)
? message
: {
...message,
permissions: message.permissions.map((permission) =>
permission.requestId === requestId
? {
...permission,
status: toPermissionStatus(reply),
repliedAt: Date.now(),
error: undefined,
}
: permission,
),
},
),
);
} catch (error) {
setMessages((prev) =>
prev.map((message) =>
!message.permissions?.some((permission) => permission.requestId === requestId)
? message
: {
...message,
permissions: message.permissions.map((permission) =>
permission.requestId === requestId
? {
...permission,
status: "error",
error: error instanceof Error ? error.message : String(error),
}
: permission,
),
},
),
);
}
},
[],
);
const replyQuestion = useCallback(
async (requestId: string, answers: string[][]) => {
const target = messagesRef.current
.flatMap((message) => message.questions ?? [])
.find((question) => question.requestId === requestId);
if (!target || target.status === "submitting") {
return;
}
setMessages((prev) =>
prev.map((message) =>
!message.questions?.some((question) => question.requestId === requestId)
? message
: {
...message,
questions: message.questions.map((question) =>
question.requestId === requestId
? { ...question, status: "submitting", error: undefined }
: question,
),
},
),
);
try {
await replyAgentQuestion(target.sessionId, requestId, answers);
setMessages((prev) =>
prev.map((message) =>
!message.questions?.some((question) => question.requestId === requestId)
? message
: {
...message,
questions: message.questions.map((question) =>
question.requestId === requestId
? {
...question,
status: "answered",
answers,
repliedAt: Date.now(),
error: undefined,
}
: question,
),
},
),
);
} catch (error) {
setMessages((prev) =>
prev.map((message) =>
!message.questions?.some((question) => question.requestId === requestId)
? message
: {
...message,
questions: message.questions.map((question) =>
question.requestId === requestId
? {
...question,
status: "error",
error: error instanceof Error ? error.message : String(error),
}
: question,
),
},
),
);
}
},
[],
);
const rejectQuestion = useCallback(
async (requestId: string) => {
const target = messagesRef.current
.flatMap((message) => message.questions ?? [])
.find((question) => question.requestId === requestId);
if (!target || target.status === "submitting") {
return;
}
setMessages((prev) =>
prev.map((message) =>
!message.questions?.some((question) => question.requestId === requestId)
? message
: {
...message,
questions: message.questions.map((question) =>
question.requestId === requestId
? { ...question, status: "submitting", error: undefined }
: question,
),
},
),
);
try {
await rejectAgentQuestion(target.sessionId, requestId);
setMessages((prev) =>
prev.map((message) =>
!message.questions?.some((question) => question.requestId === requestId)
? message
: {
...message,
questions: message.questions.map((question) =>
question.requestId === requestId
? {
...question,
status: "rejected",
repliedAt: Date.now(),
error: undefined,
}
: question,
),
},
),
);
} catch (error) {
setMessages((prev) =>
prev.map((message) =>
!message.questions?.some((question) => question.requestId === requestId)
? message
: {
...message,
questions: message.questions.map((question) =>
question.requestId === requestId
? {
...question,
status: "error",
error: error instanceof Error ? error.message : String(error),
}
: question,
),
},
),
);
}
},
[],
);
const createSession = useCallback(() => { const createSession = useCallback(() => {
if (isHydrating || isStreaming) return; if (isHydrating || isStreaming) return;
const controller = abortRef.current; const controller = abortRef.current;
controller?.abort(); controller?.abort();
setBranchTransition(null);
hydrationNonceRef.current += 1; hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1; titleUpdateNonceRef.current += 1;
sessionIdRef.current = undefined; sessionIdRef.current = undefined;
@@ -631,13 +747,11 @@ export const useAgentChatSession = ({
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [], messages: [],
sessionId: undefined, sessionId: undefined,
branchGroups: [],
}); });
setMessages([]); setMessages([]);
setSessionTitle("新对话"); setSessionTitle("新对话");
setIsSessionTitleManuallyEdited(false); setIsSessionTitleManuallyEdited(false);
setSessionId(undefined); setSessionId(undefined);
setBranchGroups([]);
setIsStreaming(false); setIsStreaming(false);
}, [isHydrating, isStreaming]); }, [isHydrating, isStreaming]);
@@ -658,12 +772,10 @@ export const useAgentChatSession = ({
titleUpdateNonceRef.current += 1; titleUpdateNonceRef.current += 1;
sessionIdRef.current = nextState.sessionId; sessionIdRef.current = nextState.sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
setBranchTransition(null);
setMessages(nextState.messages); setMessages(nextState.messages);
setSessionTitle(nextState.title); setSessionTitle(nextState.title);
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false); setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
setSessionId(nextState.sessionId); setSessionId(nextState.sessionId);
setBranchGroups(nextState.branchGroups);
setChatSessions(sessions); setChatSessions(sessions);
if (nextState.sessionId && nextState.isStreaming) { if (nextState.sessionId && nextState.isStreaming) {
resumeStreamingSession(nextState.sessionId); resumeStreamingSession(nextState.sessionId);
@@ -683,6 +795,10 @@ export const useAgentChatSession = ({
async (targetSessionId: string) => { async (targetSessionId: string) => {
if (isHydrating || isStreaming) return; if (isHydrating || isStreaming) return;
setChatSessions((prev) =>
prev.filter((session) => session.id !== targetSessionId),
);
try { try {
const nextActiveSessionId = await deleteChatSession( const nextActiveSessionId = await deleteChatSession(
targetSessionId, targetSessionId,
@@ -703,14 +819,11 @@ export const useAgentChatSession = ({
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [], messages: [],
sessionId: undefined, sessionId: undefined,
branchGroups: [],
}); });
setBranchTransition(null);
setMessages([]); setMessages([]);
setSessionTitle(undefined); setSessionTitle(undefined);
setIsSessionTitleManuallyEdited(false); setIsSessionTitleManuallyEdited(false);
setSessionId(undefined); setSessionId(undefined);
setBranchGroups([]);
return; return;
} }
@@ -723,15 +836,18 @@ export const useAgentChatSession = ({
titleUpdateNonceRef.current += 1; titleUpdateNonceRef.current += 1;
sessionIdRef.current = nextState.sessionId; sessionIdRef.current = nextState.sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
setBranchTransition(null);
setMessages(nextState.messages); setMessages(nextState.messages);
setSessionTitle(nextState.title); setSessionTitle(nextState.title);
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false); setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
setSessionId(nextState.sessionId); setSessionId(nextState.sessionId);
setBranchGroups(nextState.branchGroups);
setChatSessions(sessionsAfterDelete); setChatSessions(sessionsAfterDelete);
} catch (error) { } catch (error) {
console.error("[GlobalChatbox] Failed to delete chat session:", error); console.error("[GlobalChatbox] Failed to delete chat session:", error);
try {
setChatSessions(await listChatSessions());
} catch (refreshError) {
console.error("[GlobalChatbox] Failed to refresh chat sessions:", refreshError);
}
} finally { } finally {
setIsHydrating(false); setIsHydrating(false);
} }
@@ -766,183 +882,65 @@ export const useAgentChatSession = ({
title: normalizedTitle, title: normalizedTitle,
isTitleManuallyEdited: true, isTitleManuallyEdited: true,
messages, messages,
branchGroups,
}); });
} }
} catch (error) { } catch (error) {
console.error("[GlobalChatbox] Failed to rename chat session:", error); console.error("[GlobalChatbox] Failed to rename chat session:", error);
} }
}, },
[branchGroups, isHydrating, messages], [isHydrating, messages],
); );
const regenerate = useCallback(async () => { const createBranch = useCallback(
if (isHydrating || isStreaming || messages.length === 0) return; async (messageId: string) => {
let lastUserIndex = messages.length - 1;
while (lastUserIndex >= 0 && messages[lastUserIndex].role !== "user") {
lastUserIndex--;
}
if (lastUserIndex < 0) return;
const lastUser = messages[lastUserIndex];
const lastUserContent = lastUser.content;
const nextMessages = cloneMessages(messages.slice(0, lastUserIndex));
const nextUserMessage = createUserMessage(
lastUserContent,
lastUser.branchRootId ?? lastUser.id,
);
const nextAssistantMessage = createAssistantMessage();
setMessages(nextMessages);
await runPrompt({
prompt: lastUserContent,
preparedMessages: [
...nextMessages,
nextUserMessage,
nextAssistantMessage,
],
userMessage: nextUserMessage,
assistantMessage: nextAssistantMessage,
});
}, [isHydrating, isStreaming, messages, runPrompt]);
const editAndResubmit = useCallback(
async (messageId: string, newContent: string) => {
if (isHydrating || isStreaming) return; if (isHydrating || isStreaming) return;
const trimmedContent = newContent.trim(); const assistantIndex = messages.findIndex(
if (!trimmedContent) return; (message) => message.id === messageId && message.role === "assistant",
);
if (assistantIndex < 0) return;
const messageIndex = messages.findIndex((m) => m.id === messageId);
if (messageIndex < 0 || messages[messageIndex].role !== "user") return;
const originalMessage = messages[messageIndex];
if (trimmedContent === originalMessage.content.trim()) return;
const rootMessageId = originalMessage.branchRootId ?? originalMessage.id;
const currentSessionId = sessionIdRef.current; const currentSessionId = sessionIdRef.current;
const keepMessageCount = messageIndex; const keepMessageCount = assistantIndex + 1;
const prefix = cloneMessages(messages.slice(0, messageIndex)); const copiedMessages = cloneMessages(messages.slice(0, keepMessageCount));
const originalSuffix = cloneMessages(messages.slice(messageIndex));
const forkedSessionId = await forkAgentChat(currentSessionId, keepMessageCount); const forkedSessionId = await forkAgentChat(currentSessionId, keepMessageCount);
const nextUserMessage = createUserMessage(trimmedContent, rootMessageId);
const nextAssistantMessage = createAssistantMessage();
const nextSuffix = [nextUserMessage, nextAssistantMessage];
setBranchGroups((prev) => {
const next = cloneBranchGroups(prev);
const groupIndex = next.findIndex(
(group) =>
group.rootMessageId === rootMessageId && group.parentCount === messageIndex,
);
if (groupIndex >= 0) {
const group = next[groupIndex];
group.branches[group.activeIndex] = {
...group.branches[group.activeIndex],
sessionId: currentSessionId,
messages: originalSuffix,
};
group.branches.push({
id: createId(),
label: `分支 ${group.branches.length + 1}`,
sessionId: forkedSessionId,
messages: cloneMessages(nextSuffix),
});
group.activeIndex = group.branches.length - 1;
} else {
next.push({
id: rootMessageId,
rootMessageId,
parentCount: messageIndex,
activeIndex: 1,
branches: [
{
id: createId(),
label: "分支 1",
sessionId: currentSessionId,
messages: originalSuffix,
},
{
id: createId(),
label: "分支 2",
sessionId: forkedSessionId,
messages: cloneMessages(nextSuffix),
},
],
});
}
return next;
});
sessionIdRef.current = forkedSessionId; sessionIdRef.current = forkedSessionId;
setSessionId(forkedSessionId); setSessionId(forkedSessionId);
await runPrompt({ messagesRef.current = copiedMessages;
prompt: trimmedContent, setMessages(copiedMessages);
sessionIdOverride: forkedSessionId, setIsSessionTitleManuallyEdited(false);
preparedMessages: [...prefix, ...nextSuffix], const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本";
userMessage: nextUserMessage, setSessionTitle(forkTitle);
assistantMessage: nextAssistantMessage, try {
}); await saveActiveChatState({
}, title: forkTitle,
[isHydrating, isStreaming, messages, runPrompt], isTitleManuallyEdited: false,
); messages: copiedMessages,
sessionId: forkedSessionId,
const cycleBranch = useCallback(
(rootMessageId: string, direction: -1 | 1) => {
if (isHydrating || isStreaming) return;
setBranchGroups((prev) => {
const next = cloneBranchGroups(prev);
const group = next.find((item) => item.rootMessageId === rootMessageId);
if (!group || group.branches.length < 2) {
return prev;
}
const nextIndex =
(group.activeIndex + direction + group.branches.length) % group.branches.length;
const selectedBranch = group.branches[nextIndex];
group.activeIndex = nextIndex;
const nextMessages = [
...cloneMessages(messages.slice(0, group.parentCount)),
...cloneMessages(selectedBranch.messages),
];
setBranchTransition({
rootMessageId,
parentCount: group.parentCount,
activeBranchId: selectedBranch.id,
nonce: Date.now(),
}); });
sessionIdRef.current = selectedBranch.sessionId; setChatSessions(await listChatSessions());
setSessionId(selectedBranch.sessionId); } catch (error) {
setMessages(nextMessages); console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error);
}
return next;
});
}, },
[isHydrating, isStreaming, messages], [isHydrating, isStreaming, messages, sessionTitle],
); );
return { return {
messages, messages,
chatSessions, chatSessions,
activeSessionId: sessionIdRef.current, activeSessionId: sessionIdRef.current,
branchGroups,
branchTransition,
isHydrating, isHydrating,
isStreaming, isStreaming,
sessionTitle, sessionTitle,
sessionId, sessionId,
sendPrompt, sendPrompt,
regenerate, createBranch,
editAndResubmit,
cycleBranch,
abort, abort,
replyPermission,
replyQuestion,
rejectQuestion,
createSession, createSession,
renameSession, renameSession,
removeSession, removeSession,
@@ -0,0 +1,24 @@
import type { AgentApprovalMode, AgentModel, StreamEvent } from "@/lib/chatStream";
import type { AgentArtifact, Message } from "../GlobalChatbox.types";
export type UseAgentChatSessionOptions = {
projectId?: string | null;
onToolCall: (
event: StreamEvent & { type: "tool_call" },
options: {
assistantMessageId: string;
appendArtifact: (messageId: string, artifact: AgentArtifact) => void;
},
) => void;
onBeforeSend?: () => void;
getModel?: () => AgentModel;
getApprovalMode?: () => AgentApprovalMode;
};
export type PromptRunOptions = {
prompt: string;
sessionIdOverride?: string;
preparedMessages?: Message[];
userMessage?: Message;
assistantMessage?: Message;
};
@@ -148,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>,
@@ -190,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 {
@@ -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";
@@ -110,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);
+164 -6
View File
@@ -1,6 +1,10 @@
import { import {
abortAgentChat, abortAgentChat,
forkAgentChat, forkAgentChat,
rejectAgentQuestion,
replyAgentPermission,
replyAgentQuestion,
type StreamEvent,
resumeAgentChatStream, resumeAgentChatStream,
streamAgentChat, streamAgentChat,
} from "./chatStream"; } from "./chatStream";
@@ -70,6 +74,7 @@ describe("streamAgentChat", () => {
message: "hi", message: "hi",
session_id: undefined, session_id: undefined,
model: "deepseek/deepseek-v4-pro", model: "deepseek/deepseek-v4-pro",
approval_mode: undefined,
}), }),
}), }),
); );
@@ -162,12 +167,7 @@ describe("streamAgentChat", () => {
]), ]),
}); });
const events: Array<{ const events: StreamEvent[] = [];
type: string;
sessionId?: string;
tool?: string;
params?: Record<string, unknown>;
}> = [];
await streamAgentChat({ await streamAgentChat({
message: "hi", message: "hi",
@@ -182,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,
@@ -255,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,
+320 -2
View File
@@ -5,6 +5,56 @@ export type AgentModel =
| "deepseek/deepseek-v4-flash" | "deepseek/deepseek-v4-flash"
| "deepseek/deepseek-v4-pro"; | "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"; type: "state";
@@ -41,12 +91,55 @@ 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; signal?: AbortSignal;
onEvent: (event: StreamEvent) => void; onEvent: (event: StreamEvent) => void;
}; };
@@ -100,6 +193,80 @@ 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 = ( const emitParsedStreamEvent = (
event: string, event: string,
data: string, data: string,
@@ -111,7 +278,7 @@ const emitParsedStreamEvent = (
content?: string; content?: string;
message?: string; message?: string;
detail?: string; detail?: string;
tool?: string; tool?: unknown;
params?: Record<string, unknown>; params?: Record<string, unknown>;
arguments?: unknown; arguments?: unknown;
id?: string; id?: string;
@@ -126,6 +293,18 @@ const emitParsedStreamEvent = (
elapsed_ms?: number; elapsed_ms?: number;
duration_ms?: number; duration_ms?: number;
total_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") { if (event === "state") {
onEvent({ onEvent({
@@ -179,9 +358,64 @@ const emitParsedStreamEvent = (
onEvent({ onEvent({
type: "tool_call", type: "tool_call",
sessionId: parsed.session_id ?? "", sessionId: parsed.session_id ?? "",
tool: parsed.tool ?? "", tool: typeof parsed.tool === "string" ? parsed.tool : "",
params: resolveToolParams(parsed.params, parsed.arguments), 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 { } catch {
onEvent({ onEvent({
@@ -224,6 +458,7 @@ export const streamAgentChat = async ({
message, message,
sessionId, sessionId,
model, model,
approvalMode,
signal, signal,
onEvent, onEvent,
}: StreamOptions) => { }: StreamOptions) => {
@@ -242,6 +477,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",
@@ -349,6 +585,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",
+7
View File
@@ -15,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][];