Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22afdbf2e8 | |||
| ed9828befe | |||
| 968d798a2a | |||
| 7da0ed0e39 | |||
| 166b45e529 | |||
| e5f13c3d46 | |||
| 36cdb1df8d | |||
| 865e425748 | |||
| 3a36c693cd | |||
| b23cb6acdd | |||
| 2691f42581 | |||
| 34fd5bfb1a | |||
| 40cc355fff | |||
| f7cd5ebfa7 | |||
| d31565d52c | |||
| e32823e4b5 |
@@ -26,7 +26,9 @@ import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||
import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
|
||||
import BoltRounded from "@mui/icons-material/BoltRounded";
|
||||
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
||||
import 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 = {
|
||||
focus: () => void;
|
||||
@@ -48,6 +50,8 @@ type AgentComposerProps = {
|
||||
onStopListening: () => void;
|
||||
selectedModel: AgentModel;
|
||||
onModelChange: (model: AgentModel) => void;
|
||||
approvalMode: AgentApprovalMode;
|
||||
onApprovalModeChange: (mode: AgentApprovalMode) => void;
|
||||
};
|
||||
|
||||
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
|
||||
@@ -62,6 +66,8 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
onStopListening,
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
approvalMode,
|
||||
onApprovalModeChange,
|
||||
}, ref) {
|
||||
const theme = useTheme();
|
||||
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
|
||||
@@ -245,6 +251,97 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
</IconButton>
|
||||
)
|
||||
) : null}
|
||||
<FormControl size="small" sx={{ minWidth: 96 }}>
|
||||
<Select
|
||||
value={approvalMode}
|
||||
onChange={(event) =>
|
||||
onApprovalModeChange(event.target.value as AgentApprovalMode)
|
||||
}
|
||||
disabled={isHydrating || isStreaming}
|
||||
aria-label="权限批准模式"
|
||||
renderValue={(val) => (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.45 }}>
|
||||
{val === "always" ? (
|
||||
<AdminPanelSettingsRounded sx={{ fontSize: 18, color: "inherit" }} />
|
||||
) : (
|
||||
<VerifiedUserRounded sx={{ fontSize: 18, color: "inherit" }} />
|
||||
)}
|
||||
<Typography sx={{ fontSize: "0.75rem", fontWeight: 600, color: "inherit" }}>
|
||||
{val === "always" ? "始终允许" : "请求批准"}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
MenuProps={{
|
||||
anchorOrigin: { vertical: "top", horizontal: "left" },
|
||||
transformOrigin: { vertical: "bottom", horizontal: "left" },
|
||||
sx: { zIndex: (theme) => theme.zIndex.modal + 110 },
|
||||
PaperProps: {
|
||||
sx: {
|
||||
mb: 1.5,
|
||||
width: 210,
|
||||
borderRadius: 4,
|
||||
bgcolor: alpha("#fff", 0.9),
|
||||
backdropFilter: "blur(24px)",
|
||||
border: `1px solid ${alpha("#fff", 0.9)}`,
|
||||
boxShadow: `0 -12px 40px ${alpha("#000", 0.08)}`,
|
||||
"& .MuiList-root": { p: 1 },
|
||||
"& .MuiMenuItem-root": {
|
||||
px: 1.5,
|
||||
py: 1.2,
|
||||
mb: 0.5,
|
||||
borderRadius: 3,
|
||||
alignItems: "flex-start",
|
||||
"&:last-child": { mb: 0 },
|
||||
"&.Mui-selected": {
|
||||
bgcolor: alpha("#00acc1", 0.08),
|
||||
"&:hover": { bgcolor: alpha("#00acc1", 0.12) },
|
||||
"& .title": { color: "#00838f" },
|
||||
"& .icon": { color: "#00acc1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
height: 36,
|
||||
borderRadius: "18px",
|
||||
bgcolor: alpha("#fff", 0.6),
|
||||
color: "text.secondary",
|
||||
".MuiOutlinedInput-notchedOutline": { border: "none" },
|
||||
".MuiSelect-select": {
|
||||
py: 0,
|
||||
pl: 1,
|
||||
pr: "28px !important",
|
||||
minHeight: 36,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
"&:hover, &:has(.MuiSelect-select[aria-expanded=\"true\"])": {
|
||||
bgcolor: alpha("#000", 0.06),
|
||||
color: "text.primary",
|
||||
},
|
||||
".MuiSelect-icon": {
|
||||
color: "text.secondary",
|
||||
right: 4,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="request">
|
||||
<VerifiedUserRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} />
|
||||
<Box>
|
||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}>请求批准</Typography>
|
||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>工具权限逐次确认</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
<MenuItem value="always">
|
||||
<AdminPanelSettingsRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} />
|
||||
<Box>
|
||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}>始终允许</Typography>
|
||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>自动允许本轮权限请求</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+108
-292
@@ -2,13 +2,10 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import React, { useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
@@ -18,72 +15,68 @@ import {
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
|
||||
import RefreshRounded from "@mui/icons-material/RefreshRounded";
|
||||
import EditRounded from "@mui/icons-material/EditRounded";
|
||||
import CloseRounded from "@mui/icons-material/CloseRounded";
|
||||
import ChevronLeftRounded from "@mui/icons-material/ChevronLeftRounded";
|
||||
import ChevronRightRounded from "@mui/icons-material/ChevronRightRounded";
|
||||
import { TbArrowsSplit2 } from "react-icons/tb";
|
||||
import type { PermissionReply } from "@/lib/chatStream";
|
||||
import {
|
||||
parseAssistantMessageSections,
|
||||
parseContentWithToolCalls,
|
||||
type ContentSegment,
|
||||
} from "./chatMessageSections";
|
||||
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
||||
import type { BranchState, Message, SpeechState } from "./GlobalChatbox.types";
|
||||
import type { Message, SpeechState } from "./GlobalChatbox.types";
|
||||
import { stripMarkdown } from "./GlobalChatbox.utils";
|
||||
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
||||
import { ChatInlineChart } from "./ChatInlineChart";
|
||||
import type { ChatChartSeries } from "./ChatInlineChart";
|
||||
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
||||
import { AgentArtifactPanel } from "./AgentArtifactPanel";
|
||||
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
||||
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
|
||||
import { PermissionRequestGroup } from "./AgentPermissionRequests";
|
||||
import { QuestionRequestGroup } from "./AgentQuestionRequests";
|
||||
import { TodoPlanCard } from "./AgentTodoPlanCard";
|
||||
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
|
||||
import PauseRounded from "@mui/icons-material/PauseRounded";
|
||||
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
||||
import StopRounded from "@mui/icons-material/StopRounded";
|
||||
import SendRounded from "@mui/icons-material/SendRounded";
|
||||
|
||||
type AgentTurnProps = {
|
||||
message: Message;
|
||||
branchState?: BranchState;
|
||||
isStreaming: boolean;
|
||||
messageSpeechState: SpeechState;
|
||||
onSpeak: (messageId: string, text: string) => void;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onStopSpeech: () => void;
|
||||
isTtsSupported: boolean;
|
||||
onRegenerate: () => void;
|
||||
onEditResubmit: (messageId: string, newContent: string) => void;
|
||||
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
||||
onCreateBranch: (messageId: string) => void;
|
||||
onReplyPermission: (requestId: string, reply: PermissionReply) => 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(
|
||||
({
|
||||
message,
|
||||
branchState,
|
||||
isStreaming,
|
||||
messageSpeechState,
|
||||
onSpeak,
|
||||
onPause,
|
||||
onResume,
|
||||
onStopSpeech,
|
||||
isTtsSupported,
|
||||
onRegenerate,
|
||||
onEditResubmit,
|
||||
onCycleBranch,
|
||||
onCreateBranch,
|
||||
onReplyPermission,
|
||||
onReplyQuestion,
|
||||
onRejectQuestion,
|
||||
}: AgentTurnProps) => {
|
||||
const theme = useTheme();
|
||||
const isUser = message.role === "user";
|
||||
const isErrorMessage = Boolean(message.isError);
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [editDraft, setEditDraft] = React.useState(message.content);
|
||||
const rootMessageId = message.branchRootId ?? message.id;
|
||||
const isProgressComplete = message.progress?.some(
|
||||
(item) => item.phase === "complete" && item.status === "completed",
|
||||
) ?? false;
|
||||
const isProgressRunning = !isErrorMessage && !isProgressComplete && (
|
||||
message.progress?.some((item) => item.status === "running") ?? false
|
||||
);
|
||||
|
||||
const parsedAssistantSections = useMemo(
|
||||
() =>
|
||||
@@ -112,185 +105,33 @@ export const AgentTurn = React.memo(
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{isEditing ? (
|
||||
<Paper
|
||||
elevation={12}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 5,
|
||||
bgcolor: alpha("#ffffff", 0.75),
|
||||
backdropFilter: "blur(40px)",
|
||||
border: `1px solid ${alpha("#ffffff", 0.9)}`,
|
||||
boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
|
||||
minWidth: { xs: 260, sm: 320, md: 400 },
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
<Box component="textarea"
|
||||
autoFocus
|
||||
value={editDraft}
|
||||
onChange={(e) => setEditDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (editDraft.trim() !== message.content) {
|
||||
onEditResubmit(message.id, editDraft);
|
||||
}
|
||||
setIsEditing(false);
|
||||
} else if (e.key === "Escape") {
|
||||
setEditDraft(message.content);
|
||||
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}
|
||||
</>
|
||||
)}
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 5,
|
||||
borderBottomRightRadius: 2,
|
||||
color: "#fff",
|
||||
background: `linear-gradient(135deg, #0288d1, #00acc1)`,
|
||||
boxShadow: `0 8px 24px -8px ${alpha("#00acc1", 0.5)}, inset 0 2px 4px ${alpha("#fff", 0.2)}`,
|
||||
backdropFilter: "blur(10px)",
|
||||
"--chat-md-text": alpha("#fff", 0.96),
|
||||
"--chat-md-heading": "#fff",
|
||||
"--chat-md-link": "#e0f7fa",
|
||||
"--chat-md-link-hover": "#fff",
|
||||
"--chat-md-inline-code-bg": "rgba(255,255,255,0.15)",
|
||||
"--chat-md-inline-code-border": alpha("#fff", 0.1),
|
||||
"--chat-md-inline-code-text": "#fff",
|
||||
"--chat-md-pre-bg": "rgba(0, 0, 0, 0.25)",
|
||||
"--chat-md-pre-border": alpha("#fff", 0.1),
|
||||
"--chat-md-pre-text": "#F8FAFC",
|
||||
"--chat-md-quote-border": alpha("#fff", 0.4),
|
||||
"--chat-md-quote-bg": alpha("#fff", 0.05),
|
||||
"--chat-md-quote-text": alpha("#fff", 0.8),
|
||||
}}
|
||||
>
|
||||
<MarkdownBlock>{message.content}</MarkdownBlock>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -359,6 +200,26 @@ export const AgentTurn = React.memo(
|
||||
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
|
||||
) : null}
|
||||
|
||||
{message.permissions?.length ? (
|
||||
<PermissionRequestGroup
|
||||
permissions={message.permissions}
|
||||
isRunning={isProgressRunning}
|
||||
onReply={onReplyPermission}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{message.questions?.length ? (
|
||||
<QuestionRequestGroup
|
||||
questions={message.questions}
|
||||
onReply={onReplyQuestion}
|
||||
onReject={onRejectQuestion}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{message.todos ? (
|
||||
<TodoPlanCard todoUpdate={message.todos} />
|
||||
) : null}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
@@ -418,7 +279,7 @@ export const AgentTurn = React.memo(
|
||||
</Stack>
|
||||
|
||||
<AnimatePresence>
|
||||
{isHovered && (
|
||||
{isHovered && !isStreaming && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 5 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
@@ -444,7 +305,9 @@ export const AgentTurn = React.memo(
|
||||
size="small"
|
||||
aria-label="复制"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(message.content);
|
||||
navigator.clipboard.writeText(
|
||||
normalizeClipboardText(message.content),
|
||||
);
|
||||
// Could add a toast here
|
||||
}}
|
||||
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
||||
@@ -452,16 +315,16 @@ export const AgentTurn = React.memo(
|
||||
<ContentCopyRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="重新生成">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="重新生成"
|
||||
<Tooltip title="拆分为新会话">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="拆分为新会话"
|
||||
onClick={() => {
|
||||
onRegenerate();
|
||||
onCreateBranch(message.id);
|
||||
}}
|
||||
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
||||
>
|
||||
<RefreshRounded sx={{ fontSize: 16 }} />
|
||||
<TbArrowsSplit2 size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Paper>
|
||||
@@ -472,87 +335,40 @@ export const AgentTurn = React.memo(
|
||||
</Paper>
|
||||
</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" 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={() => onSpeak(message.id, stripMarkdown(answerContent))}
|
||||
aria-label="朗读消息"
|
||||
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
|
||||
>
|
||||
<VolumeUpRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
) : null}
|
||||
{messageSpeechState === "playing" ? (
|
||||
<>
|
||||
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||
<PauseRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||
<StopRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</>
|
||||
) : null}
|
||||
{messageSpeechState === "paused" ? (
|
||||
<>
|
||||
<IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||
<PlayArrowRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||
<StopRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</>
|
||||
) : null}
|
||||
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||
<PauseRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||
<StopRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</>
|
||||
) : null}
|
||||
{messageSpeechState === "paused" ? (
|
||||
<>
|
||||
<IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||
<PlayArrowRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||
<StopRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{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>
|
||||
) : null}
|
||||
</motion.div>
|
||||
|
||||
@@ -33,8 +33,6 @@ jest.mock("./AgentTurn", () => ({
|
||||
|
||||
describe("AgentWorkspace", () => {
|
||||
const defaultProps = {
|
||||
branchGroups: [],
|
||||
branchTransition: null,
|
||||
bottomRef: { current: null },
|
||||
speakingMessageId: null,
|
||||
speechState: "idle" as const,
|
||||
@@ -43,9 +41,10 @@ describe("AgentWorkspace", () => {
|
||||
onResumeSpeech: jest.fn(),
|
||||
onStopSpeech: jest.fn(),
|
||||
isTtsSupported: false,
|
||||
onRegenerate: jest.fn(),
|
||||
onEditResubmit: jest.fn(),
|
||||
onCycleBranch: jest.fn(),
|
||||
onCreateBranch: jest.fn(),
|
||||
onReplyPermission: jest.fn(),
|
||||
onReplyQuestion: jest.fn(),
|
||||
onRejectQuestion: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -11,18 +11,14 @@ import MapRounded from "@mui/icons-material/MapRounded";
|
||||
|
||||
import { AgentTurn } from "./AgentTurn";
|
||||
import { TypingIndicator } from "./GlobalChatbox.parts";
|
||||
import type { PermissionReply } from "@/lib/chatStream";
|
||||
import type {
|
||||
BranchGroup,
|
||||
BranchState,
|
||||
BranchTransition,
|
||||
Message,
|
||||
SpeechState,
|
||||
} from "./GlobalChatbox.types";
|
||||
|
||||
type AgentWorkspaceProps = {
|
||||
messages: Message[];
|
||||
branchGroups: BranchGroup[];
|
||||
branchTransition: BranchTransition | null;
|
||||
isStreaming: boolean;
|
||||
bottomRef: React.RefObject<HTMLDivElement | null>;
|
||||
speakingMessageId: string | null;
|
||||
@@ -32,14 +28,15 @@ type AgentWorkspaceProps = {
|
||||
onResumeSpeech: () => void;
|
||||
onStopSpeech: () => void;
|
||||
isTtsSupported: boolean;
|
||||
onRegenerate: () => void;
|
||||
onEditResubmit: (messageId: string, newContent: string) => void;
|
||||
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
||||
onCreateBranch: (messageId: string) => void;
|
||||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||
onRejectQuestion: (requestId: string) => void;
|
||||
};
|
||||
|
||||
type TurnListProps = {
|
||||
messages: Message[];
|
||||
branchGroups: BranchGroup[];
|
||||
isStreaming: boolean;
|
||||
speakingMessageId: string | null;
|
||||
speechState: SpeechState;
|
||||
onSpeak: (messageId: string, text: string) => void;
|
||||
@@ -47,9 +44,10 @@ type TurnListProps = {
|
||||
onResumeSpeech: () => void;
|
||||
onStopSpeech: () => void;
|
||||
isTtsSupported: boolean;
|
||||
onRegenerate: () => void;
|
||||
onEditResubmit: (messageId: string, newContent: string) => void;
|
||||
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
||||
onCreateBranch: (messageId: string) => void;
|
||||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||
onRejectQuestion: (requestId: string) => void;
|
||||
};
|
||||
|
||||
const sameMessages = (left: Message[], right: Message[]) =>
|
||||
@@ -58,7 +56,7 @@ const sameMessages = (left: Message[], right: Message[]) =>
|
||||
|
||||
const TurnListInner = ({
|
||||
messages,
|
||||
branchGroups,
|
||||
isStreaming,
|
||||
speakingMessageId,
|
||||
speechState,
|
||||
onSpeak,
|
||||
@@ -66,44 +64,30 @@ const TurnListInner = ({
|
||||
onResumeSpeech,
|
||||
onStopSpeech,
|
||||
isTtsSupported,
|
||||
onRegenerate,
|
||||
onEditResubmit,
|
||||
onCycleBranch,
|
||||
onCreateBranch,
|
||||
onReplyPermission,
|
||||
onReplyQuestion,
|
||||
onRejectQuestion,
|
||||
}: 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 (
|
||||
<>
|
||||
{messages.map((message) => {
|
||||
const rootMessageId = message.branchRootId ?? message.id;
|
||||
return (
|
||||
<AgentTurn
|
||||
key={rootMessageId}
|
||||
message={message}
|
||||
branchState={branchStateByRootId.get(rootMessageId)}
|
||||
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||
onSpeak={onSpeak}
|
||||
onPause={onPauseSpeech}
|
||||
onResume={onResumeSpeech}
|
||||
onStopSpeech={onStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onRegenerate={onRegenerate}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onCycleBranch={onCycleBranch}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{messages.map((message) => (
|
||||
<AgentTurn
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={isStreaming}
|
||||
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||
onSpeak={onSpeak}
|
||||
onPause={onPauseSpeech}
|
||||
onResume={onResumeSpeech}
|
||||
onStopSpeech={onStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -112,7 +96,7 @@ const TurnList = React.memo(
|
||||
TurnListInner,
|
||||
(prevProps, nextProps) =>
|
||||
sameMessages(prevProps.messages, nextProps.messages) &&
|
||||
prevProps.branchGroups === nextProps.branchGroups &&
|
||||
prevProps.isStreaming === nextProps.isStreaming &&
|
||||
prevProps.speakingMessageId === nextProps.speakingMessageId &&
|
||||
prevProps.speechState === nextProps.speechState &&
|
||||
prevProps.onSpeak === nextProps.onSpeak &&
|
||||
@@ -120,9 +104,10 @@ const TurnList = React.memo(
|
||||
prevProps.onResumeSpeech === nextProps.onResumeSpeech &&
|
||||
prevProps.onStopSpeech === nextProps.onStopSpeech &&
|
||||
prevProps.isTtsSupported === nextProps.isTtsSupported &&
|
||||
prevProps.onRegenerate === nextProps.onRegenerate &&
|
||||
prevProps.onEditResubmit === nextProps.onEditResubmit &&
|
||||
prevProps.onCycleBranch === nextProps.onCycleBranch,
|
||||
prevProps.onCreateBranch === nextProps.onCreateBranch &&
|
||||
prevProps.onReplyPermission === nextProps.onReplyPermission &&
|
||||
prevProps.onReplyQuestion === nextProps.onReplyQuestion &&
|
||||
prevProps.onRejectQuestion === nextProps.onRejectQuestion,
|
||||
);
|
||||
|
||||
TurnList.displayName = "TurnList";
|
||||
@@ -243,8 +228,6 @@ const EmptyState = () => {
|
||||
|
||||
export const AgentWorkspace = ({
|
||||
messages,
|
||||
branchGroups,
|
||||
branchTransition,
|
||||
isStreaming,
|
||||
bottomRef,
|
||||
speakingMessageId,
|
||||
@@ -254,9 +237,10 @@ export const AgentWorkspace = ({
|
||||
onResumeSpeech,
|
||||
onStopSpeech,
|
||||
isTtsSupported,
|
||||
onRegenerate,
|
||||
onEditResubmit,
|
||||
onCycleBranch,
|
||||
onCreateBranch,
|
||||
onReplyPermission,
|
||||
onReplyQuestion,
|
||||
onRejectQuestion,
|
||||
}: AgentWorkspaceProps) => {
|
||||
const theme = useTheme();
|
||||
const latestAssistant = [...messages]
|
||||
@@ -267,18 +251,12 @@ export const AgentWorkspace = ({
|
||||
(!latestAssistant ||
|
||||
(latestAssistant.content.trim().length === 0 &&
|
||||
!(latestAssistant.artifacts?.length)));
|
||||
const stableMessages = branchTransition
|
||||
? messages.slice(0, branchTransition.parentCount)
|
||||
: messages;
|
||||
const transitionMessages = branchTransition
|
||||
? messages.slice(branchTransition.parentCount)
|
||||
: [];
|
||||
const streamingMessage =
|
||||
!branchTransition && isStreaming && messages.at(-1)?.role === "assistant"
|
||||
isStreaming && messages.at(-1)?.role === "assistant"
|
||||
? messages.at(-1)
|
||||
: undefined;
|
||||
const historyMessages =
|
||||
streamingMessage !== undefined ? messages.slice(0, -1) : stableMessages;
|
||||
streamingMessage !== undefined ? messages.slice(0, -1) : messages;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -300,7 +278,7 @@ export const AgentWorkspace = ({
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<TurnList
|
||||
messages={historyMessages}
|
||||
branchGroups={branchGroups}
|
||||
isStreaming={isStreaming}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={onSpeak}
|
||||
@@ -308,15 +286,16 @@ export const AgentWorkspace = ({
|
||||
onResumeSpeech={onResumeSpeech}
|
||||
onStopSpeech={onStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onRegenerate={onRegenerate}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onCycleBranch={onCycleBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
|
||||
{streamingMessage ? (
|
||||
<TurnList
|
||||
messages={[streamingMessage]}
|
||||
branchGroups={branchGroups}
|
||||
isStreaming={isStreaming}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={onSpeak}
|
||||
@@ -324,39 +303,12 @@ export const AgentWorkspace = ({
|
||||
onResumeSpeech={onResumeSpeech}
|
||||
onStopSpeech={onStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onRegenerate={onRegenerate}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onCycleBranch={onCycleBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
) : 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>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
|
||||
import { getAccessToken } from "@/lib/authToken";
|
||||
import type { AgentModel } from "@/lib/chatStream";
|
||||
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
|
||||
import { AgentHeader } from "./AgentHeader";
|
||||
@@ -31,6 +31,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
const [selectedModel, setSelectedModel] = useState<AgentModel>(
|
||||
"deepseek/deepseek-v4-pro",
|
||||
);
|
||||
const [approvalMode, setApprovalMode] =
|
||||
useState<AgentApprovalMode>("request");
|
||||
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const composerRef = useRef<AgentComposerHandle | null>(null);
|
||||
@@ -65,16 +67,15 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
messages,
|
||||
chatSessions,
|
||||
activeSessionId,
|
||||
branchGroups,
|
||||
branchTransition,
|
||||
isHydrating,
|
||||
isStreaming,
|
||||
sessionTitle,
|
||||
sendPrompt,
|
||||
regenerate,
|
||||
editAndResubmit,
|
||||
cycleBranch,
|
||||
createBranch,
|
||||
abort,
|
||||
replyPermission,
|
||||
replyQuestion,
|
||||
rejectQuestion,
|
||||
createSession,
|
||||
renameSession,
|
||||
removeSession,
|
||||
@@ -84,6 +85,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
onToolCall: handleToolCall,
|
||||
onBeforeSend: stopListening,
|
||||
getModel: () => selectedModel,
|
||||
getApprovalMode: () => approvalMode,
|
||||
});
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||
@@ -340,8 +342,6 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
<Box sx={{ flex: 1, display: "flex", minWidth: 0, flexDirection: "column" }}>
|
||||
<AgentWorkspace
|
||||
messages={messages}
|
||||
branchGroups={branchGroups}
|
||||
branchTransition={branchTransition}
|
||||
isStreaming={isStreaming}
|
||||
bottomRef={bottomRef}
|
||||
speakingMessageId={speakingMessageId}
|
||||
@@ -351,9 +351,10 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
onResumeSpeech={handleResumeSpeech}
|
||||
onStopSpeech={handleStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onRegenerate={regenerate}
|
||||
onEditResubmit={editAndResubmit}
|
||||
onCycleBranch={cycleBranch}
|
||||
onCreateBranch={createBranch}
|
||||
onReplyPermission={replyPermission}
|
||||
onReplyQuestion={replyQuestion}
|
||||
onRejectQuestion={rejectQuestion}
|
||||
/>
|
||||
|
||||
<AgentComposer
|
||||
@@ -369,6 +370,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
onStopListening={stopListening}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
approvalMode={approvalMode}
|
||||
onApprovalModeChange={setApprovalMode}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
AgentQuestionRequest,
|
||||
AgentTodoUpdate,
|
||||
} from "@/lib/chatStream";
|
||||
|
||||
export type ChatProgress = {
|
||||
id: string;
|
||||
phase: string;
|
||||
@@ -22,6 +27,32 @@ export type AgentArtifact = {
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AgentPermissionStatus =
|
||||
| "pending"
|
||||
| "submitting"
|
||||
| "approved_once"
|
||||
| "approved_always"
|
||||
| "rejected"
|
||||
| "aborted"
|
||||
| "error";
|
||||
|
||||
export type AgentPermissionRequest = {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
permission: string;
|
||||
patterns: string[];
|
||||
target?: string;
|
||||
always: string[];
|
||||
tool?: {
|
||||
messageID: string;
|
||||
callID: string;
|
||||
};
|
||||
createdAt: number;
|
||||
repliedAt?: number;
|
||||
status: AgentPermissionStatus;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
@@ -29,34 +60,9 @@ export type Message = {
|
||||
isError?: boolean;
|
||||
progress?: ChatProgress[];
|
||||
artifacts?: AgentArtifact[];
|
||||
branchRootId?: string;
|
||||
};
|
||||
|
||||
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;
|
||||
permissions?: AgentPermissionRequest[];
|
||||
questions?: AgentQuestionRequest[];
|
||||
todos?: AgentTodoUpdate;
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
@@ -80,7 +86,6 @@ export type LoadedChatState = {
|
||||
title?: string;
|
||||
isTitleManuallyEdited?: boolean;
|
||||
messages: Message[];
|
||||
branchGroups: BranchGroup[];
|
||||
isStreaming?: boolean;
|
||||
runStatus?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { cloneMessage } from "./GlobalChatbox.utils";
|
||||
import type { Message } from "./GlobalChatbox.types";
|
||||
|
||||
describe("cloneMessage", () => {
|
||||
it("normalizes persisted question and todo arrays", () => {
|
||||
const message = {
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "需要补充信息",
|
||||
questions: [
|
||||
{
|
||||
requestId: "question-1",
|
||||
sessionId: "session-1",
|
||||
questions: [
|
||||
{
|
||||
header: "范围",
|
||||
question: "请选择分析范围",
|
||||
},
|
||||
],
|
||||
createdAt: 1,
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
todos: {
|
||||
sessionId: "session-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
} as unknown as Message;
|
||||
|
||||
const cloned = cloneMessage(message);
|
||||
|
||||
expect(cloned.questions?.[0]?.questions[0]?.options).toEqual([]);
|
||||
expect(cloned.todos?.todos).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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 = () =>
|
||||
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -29,19 +33,65 @@ export const stripMarkdown = (md: string): string =>
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.trim();
|
||||
|
||||
const normalizeQuestionRequests = (
|
||||
questions: Message["questions"],
|
||||
): Message["questions"] =>
|
||||
Array.isArray(questions)
|
||||
? questions.map((request) => ({
|
||||
...request,
|
||||
questions: Array.isArray(request.questions)
|
||||
? request.questions.map((question) => ({
|
||||
...question,
|
||||
header: typeof question.header === "string" ? question.header : "",
|
||||
question:
|
||||
typeof question.question === "string" ? question.question : "",
|
||||
options: Array.isArray(question.options)
|
||||
? question.options.map((option) => ({
|
||||
label:
|
||||
typeof option.label === "string" ? option.label : "",
|
||||
description:
|
||||
typeof option.description === "string"
|
||||
? option.description
|
||||
: "",
|
||||
}))
|
||||
: [],
|
||||
}))
|
||||
: [],
|
||||
answers: Array.isArray(request.answers)
|
||||
? request.answers.map((answer) =>
|
||||
Array.isArray(answer)
|
||||
? answer.filter((item): item is string => typeof item === "string")
|
||||
: [],
|
||||
)
|
||||
: undefined,
|
||||
} satisfies AgentQuestionRequest))
|
||||
: undefined;
|
||||
|
||||
const normalizeTodoUpdate = (todos: Message["todos"]): Message["todos"] => {
|
||||
if (!todos) return undefined;
|
||||
return {
|
||||
...todos,
|
||||
todos: Array.isArray(todos.todos)
|
||||
? todos.todos.map((todo) => ({ ...todo }))
|
||||
: [],
|
||||
} satisfies AgentTodoUpdate;
|
||||
};
|
||||
|
||||
export const cloneMessage = (message: Message): Message => ({
|
||||
...message,
|
||||
progress: message.progress ? [...message.progress] : undefined,
|
||||
artifacts: message.artifacts ? [...message.artifacts] : undefined,
|
||||
progress: Array.isArray(message.progress) ? [...message.progress] : undefined,
|
||||
artifacts: Array.isArray(message.artifacts) ? [...message.artifacts] : undefined,
|
||||
permissions: Array.isArray(message.permissions)
|
||||
? message.permissions.map((permission) => ({
|
||||
...permission,
|
||||
patterns: Array.isArray(permission.patterns)
|
||||
? [...permission.patterns]
|
||||
: [],
|
||||
always: Array.isArray(permission.always) ? [...permission.always] : [],
|
||||
}))
|
||||
: undefined,
|
||||
questions: normalizeQuestionRequests(message.questions),
|
||||
todos: normalizeTodoUpdate(message.todos),
|
||||
});
|
||||
|
||||
export const cloneMessages = (messages: Message[]) => messages.map(cloneMessage);
|
||||
|
||||
export const cloneBranchGroups = (branchGroups: BranchGroup[]) =>
|
||||
branchGroups.map((group) => ({
|
||||
...group,
|
||||
branches: group.branches.map((branch) => ({
|
||||
...branch,
|
||||
messages: cloneMessages(branch.messages),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -21,7 +21,6 @@ describe("chatStorage backend-only persistence", () => {
|
||||
title: undefined,
|
||||
messages: [],
|
||||
sessionId: undefined,
|
||||
branchGroups: [],
|
||||
});
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -60,11 +59,9 @@ describe("chatStorage backend-only persistence", () => {
|
||||
id: "message-2",
|
||||
role: "user",
|
||||
content: "第一条消息",
|
||||
branchRootId: "message-2",
|
||||
},
|
||||
],
|
||||
sessionId: undefined,
|
||||
branchGroups: [],
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -2,12 +2,11 @@ import { apiFetch } from "@/lib/apiFetch";
|
||||
import { config } from "@config/config";
|
||||
|
||||
import type {
|
||||
BranchGroup,
|
||||
ChatSessionSummary,
|
||||
LoadedChatState,
|
||||
Message,
|
||||
} from "./GlobalChatbox.types";
|
||||
import { cloneBranchGroups, cloneMessages } from "./GlobalChatbox.utils";
|
||||
import { cloneMessages } from "./GlobalChatbox.utils";
|
||||
|
||||
type BackendSessionPayload = {
|
||||
id?: string;
|
||||
@@ -23,22 +22,16 @@ export const createEmptyChatState = (): LoadedChatState => ({
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
sessionId: undefined,
|
||||
branchGroups: [],
|
||||
});
|
||||
|
||||
const sanitizeMessages = (messages: Message[] | undefined) =>
|
||||
Array.isArray(messages) ? cloneMessages(messages) : [];
|
||||
|
||||
const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) =>
|
||||
Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : [];
|
||||
|
||||
const hasChatContent = (state: {
|
||||
messages: Message[];
|
||||
branchGroups: BranchGroup[];
|
||||
sessionId?: string;
|
||||
}) =>
|
||||
state.messages.length > 0 ||
|
||||
state.branchGroups.length > 0 ||
|
||||
Boolean(state.sessionId);
|
||||
|
||||
const compareSessionsByAnchorTime = (
|
||||
@@ -107,7 +100,6 @@ const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatSta
|
||||
is_title_manually_edited?: boolean;
|
||||
session_id?: string;
|
||||
messages?: Message[];
|
||||
branch_groups?: BranchGroup[];
|
||||
is_streaming?: boolean;
|
||||
run_status?: string;
|
||||
};
|
||||
@@ -116,7 +108,6 @@ const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatSta
|
||||
isTitleManuallyEdited: payload.is_title_manually_edited ?? false,
|
||||
messages: sanitizeMessages(payload.messages),
|
||||
sessionId: payload.session_id ?? payload.id,
|
||||
branchGroups: sanitizeBranchGroups(payload.branch_groups),
|
||||
isStreaming: payload.is_streaming ?? false,
|
||||
runStatus: payload.run_status,
|
||||
};
|
||||
@@ -167,7 +158,6 @@ const saveBackendChatState = async (
|
||||
title: normalizeTitle(state.title),
|
||||
is_title_manually_edited: state.isTitleManuallyEdited ?? false,
|
||||
messages: sanitizeMessages(state.messages),
|
||||
branch_groups: sanitizeBranchGroups(state.branchGroups),
|
||||
}),
|
||||
projectHeaderMode: "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,460 +1,2 @@
|
||||
"use client";
|
||||
|
||||
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 deleteChatSession = 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: (...args: unknown[]) => deleteChatSession(...args),
|
||||
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();
|
||||
deleteChatSession.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);
|
||||
deleteChatSession.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("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("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("finalizes running progress when aborting an active prompt", async () => {
|
||||
listChatSessions.mockResolvedValue([]);
|
||||
jest.mocked(streamAgentChat).mockImplementationOnce(
|
||||
({ onEvent, signal }) =>
|
||||
new Promise<void>((_, reject) => {
|
||||
onEvent({
|
||||
type: "progress",
|
||||
sessionId: "session-1",
|
||||
id: "request-received",
|
||||
phase: "start",
|
||||
status: "running",
|
||||
title: "开始分析",
|
||||
startedAt: 1000,
|
||||
} satisfies StreamEvent);
|
||||
|
||||
signal.addEventListener("abort", () => {
|
||||
reject(new Error("aborted"));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
|
||||
act(() => {
|
||||
void result.current.sendPrompt("测试中断");
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isStreaming).toBe(true));
|
||||
|
||||
act(() => {
|
||||
result.current.abort();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isStreaming).toBe(false));
|
||||
|
||||
expect(result.current.messages.at(-1)).toEqual(
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: "⚠️ **请求已中断**",
|
||||
isError: true,
|
||||
progress: [
|
||||
expect.objectContaining({
|
||||
id: "request-received",
|
||||
status: "completed",
|
||||
durationMs: expect.any(Number),
|
||||
endedAt: expect.any(Number),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(abortAgentChat).toHaveBeenCalledWith("session-1");
|
||||
});
|
||||
|
||||
it("ignores generated session titles after the title was edited manually", async () => {
|
||||
listChatSessions.mockResolvedValue([]);
|
||||
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||
onEvent({
|
||||
type: "session_title",
|
||||
sessionId: "session-1",
|
||||
title: "自动标题",
|
||||
});
|
||||
onEvent({
|
||||
type: "done",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchSession("session-loaded");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.renameSession("session-loaded", "手动标题");
|
||||
});
|
||||
|
||||
await waitFor(() => expect(updateChatSessionTitle).toHaveBeenCalled());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendPrompt("帮我分析一下");
|
||||
});
|
||||
|
||||
expect(result.current.sessionTitle).toBe("手动标题");
|
||||
expect(updateChatSessionTitle).not.toHaveBeenCalledWith(
|
||||
"session-loaded",
|
||||
"自动标题",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
// Tests for useAgentChatSession are split by behavior boundary.
|
||||
// See useAgentChatSession.lifecycle.test.tsx and useAgentChatSession.actions.test.tsx.
|
||||
|
||||
@@ -2,177 +2,20 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
abortAgentChat,
|
||||
forkAgentChat,
|
||||
resumeAgentChatStream,
|
||||
streamAgentChat,
|
||||
} from "@/lib/chatStream";
|
||||
import type { AgentModel, StreamEvent } from "@/lib/chatStream";
|
||||
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 finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
||||
const completedProgress = completeRunningProgress(message.progress);
|
||||
const hasVisibleOutput =
|
||||
message.content.trim().length > 0 ||
|
||||
Boolean(message.artifacts?.length) ||
|
||||
Boolean(completedProgress?.length);
|
||||
|
||||
if (!hasVisibleOutput) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: message.content || "⚠️ **请求已中断**",
|
||||
isError: true,
|
||||
progress: completedProgress,
|
||||
};
|
||||
};
|
||||
|
||||
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);
|
||||
import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
|
||||
import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
|
||||
import type { AgentArtifact, ChatSessionSummary, LoadedChatState, Message } from "../GlobalChatbox.types";
|
||||
import { cloneMessages } from "../GlobalChatbox.utils";
|
||||
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, saveActiveChatState, updateChatSessionTitle } from "../chatStorage";
|
||||
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createPersistedStateKey, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
|
||||
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
|
||||
|
||||
export const useAgentChatSession = ({
|
||||
projectId,
|
||||
onToolCall,
|
||||
onBeforeSend,
|
||||
getModel,
|
||||
getApprovalMode,
|
||||
}: UseAgentChatSessionOptions) => {
|
||||
const hydrationCompletedRef = useRef(false);
|
||||
const hydrationNonceRef = useRef(0);
|
||||
@@ -181,9 +24,7 @@ export const useAgentChatSession = ({
|
||||
const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined);
|
||||
const [isSessionTitleManuallyEdited, setIsSessionTitleManuallyEdited] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
|
||||
const [branchGroups, setBranchGroups] = useState<BranchGroup[]>([]);
|
||||
const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
|
||||
const [branchTransition, setBranchTransition] = useState<BranchTransition | null>(null);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [isHydrating, setIsHydrating] = useState(true);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
@@ -199,7 +40,6 @@ export const useAgentChatSession = ({
|
||||
title: undefined,
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
branchGroups: [],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -211,6 +51,7 @@ export const useAgentChatSession = ({
|
||||
messagesRef.current = messages;
|
||||
}, [messages]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
|
||||
}, [isSessionTitleManuallyEdited]);
|
||||
@@ -229,17 +70,14 @@ export const useAgentChatSession = ({
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
sessionId: undefined,
|
||||
branchGroups: [],
|
||||
});
|
||||
hydrationCompletedRef.current = true;
|
||||
hydrationNonceRef.current += 1;
|
||||
titleUpdateNonceRef.current += 1;
|
||||
setBranchTransition(null);
|
||||
setMessages([]);
|
||||
setSessionTitle(undefined);
|
||||
setIsSessionTitleManuallyEdited(false);
|
||||
setSessionId(undefined);
|
||||
setBranchGroups([]);
|
||||
setChatSessions([]);
|
||||
setIsHydrating(false);
|
||||
return;
|
||||
@@ -259,11 +97,12 @@ export const useAgentChatSession = ({
|
||||
hydrationNonceRef.current += 1;
|
||||
titleUpdateNonceRef.current += 1;
|
||||
|
||||
setMessages(loadedState.messages);
|
||||
setMessages(
|
||||
normalizeSessionTodos(dedupeQuestionsAcrossMessages(loadedState.messages)),
|
||||
);
|
||||
setSessionTitle(loadedState.title);
|
||||
setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false);
|
||||
setSessionId(loadedState.sessionId);
|
||||
setBranchGroups(loadedState.branchGroups);
|
||||
setChatSessions(sessions);
|
||||
if (
|
||||
loadedState.sessionId &&
|
||||
@@ -301,7 +140,6 @@ export const useAgentChatSession = ({
|
||||
isTitleManuallyEdited: isSessionTitleManuallyEdited,
|
||||
messages,
|
||||
sessionId,
|
||||
branchGroups,
|
||||
};
|
||||
|
||||
const currentStateKey = createPersistedStateKey(state);
|
||||
@@ -331,46 +169,7 @@ export const useAgentChatSession = ({
|
||||
return () => {
|
||||
window.clearTimeout(persistTimer);
|
||||
};
|
||||
}, [branchGroups, 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]);
|
||||
}, [isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]);
|
||||
|
||||
const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => {
|
||||
setMessages((prev) =>
|
||||
@@ -399,19 +198,58 @@ export const useAgentChatSession = ({
|
||||
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;
|
||||
setSessionId(event.sessionId);
|
||||
}
|
||||
|
||||
if (event.type === "state") {
|
||||
const nextMessages = cloneMessages(event.messages as Message[]);
|
||||
const nextMessages = normalizeSessionTodos(
|
||||
dedupeQuestionsAcrossMessages(cloneMessages(event.messages as Message[])),
|
||||
);
|
||||
messagesRef.current = nextMessages;
|
||||
setMessages(nextMessages);
|
||||
setIsStreaming(event.isStreaming);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "session_title") {
|
||||
const nextTitle = event.title.trim();
|
||||
if (nextTitle && !isSessionTitleManuallyEditedRef.current) {
|
||||
const currentSessionId = sessionIdRef.current;
|
||||
const targetSessionId = event.sessionId || currentSessionId;
|
||||
if (targetSessionId === currentSessionId) {
|
||||
setSessionTitle(nextTitle);
|
||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||
sessionId: targetSessionId,
|
||||
title: nextTitle,
|
||||
isTitleManuallyEdited: false,
|
||||
messages: messagesRef.current,
|
||||
});
|
||||
}
|
||||
if (targetSessionId) {
|
||||
const currentNonce = ++titleUpdateNonceRef.current;
|
||||
void updateChatSessionTitle(targetSessionId, nextTitle, {
|
||||
isTitleManuallyEdited: false,
|
||||
})
|
||||
.then(() => listChatSessions())
|
||||
.then((sessions) => {
|
||||
if (titleUpdateNonceRef.current !== currentNonce) return;
|
||||
setChatSessions(sessions);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[GlobalChatbox] Failed to persist session title:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const assistantMessageId = getLastAssistantMessageId(options?.assistantMessageId);
|
||||
if (!assistantMessageId) {
|
||||
return;
|
||||
@@ -442,26 +280,61 @@ export const useAgentChatSession = ({
|
||||
assistantMessageId,
|
||||
appendArtifact,
|
||||
});
|
||||
} else if (event.type === "session_title") {
|
||||
const nextTitle = event.title.trim();
|
||||
if (nextTitle && !isSessionTitleManuallyEditedRef.current) {
|
||||
setSessionTitle(nextTitle);
|
||||
const currentSessionId = sessionIdRef.current;
|
||||
if (currentSessionId) {
|
||||
const currentNonce = ++titleUpdateNonceRef.current;
|
||||
void updateChatSessionTitle(currentSessionId, 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (event.type === "permission_request") {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.id === assistantMessageId
|
||||
? {
|
||||
...message,
|
||||
permissions: upsertPermission(message.permissions, event),
|
||||
}
|
||||
: message,
|
||||
),
|
||||
);
|
||||
} else if (event.type === "permission_response") {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) => {
|
||||
if (message.id !== assistantMessageId || !message.permissions?.length) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
permissions: message.permissions.map((permission) =>
|
||||
permission.requestId === event.requestId
|
||||
? {
|
||||
...permission,
|
||||
status: toPermissionStatus(event.reply),
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
}
|
||||
: permission,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} else if (event.type === "question_request") {
|
||||
setMessages((prev) =>
|
||||
upsertQuestionAcrossMessages(prev, event, assistantMessageId),
|
||||
);
|
||||
} else if (event.type === "question_response") {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.questions?.some((question) => question.requestId === event.requestId)
|
||||
? {
|
||||
...message,
|
||||
questions: applyQuestionResponse(message.questions, event),
|
||||
}
|
||||
: message,
|
||||
),
|
||||
);
|
||||
} else if (event.type === "todo_update") {
|
||||
setMessages((prev) =>
|
||||
normalizeSessionTodos(
|
||||
prev,
|
||||
createTodoUpdateFromEvent(event),
|
||||
assistantMessageId,
|
||||
),
|
||||
);
|
||||
} else if (event.type === "done") {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) => {
|
||||
@@ -491,6 +364,7 @@ export const useAgentChatSession = ({
|
||||
content: message.content || `⚠️ **错误:** ${event.message}`,
|
||||
isError: true,
|
||||
progress: completeRunningProgress(message.progress),
|
||||
todos: cancelRunningTodos(message.todos),
|
||||
}
|
||||
: message,
|
||||
),
|
||||
@@ -542,7 +416,6 @@ export const useAgentChatSession = ({
|
||||
|
||||
await cancelPromiseRef.current?.catch(() => undefined);
|
||||
onBeforeSend?.();
|
||||
setBranchTransition(null);
|
||||
|
||||
const nextUserMessage = userMessage ?? createUserMessage(prompt);
|
||||
const nextAssistantMessage = assistantMessage ?? createAssistantMessage();
|
||||
@@ -567,6 +440,7 @@ export const useAgentChatSession = ({
|
||||
message: prompt,
|
||||
sessionId: sessionIdOverride ?? sessionIdRef.current,
|
||||
model: getModel?.(),
|
||||
approvalMode: getApprovalMode?.(),
|
||||
signal: controller.signal,
|
||||
onEvent: (event) =>
|
||||
applyStreamEvent(event, {
|
||||
@@ -579,11 +453,7 @@ export const useAgentChatSession = ({
|
||||
prev
|
||||
.map((message) =>
|
||||
message.id === nextAssistantMessage.id
|
||||
? {
|
||||
...message,
|
||||
content: message.content || "⚠️ **请求已中断**",
|
||||
isError: true,
|
||||
}
|
||||
? finalizeAssistantMessageAfterAbort(message)
|
||||
: message,
|
||||
)
|
||||
.filter(
|
||||
@@ -593,7 +463,8 @@ export const useAgentChatSession = ({
|
||||
message.role === "assistant" &&
|
||||
message.content.trim().length === 0 &&
|
||||
!(message.artifacts?.length) &&
|
||||
!(message.progress?.length)
|
||||
!(message.progress?.length) &&
|
||||
!message.todos
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -617,7 +488,15 @@ export const useAgentChatSession = ({
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
[applyStreamEvent, getModel, isHydrating, isStreaming, messages, onBeforeSend],
|
||||
[
|
||||
applyStreamEvent,
|
||||
getApprovalMode,
|
||||
getModel,
|
||||
isHydrating,
|
||||
isStreaming,
|
||||
messages,
|
||||
onBeforeSend,
|
||||
],
|
||||
);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
@@ -647,12 +526,219 @@ export const useAgentChatSession = ({
|
||||
cancelPromiseRef.current = trackedCancelPromise;
|
||||
}, [getLastAssistantMessageId]);
|
||||
|
||||
const replyPermission = useCallback(
|
||||
async (requestId: string, reply: PermissionReply) => {
|
||||
const target = messagesRef.current
|
||||
.flatMap((message) => message.permissions ?? [])
|
||||
.find((permission) => permission.requestId === requestId);
|
||||
if (!target || target.status === "submitting") {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.permissions?.some((permission) => permission.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
permissions: message.permissions.map((permission) =>
|
||||
permission.requestId === requestId
|
||||
? { ...permission, status: "submitting", error: undefined }
|
||||
: permission,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await replyAgentPermission(target.sessionId, requestId, reply);
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.permissions?.some((permission) => permission.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
permissions: message.permissions.map((permission) =>
|
||||
permission.requestId === requestId
|
||||
? {
|
||||
...permission,
|
||||
status: toPermissionStatus(reply),
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
}
|
||||
: permission,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.permissions?.some((permission) => permission.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
permissions: message.permissions.map((permission) =>
|
||||
permission.requestId === requestId
|
||||
? {
|
||||
...permission,
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
: permission,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const replyQuestion = useCallback(
|
||||
async (requestId: string, answers: string[][]) => {
|
||||
const target = messagesRef.current
|
||||
.flatMap((message) => message.questions ?? [])
|
||||
.find((question) => question.requestId === requestId);
|
||||
if (!target || target.status === "submitting") {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.questions?.some((question) => question.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
questions: message.questions.map((question) =>
|
||||
question.requestId === requestId
|
||||
? { ...question, status: "submitting", error: undefined }
|
||||
: question,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await replyAgentQuestion(target.sessionId, requestId, answers);
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.questions?.some((question) => question.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
questions: message.questions.map((question) =>
|
||||
question.requestId === requestId
|
||||
? {
|
||||
...question,
|
||||
status: "answered",
|
||||
answers,
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
}
|
||||
: question,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.questions?.some((question) => question.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
questions: message.questions.map((question) =>
|
||||
question.requestId === requestId
|
||||
? {
|
||||
...question,
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
: question,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const rejectQuestion = useCallback(
|
||||
async (requestId: string) => {
|
||||
const target = messagesRef.current
|
||||
.flatMap((message) => message.questions ?? [])
|
||||
.find((question) => question.requestId === requestId);
|
||||
if (!target || target.status === "submitting") {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.questions?.some((question) => question.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
questions: message.questions.map((question) =>
|
||||
question.requestId === requestId
|
||||
? { ...question, status: "submitting", error: undefined }
|
||||
: question,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await rejectAgentQuestion(target.sessionId, requestId);
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.questions?.some((question) => question.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
questions: message.questions.map((question) =>
|
||||
question.requestId === requestId
|
||||
? {
|
||||
...question,
|
||||
status: "rejected",
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
}
|
||||
: question,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.questions?.some((question) => question.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
questions: message.questions.map((question) =>
|
||||
question.requestId === requestId
|
||||
? {
|
||||
...question,
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
: question,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const createSession = useCallback(() => {
|
||||
if (isHydrating || isStreaming) return;
|
||||
|
||||
const controller = abortRef.current;
|
||||
controller?.abort();
|
||||
setBranchTransition(null);
|
||||
hydrationNonceRef.current += 1;
|
||||
titleUpdateNonceRef.current += 1;
|
||||
sessionIdRef.current = undefined;
|
||||
@@ -661,13 +747,11 @@ export const useAgentChatSession = ({
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
sessionId: undefined,
|
||||
branchGroups: [],
|
||||
});
|
||||
setMessages([]);
|
||||
setSessionTitle("新对话");
|
||||
setIsSessionTitleManuallyEdited(false);
|
||||
setSessionId(undefined);
|
||||
setBranchGroups([]);
|
||||
setIsStreaming(false);
|
||||
}, [isHydrating, isStreaming]);
|
||||
|
||||
@@ -688,12 +772,10 @@ export const useAgentChatSession = ({
|
||||
titleUpdateNonceRef.current += 1;
|
||||
sessionIdRef.current = nextState.sessionId;
|
||||
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
|
||||
setBranchTransition(null);
|
||||
setMessages(nextState.messages);
|
||||
setSessionTitle(nextState.title);
|
||||
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
||||
setSessionId(nextState.sessionId);
|
||||
setBranchGroups(nextState.branchGroups);
|
||||
setChatSessions(sessions);
|
||||
if (nextState.sessionId && nextState.isStreaming) {
|
||||
resumeStreamingSession(nextState.sessionId);
|
||||
@@ -737,14 +819,11 @@ export const useAgentChatSession = ({
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
sessionId: undefined,
|
||||
branchGroups: [],
|
||||
});
|
||||
setBranchTransition(null);
|
||||
setMessages([]);
|
||||
setSessionTitle(undefined);
|
||||
setIsSessionTitleManuallyEdited(false);
|
||||
setSessionId(undefined);
|
||||
setBranchGroups([]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -757,12 +836,10 @@ export const useAgentChatSession = ({
|
||||
titleUpdateNonceRef.current += 1;
|
||||
sessionIdRef.current = nextState.sessionId;
|
||||
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
|
||||
setBranchTransition(null);
|
||||
setMessages(nextState.messages);
|
||||
setSessionTitle(nextState.title);
|
||||
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
||||
setSessionId(nextState.sessionId);
|
||||
setBranchGroups(nextState.branchGroups);
|
||||
setChatSessions(sessionsAfterDelete);
|
||||
} catch (error) {
|
||||
console.error("[GlobalChatbox] Failed to delete chat session:", error);
|
||||
@@ -805,183 +882,65 @@ export const useAgentChatSession = ({
|
||||
title: normalizedTitle,
|
||||
isTitleManuallyEdited: true,
|
||||
messages,
|
||||
branchGroups,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[GlobalChatbox] Failed to rename chat session:", error);
|
||||
}
|
||||
},
|
||||
[branchGroups, isHydrating, messages],
|
||||
[isHydrating, messages],
|
||||
);
|
||||
|
||||
const regenerate = useCallback(async () => {
|
||||
if (isHydrating || isStreaming || messages.length === 0) return;
|
||||
|
||||
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) => {
|
||||
const createBranch = useCallback(
|
||||
async (messageId: string) => {
|
||||
if (isHydrating || isStreaming) return;
|
||||
|
||||
const trimmedContent = newContent.trim();
|
||||
if (!trimmedContent) return;
|
||||
const assistantIndex = messages.findIndex(
|
||||
(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 keepMessageCount = messageIndex;
|
||||
const prefix = cloneMessages(messages.slice(0, messageIndex));
|
||||
const originalSuffix = cloneMessages(messages.slice(messageIndex));
|
||||
const keepMessageCount = assistantIndex + 1;
|
||||
const copiedMessages = cloneMessages(messages.slice(0, 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;
|
||||
setSessionId(forkedSessionId);
|
||||
await runPrompt({
|
||||
prompt: trimmedContent,
|
||||
sessionIdOverride: forkedSessionId,
|
||||
preparedMessages: [...prefix, ...nextSuffix],
|
||||
userMessage: nextUserMessage,
|
||||
assistantMessage: nextAssistantMessage,
|
||||
});
|
||||
},
|
||||
[isHydrating, isStreaming, messages, runPrompt],
|
||||
);
|
||||
|
||||
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(),
|
||||
messagesRef.current = copiedMessages;
|
||||
setMessages(copiedMessages);
|
||||
setIsSessionTitleManuallyEdited(false);
|
||||
const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本";
|
||||
setSessionTitle(forkTitle);
|
||||
try {
|
||||
await saveActiveChatState({
|
||||
title: forkTitle,
|
||||
isTitleManuallyEdited: false,
|
||||
messages: copiedMessages,
|
||||
sessionId: forkedSessionId,
|
||||
});
|
||||
sessionIdRef.current = selectedBranch.sessionId;
|
||||
setSessionId(selectedBranch.sessionId);
|
||||
setMessages(nextMessages);
|
||||
|
||||
return next;
|
||||
});
|
||||
setChatSessions(await listChatSessions());
|
||||
} catch (error) {
|
||||
console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error);
|
||||
}
|
||||
},
|
||||
[isHydrating, isStreaming, messages],
|
||||
[isHydrating, isStreaming, messages, sessionTitle],
|
||||
);
|
||||
|
||||
return {
|
||||
messages,
|
||||
chatSessions,
|
||||
activeSessionId: sessionIdRef.current,
|
||||
branchGroups,
|
||||
branchTransition,
|
||||
isHydrating,
|
||||
isStreaming,
|
||||
sessionTitle,
|
||||
sessionId,
|
||||
sendPrompt,
|
||||
regenerate,
|
||||
editAndResubmit,
|
||||
cycleBranch,
|
||||
createBranch,
|
||||
abort,
|
||||
replyPermission,
|
||||
replyQuestion,
|
||||
rejectQuestion,
|
||||
createSession,
|
||||
renameSession,
|
||||
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;
|
||||
};
|
||||
+164
-6
@@ -1,6 +1,10 @@
|
||||
import {
|
||||
abortAgentChat,
|
||||
forkAgentChat,
|
||||
rejectAgentQuestion,
|
||||
replyAgentPermission,
|
||||
replyAgentQuestion,
|
||||
type StreamEvent,
|
||||
resumeAgentChatStream,
|
||||
streamAgentChat,
|
||||
} from "./chatStream";
|
||||
@@ -70,6 +74,7 @@ describe("streamAgentChat", () => {
|
||||
message: "hi",
|
||||
session_id: undefined,
|
||||
model: "deepseek/deepseek-v4-pro",
|
||||
approval_mode: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -162,12 +167,7 @@ describe("streamAgentChat", () => {
|
||||
]),
|
||||
});
|
||||
|
||||
const events: Array<{
|
||||
type: string;
|
||||
sessionId?: string;
|
||||
tool?: string;
|
||||
params?: Record<string, unknown>;
|
||||
}> = [];
|
||||
const events: StreamEvent[] = [];
|
||||
|
||||
await streamAgentChat({
|
||||
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 () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
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 () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
+320
-2
@@ -5,6 +5,56 @@ export type AgentModel =
|
||||
| "deepseek/deepseek-v4-flash"
|
||||
| "deepseek/deepseek-v4-pro";
|
||||
|
||||
export type PermissionReply = "once" | "always" | "reject";
|
||||
export type AgentApprovalMode = "request" | "always";
|
||||
|
||||
export type AgentQuestionStatus =
|
||||
| "pending"
|
||||
| "submitting"
|
||||
| "answered"
|
||||
| "rejected"
|
||||
| "error";
|
||||
|
||||
export type AgentQuestionRequest = {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
questions: Array<{
|
||||
header: string;
|
||||
question: string;
|
||||
options: Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
}>;
|
||||
multiple?: boolean;
|
||||
custom?: boolean;
|
||||
}>;
|
||||
tool?: {
|
||||
messageID: string;
|
||||
callID: string;
|
||||
};
|
||||
createdAt: number;
|
||||
repliedAt?: number;
|
||||
status: AgentQuestionStatus;
|
||||
answers?: string[][];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type AgentTodoItem = {
|
||||
id: string;
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed" | "cancelled";
|
||||
priority?: "low" | "medium" | "high";
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
};
|
||||
|
||||
export type AgentTodoUpdate = {
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
todos: AgentTodoItem[];
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type StreamEvent =
|
||||
| {
|
||||
type: "state";
|
||||
@@ -41,12 +91,55 @@ export type StreamEvent =
|
||||
sessionId: string;
|
||||
tool: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: "permission_request";
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
permission: string;
|
||||
patterns: string[];
|
||||
target?: string;
|
||||
always: string[];
|
||||
tool?: {
|
||||
messageID: string;
|
||||
callID: string;
|
||||
};
|
||||
createdAt: number;
|
||||
}
|
||||
| {
|
||||
type: "permission_response";
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
reply: PermissionReply;
|
||||
}
|
||||
| {
|
||||
type: "question_request";
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
questions: AgentQuestionRequest["questions"];
|
||||
tool?: AgentQuestionRequest["tool"];
|
||||
createdAt: number;
|
||||
}
|
||||
| {
|
||||
type: "question_response";
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
answers?: string[][];
|
||||
rejected?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "todo_update";
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
todos: AgentTodoItem[];
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
type StreamOptions = {
|
||||
message: string;
|
||||
sessionId?: string;
|
||||
model?: AgentModel;
|
||||
approvalMode?: AgentApprovalMode;
|
||||
signal?: AbortSignal;
|
||||
onEvent: (event: StreamEvent) => void;
|
||||
};
|
||||
@@ -100,6 +193,80 @@ const resolveToolParams = (
|
||||
return isObjectRecord(params) ? params : {};
|
||||
};
|
||||
|
||||
const normalizeQuestionList = (value: unknown): AgentQuestionRequest["questions"] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter(isObjectRecord)
|
||||
.map((question) => ({
|
||||
header: typeof question.header === "string" ? question.header : "",
|
||||
question: typeof question.question === "string" ? question.question : "",
|
||||
options: Array.isArray(question.options)
|
||||
? question.options.filter(isObjectRecord).map((option) => ({
|
||||
label: typeof option.label === "string" ? option.label : "",
|
||||
description:
|
||||
typeof option.description === "string" ? option.description : "",
|
||||
}))
|
||||
: [],
|
||||
multiple: typeof question.multiple === "boolean" ? question.multiple : undefined,
|
||||
custom: typeof question.custom === "boolean" ? question.custom : undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const normalizeAnswers = (value: unknown): string[][] | undefined => {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value.map((answer) =>
|
||||
Array.isArray(answer)
|
||||
? answer.filter((item): item is string => typeof item === "string")
|
||||
: [],
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeQuestionTool = (value: unknown): AgentQuestionRequest["tool"] => {
|
||||
if (!isObjectRecord(value)) return undefined;
|
||||
const messageID =
|
||||
typeof value.messageID === "string"
|
||||
? value.messageID
|
||||
: typeof value.message_id === "string"
|
||||
? value.message_id
|
||||
: undefined;
|
||||
const callID =
|
||||
typeof value.callID === "string"
|
||||
? value.callID
|
||||
: typeof value.call_id === "string"
|
||||
? value.call_id
|
||||
: undefined;
|
||||
return messageID && callID ? { messageID, callID } : undefined;
|
||||
};
|
||||
|
||||
const normalizeTodoStatus = (value: unknown): AgentTodoItem["status"] => {
|
||||
if (value === "in_progress" || value === "completed" || value === "cancelled") {
|
||||
return value;
|
||||
}
|
||||
return "pending";
|
||||
};
|
||||
|
||||
const normalizeTodoPriority = (value: unknown): AgentTodoItem["priority"] => {
|
||||
if (value === "low" || value === "medium" || value === "high") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeTodos = (value: unknown): AgentTodoItem[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isObjectRecord).map((todo, index) => ({
|
||||
id:
|
||||
typeof todo.id === "string" && todo.id.trim()
|
||||
? todo.id
|
||||
: `todo-${index}`,
|
||||
content: typeof todo.content === "string" ? todo.content : "",
|
||||
status: normalizeTodoStatus(todo.status),
|
||||
priority: normalizeTodoPriority(todo.priority),
|
||||
createdAt: typeof todo.created_at === "number" ? todo.created_at : undefined,
|
||||
updatedAt: typeof todo.updated_at === "number" ? todo.updated_at : undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const emitParsedStreamEvent = (
|
||||
event: string,
|
||||
data: string,
|
||||
@@ -111,7 +278,7 @@ const emitParsedStreamEvent = (
|
||||
content?: string;
|
||||
message?: string;
|
||||
detail?: string;
|
||||
tool?: string;
|
||||
tool?: unknown;
|
||||
params?: Record<string, unknown>;
|
||||
arguments?: unknown;
|
||||
id?: string;
|
||||
@@ -126,6 +293,18 @@ const emitParsedStreamEvent = (
|
||||
elapsed_ms?: number;
|
||||
duration_ms?: number;
|
||||
total_duration_ms?: number;
|
||||
request_id?: string;
|
||||
permission?: string;
|
||||
patterns?: unknown;
|
||||
target?: string;
|
||||
always?: unknown;
|
||||
created_at?: number;
|
||||
reply?: PermissionReply;
|
||||
questions?: unknown;
|
||||
answers?: unknown;
|
||||
rejected?: boolean;
|
||||
message_id?: string;
|
||||
todos?: unknown;
|
||||
};
|
||||
if (event === "state") {
|
||||
onEvent({
|
||||
@@ -179,9 +358,64 @@ const emitParsedStreamEvent = (
|
||||
onEvent({
|
||||
type: "tool_call",
|
||||
sessionId: parsed.session_id ?? "",
|
||||
tool: parsed.tool ?? "",
|
||||
tool: typeof parsed.tool === "string" ? parsed.tool : "",
|
||||
params: resolveToolParams(parsed.params, parsed.arguments),
|
||||
});
|
||||
} else if (event === "permission_request") {
|
||||
onEvent({
|
||||
type: "permission_request",
|
||||
sessionId: parsed.session_id ?? "",
|
||||
requestId: parsed.request_id ?? "",
|
||||
permission: parsed.permission ?? "",
|
||||
patterns: Array.isArray(parsed.patterns)
|
||||
? parsed.patterns.filter((item): item is string => typeof item === "string")
|
||||
: [],
|
||||
target: typeof parsed.target === "string" ? parsed.target : undefined,
|
||||
always: Array.isArray(parsed.always)
|
||||
? parsed.always.filter((item): item is string => typeof item === "string")
|
||||
: [],
|
||||
tool: isObjectRecord(parsed.tool) &&
|
||||
typeof parsed.tool.messageID === "string" &&
|
||||
typeof parsed.tool.callID === "string"
|
||||
? {
|
||||
messageID: parsed.tool.messageID,
|
||||
callID: parsed.tool.callID,
|
||||
}
|
||||
: undefined,
|
||||
createdAt: parsed.created_at ?? Date.now(),
|
||||
});
|
||||
} else if (event === "permission_response") {
|
||||
onEvent({
|
||||
type: "permission_response",
|
||||
sessionId: parsed.session_id ?? "",
|
||||
requestId: parsed.request_id ?? "",
|
||||
reply: parsed.reply ?? "reject",
|
||||
});
|
||||
} else if (event === "question_request") {
|
||||
onEvent({
|
||||
type: "question_request",
|
||||
sessionId: parsed.session_id ?? "",
|
||||
requestId: parsed.request_id ?? "",
|
||||
questions: normalizeQuestionList(parsed.questions),
|
||||
tool: normalizeQuestionTool(parsed.tool),
|
||||
createdAt: parsed.created_at ?? Date.now(),
|
||||
});
|
||||
} else if (event === "question_response") {
|
||||
onEvent({
|
||||
type: "question_response",
|
||||
sessionId: parsed.session_id ?? "",
|
||||
requestId: parsed.request_id ?? "",
|
||||
answers: normalizeAnswers(parsed.answers),
|
||||
rejected: parsed.rejected === true,
|
||||
});
|
||||
} else if (event === "todo_update") {
|
||||
onEvent({
|
||||
type: "todo_update",
|
||||
sessionId: parsed.session_id ?? "",
|
||||
messageId: parsed.message_id,
|
||||
todos: normalizeTodos(parsed.todos),
|
||||
createdAt: parsed.created_at ?? Date.now(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
onEvent({
|
||||
@@ -224,6 +458,7 @@ export const streamAgentChat = async ({
|
||||
message,
|
||||
sessionId,
|
||||
model,
|
||||
approvalMode,
|
||||
signal,
|
||||
onEvent,
|
||||
}: StreamOptions) => {
|
||||
@@ -242,6 +477,7 @@ export const streamAgentChat = async ({
|
||||
message,
|
||||
session_id: sessionId,
|
||||
model,
|
||||
approval_mode: approvalMode,
|
||||
}),
|
||||
projectHeaderMode: "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) => {
|
||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/fork`, {
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user