refactor(chat): split oversized chat modules
This commit is contained in:
@@ -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,617 @@
|
|||||||
|
"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 formatMetadataValue = (value: unknown) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return "[unserializable]";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncateText = (value: string, maxLength: number) =>
|
||||||
|
value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
|
||||||
|
|
||||||
|
const formatMetadata = (metadata: Record<string, unknown>) => {
|
||||||
|
const entries = Object.entries(metadata)
|
||||||
|
.filter(([key]) => !["command", "path", "file", "directory"].includes(key))
|
||||||
|
.slice(0, 3);
|
||||||
|
if (!entries.length) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
.map(([key, value]) => `${key}: ${truncateText(formatMetadataValue(value), 64)}`)
|
||||||
|
.join(";");
|
||||||
|
};
|
||||||
|
|
||||||
|
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],
|
||||||
|
) => {
|
||||||
|
const command = permission.metadata.command;
|
||||||
|
if (typeof command === "string" && command.trim()) {
|
||||||
|
return command.trim();
|
||||||
|
}
|
||||||
|
for (const key of ["path", "file", "directory"]) {
|
||||||
|
const value = permission.metadata[key];
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
return value.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 === "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;
|
||||||
|
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;
|
||||||
|
return "#8a5a00";
|
||||||
|
};
|
||||||
|
|
||||||
|
const PermissionRequestCard = ({
|
||||||
|
permission,
|
||||||
|
onReply,
|
||||||
|
}: {
|
||||||
|
permission: NonNullable<Message["permissions"]>[number];
|
||||||
|
onReply: (requestId: string, reply: PermissionReply) => void;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isPending = permission.status === "pending" || permission.status === "error";
|
||||||
|
const isSubmitting = permission.status === "submitting";
|
||||||
|
const primaryValue = getPermissionPrimaryValue(permission);
|
||||||
|
const metadataText = formatMetadata(permission.metadata);
|
||||||
|
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>
|
||||||
|
|
||||||
|
{metadataText ? (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: "break-word" }}>
|
||||||
|
{metadataText}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</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 pendingCount = permissions.length - onceCount - alwaysCount - rejectedCount;
|
||||||
|
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) },
|
||||||
|
];
|
||||||
|
const chipColor = pendingCount > 0 ? getPermissionStatusColor("pending", theme) : rejectedCount > 0 ? getPermissionStatusColor("rejected", theme) : getPermissionStatusColor("approved_always", theme);
|
||||||
|
const chipTextColor = pendingCount > 0 ? getPermissionStatusTextColor("pending", 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
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
fontFamily: permission.permission === "bash" ? "monospace" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{truncateText(primaryValue, 72)}
|
||||||
|
</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}
|
||||||
|
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}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,457 @@
|
|||||||
|
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,
|
||||||
|
metadata: event.metadata,
|
||||||
|
always: event.always,
|
||||||
|
tool: event.tool,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
status: "pending",
|
||||||
|
};
|
||||||
|
if (index >= 0) {
|
||||||
|
next[index] = {
|
||||||
|
...next[index],
|
||||||
|
...nextItem,
|
||||||
|
status: next[index].status === "submitting" ? "submitting" : nextItem.status,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
next.push(nextItem);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 rejectOpenPermissionsAfterAbort = (
|
||||||
|
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: "rejected" 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 rejectedPermissions = rejectOpenPermissionsAfterAbort(message.permissions);
|
||||||
|
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
|
||||||
|
const hasVisibleOutput =
|
||||||
|
message.content.trim().length > 0 ||
|
||||||
|
Boolean(message.artifacts?.length) ||
|
||||||
|
Boolean(rejectedPermissions?.length) ||
|
||||||
|
Boolean(rejectedQuestions?.length) ||
|
||||||
|
Boolean(completedProgress?.length) ||
|
||||||
|
Boolean(cancelledTodos);
|
||||||
|
|
||||||
|
if (!hasVisibleOutput) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content: message.content || "⚠️ **请求已中断**",
|
||||||
|
isError: true,
|
||||||
|
progress: completedProgress,
|
||||||
|
permissions: rejectedPermissions,
|
||||||
|
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,479 @@
|
|||||||
|
"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 *"],
|
||||||
|
metadata: { command: "rm tmp.txt" },
|
||||||
|
always: ["rm *"],
|
||||||
|
createdAt: 123,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages.at(-1)?.permissions).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "perm-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
status: "pending",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.replyPermission("perm-1", "once");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replyAgentPermission).toHaveBeenCalledWith("session-1", "perm-1", "once");
|
||||||
|
expect(result.current.messages.at(-1)?.permissions?.[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "perm-1",
|
||||||
|
status: "approved_once",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finalizes running progress when aborting an active prompt", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(
|
||||||
|
({ onEvent, signal }) =>
|
||||||
|
new Promise<void>((_, reject) => {
|
||||||
|
onEvent({
|
||||||
|
type: "progress",
|
||||||
|
sessionId: "session-1",
|
||||||
|
id: "request-received",
|
||||||
|
phase: "start",
|
||||||
|
status: "running",
|
||||||
|
title: "开始分析",
|
||||||
|
startedAt: 1000,
|
||||||
|
} satisfies StreamEvent);
|
||||||
|
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"],
|
||||||
|
metadata: { command: "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: "rejected",
|
||||||
|
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("asks the backend to undo the previous user turn before regenerating", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendPrompt("重新分析压力异常");
|
||||||
|
});
|
||||||
|
const assistantMessageId = result.current.messages[1]?.id ?? "";
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.regenerate(assistantMessageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(streamAgentChat).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
message: "重新分析压力异常",
|
||||||
|
regenerateFromMessageIndex: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the current chain when regenerating a middle assistant message", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendPrompt("第一轮");
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendPrompt("第二轮");
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstAssistantMessageId = result.current.messages[1]?.id ?? "";
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.regenerate(firstAssistantMessageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages).toHaveLength(2);
|
||||||
|
expect(result.current.messages[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
role: "user",
|
||||||
|
content: "第一轮",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.current.messages[1]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(streamAgentChat).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
expect.objectContaining({
|
||||||
|
message: "第一轮",
|
||||||
|
regenerateFromMessageIndex: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forks a copied conversation from an assistant message", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendPrompt("第一轮");
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstAssistantMessageId = result.current.messages[1]?.id ?? "";
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.createBranch(firstAssistantMessageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(forkAgentChat).toHaveBeenCalledWith(undefined, 2);
|
||||||
|
expect(result.current.activeSessionId).toBe("forked-session");
|
||||||
|
expect(result.current.messages).toHaveLength(2);
|
||||||
|
expect(result.current.messages[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
role: "user",
|
||||||
|
content: "第一轮",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.current.messages[1]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
role: "assistant",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(streamAgentChat).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,509 +2,13 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
|
||||||
abortAgentChat,
|
import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
|
||||||
forkAgentChat,
|
import type { AgentArtifact, ChatSessionSummary, LoadedChatState, Message } from "../GlobalChatbox.types";
|
||||||
rejectAgentQuestion,
|
import { cloneMessages } from "../GlobalChatbox.utils";
|
||||||
replyAgentPermission,
|
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, saveActiveChatState, updateChatSessionTitle } from "../chatStorage";
|
||||||
replyAgentQuestion,
|
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createPersistedStateKey, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
|
||||||
resumeAgentChatStream,
|
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
|
||||||
streamAgentChat,
|
|
||||||
} from "@/lib/chatStream";
|
|
||||||
import type {
|
|
||||||
AgentApprovalMode,
|
|
||||||
AgentModel,
|
|
||||||
AgentQuestionRequest,
|
|
||||||
AgentTodoUpdate,
|
|
||||||
PermissionReply,
|
|
||||||
StreamEvent,
|
|
||||||
} from "@/lib/chatStream";
|
|
||||||
import type {
|
|
||||||
AgentArtifact,
|
|
||||||
AgentPermissionRequest,
|
|
||||||
ChatProgress,
|
|
||||||
ChatSessionSummary,
|
|
||||||
LoadedChatState,
|
|
||||||
Message,
|
|
||||||
} from "../GlobalChatbox.types";
|
|
||||||
import {
|
|
||||||
cloneMessages,
|
|
||||||
createId,
|
|
||||||
} from "../GlobalChatbox.utils";
|
|
||||||
import {
|
|
||||||
createEmptyChatState,
|
|
||||||
deleteChatSession,
|
|
||||||
listChatSessions,
|
|
||||||
loadChatSessionById,
|
|
||||||
saveActiveChatState,
|
|
||||||
updateChatSessionTitle,
|
|
||||||
} from "../chatStorage";
|
|
||||||
|
|
||||||
type UseAgentChatSessionOptions = {
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PromptRunOptions = {
|
|
||||||
prompt: string;
|
|
||||||
sessionIdOverride?: string;
|
|
||||||
regenerateFromMessageIndex?: number;
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
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 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;
|
|
||||||
|
|
||||||
const upsertPermission = (
|
|
||||||
permissions: AgentPermissionRequest[] | undefined,
|
|
||||||
event: StreamEvent & { type: "permission_request" },
|
|
||||||
) => {
|
|
||||||
const next = [...(permissions ?? [])];
|
|
||||||
const index = next.findIndex((item) => item.requestId === event.requestId);
|
|
||||||
const nextItem: AgentPermissionRequest = {
|
|
||||||
requestId: event.requestId,
|
|
||||||
sessionId: event.sessionId,
|
|
||||||
permission: event.permission,
|
|
||||||
patterns: event.patterns,
|
|
||||||
metadata: event.metadata,
|
|
||||||
always: event.always,
|
|
||||||
tool: event.tool,
|
|
||||||
createdAt: event.createdAt,
|
|
||||||
status: "pending",
|
|
||||||
};
|
|
||||||
if (index >= 0) {
|
|
||||||
next[index] = {
|
|
||||||
...next[index],
|
|
||||||
...nextItem,
|
|
||||||
status: next[index].status === "submitting" ? "submitting" : nextItem.status,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
next.push(nextItem);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["status"] => {
|
|
||||||
if (reply === "always") return "approved_always";
|
|
||||||
if (reply === "once") return "approved_once";
|
|
||||||
return "rejected";
|
|
||||||
};
|
|
||||||
|
|
||||||
const isActionableQuestionRequest = (question: {
|
|
||||||
requestId: string;
|
|
||||||
tool?: AgentQuestionRequest["tool"];
|
|
||||||
}) => Boolean(question.requestId && question.requestId !== question.tool?.callID);
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
const createTodoUpdateFromEvent = (
|
|
||||||
event: StreamEvent & { type: "todo_update" },
|
|
||||||
): AgentTodoUpdate => ({
|
|
||||||
sessionId: event.sessionId,
|
|
||||||
messageId: event.messageId,
|
|
||||||
todos: event.todos,
|
|
||||||
createdAt: event.createdAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const rejectOpenPermissionsAfterAbort = (
|
|
||||||
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: "rejected" as const,
|
|
||||||
repliedAt: Date.now(),
|
|
||||||
error: undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return changed ? nextPermissions : permissions;
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
|
||||||
const completedProgress = completeRunningProgress(message.progress);
|
|
||||||
const cancelledTodos = cancelRunningTodos(message.todos);
|
|
||||||
const rejectedPermissions = rejectOpenPermissionsAfterAbort(message.permissions);
|
|
||||||
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
|
|
||||||
const hasVisibleOutput =
|
|
||||||
message.content.trim().length > 0 ||
|
|
||||||
Boolean(message.artifacts?.length) ||
|
|
||||||
Boolean(rejectedPermissions?.length) ||
|
|
||||||
Boolean(rejectedQuestions?.length) ||
|
|
||||||
Boolean(completedProgress?.length) ||
|
|
||||||
Boolean(cancelledTodos);
|
|
||||||
|
|
||||||
if (!hasVisibleOutput) {
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
content: message.content || "⚠️ **请求已中断**",
|
|
||||||
isError: true,
|
|
||||||
progress: completedProgress,
|
|
||||||
permissions: rejectedPermissions,
|
|
||||||
questions: rejectedQuestions,
|
|
||||||
todos: cancelledTodos,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const createUserMessage = (content: string): Message => {
|
|
||||||
const id = createId();
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
role: "user",
|
|
||||||
content,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const createAssistantMessage = (): Message => ({
|
|
||||||
id: createId(),
|
|
||||||
role: "assistant",
|
|
||||||
content: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useAgentChatSession = ({
|
export const useAgentChatSession = ({
|
||||||
projectId,
|
projectId,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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;
|
||||||
|
regenerateFromMessageIndex?: number;
|
||||||
|
preparedMessages?: Message[];
|
||||||
|
userMessage?: Message;
|
||||||
|
assistantMessage?: Message;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user