Files
TJWaterFrontend_Refine/src/components/chat/AgentTurn.tsx
T

1609 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Image from "next/image";
import React, { useMemo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { AnimatePresence, motion } from "framer-motion";
import {
Avatar,
Box,
Button,
Checkbox,
Chip,
CircularProgress,
Collapse,
FormControlLabel,
IconButton,
Paper,
Stack,
TextField,
Tooltip,
Typography,
alpha,
useTheme,
} from "@mui/material";
import type { Theme } from "@mui/material/styles";
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
import RefreshRounded from "@mui/icons-material/RefreshRounded";
import { TbArrowsSplit2 } from "react-icons/tb";
import {
parseAssistantMessageSections,
parseContentWithToolCalls,
type ContentSegment,
} from "./chatMessageSections";
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
import type { Message, SpeechState } from "./GlobalChatbox.types";
import { stripMarkdown } from "./GlobalChatbox.utils";
import { AgentProgressTimeline } from "./AgentProgressTimeline";
import { ChatInlineChart } from "./ChatInlineChart";
import type { ChatChartSeries } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock";
import { AgentArtifactPanel } from "./AgentArtifactPanel";
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
import PauseRounded from "@mui/icons-material/PauseRounded";
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
import StopRounded from "@mui/icons-material/StopRounded";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
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 AssignmentTurnedInRounded from "@mui/icons-material/AssignmentTurnedInRounded";
import HelpOutlineRounded from "@mui/icons-material/HelpOutlineRounded";
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
import type { PermissionReply } from "@/lib/chatStream";
type AgentTurnProps = {
message: Message;
messageSpeechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPause: () => void;
onResume: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
onRegenerate: (messageId: string) => void;
onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
onReplyQuestion: (requestId: string, answers: string[][]) => void;
onRejectQuestion: (requestId: string) => void;
};
const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, "");
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>
);
};
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>
);
};
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>
);
};
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 [custom, setCustom] = React.useState<Record<number, string>>({});
const answers = React.useMemo(
() =>
questionRequest.questions.map((question, index) => {
const selectedAnswers = selected[index] ?? [];
const customAnswer = custom[index]?.trim();
return customAnswer ? [...selectedAnswers, customAnswer] : selectedAnswers;
}),
[custom, questionRequest.questions, selected],
);
const canSubmit =
isEditable &&
questionRequest.questions.length > 0 &&
questionRequest.questions.every((question, index) => {
const answer = answers[index] ?? [];
const hasInput = answer.some((item) => item.trim().length > 0);
const canAnswer = question.options.length > 0 || question.custom === true;
return canAnswer && hasInput;
});
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 setQuestionAnswers = (nextAnswers: string[]) => {
setSelected((current) => ({
...current,
[index]: nextAnswers,
}));
};
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])}
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>
);
})}
</Stack>
) : null}
{question.custom ? (
<TextField
multiline
minRows={2}
maxRows={5}
fullWidth
size="small"
disabled={!isEditable || isSubmitting}
value={custom[index] ?? ""}
onChange={(event) =>
setCustom((current) => ({
...current,
[index]: event.target.value,
}))
}
placeholder="补充说明"
sx={{ mt: 1 }}
/>
) : null}
</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>
);
};
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>
);
const TodoPlanCard = ({
todoUpdate,
}: {
todoUpdate: NonNullable<Message["todos"]>[number];
}) => {
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 isAborted = cancelled > 0 && !running;
const [expanded, setExpanded] = React.useState(
!isAborted && todoUpdate.todos.length <= 3,
);
React.useEffect(() => {
if (isAborted) {
setExpanded(false);
}
}, [isAborted]);
const visibleTodos =
isAborted && !expanded
? []
: expanded || total <= 3
? todoUpdate.todos
: [
...todoUpdate.todos.slice(0, 3),
...(running && !todoUpdate.todos.slice(0, 3).some((todo) => todo.id === running.id)
? [running]
: []),
];
const getTodoVisual = (status: NonNullable<Message["todos"]>[number]["todos"][number]["status"]) => {
if (status === "completed") {
return { icon: <CheckCircleRounded sx={{ fontSize: 18 }} />, color: theme.palette.success.main, label: "已完成" };
}
if (status === "in_progress") {
return { icon: <CircularProgress size={16} />, color: "#0288d1", label: "进行中" };
}
if (status === "cancelled") {
return { icon: <BlockRounded sx={{ fontSize: 18 }} />, color: theme.palette.text.disabled, label: "已中止" };
}
return { icon: <RadioButtonUncheckedRounded sx={{ fontSize: 18 }} />, color: theme.palette.text.secondary, label: "待处理" };
};
if (total === 0) {
return null;
}
return (
<Box
sx={{
borderRadius: 3,
overflow: "hidden",
border: `1px solid ${alpha("#fff", 0.72)}`,
bgcolor: alpha("#fff", 0.48),
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: "#00838f",
bgcolor: alpha("#00838f", 0.1),
border: `1px solid ${alpha("#00838f", 0.15)}`,
}}
>
<AssignmentTurnedInRounded sx={{ fontSize: 18 }} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
</Typography>
<Typography variant="caption" color="text.secondary">
{isAborted
? `${completed}/${total} 已完成,${cancelled} 项已中止`
: `${completed}/${total} 已完成${running ? "1 项进行中" : ""}`}
</Typography>
</Box>
<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>
{visibleTodos.length ? (
<Stack spacing={0} sx={{ px: 1.5, pb: 1.2 }}>
{visibleTodos.map((todo, index) => {
const visual = getTodoVisual(todo.status);
return (
<Stack
key={`${todo.id}-${index}`}
direction="row"
alignItems="center"
spacing={1}
sx={{
py: 0.75,
borderTop: index === 0 ? `1px solid ${alpha("#000", 0.05)}` : "none",
color: todo.status === "cancelled" ? "text.disabled" : "text.primary",
}}
>
<Box
sx={{
width: 24,
height: 24,
borderRadius: "50%",
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: visual.color,
bgcolor: alpha(visual.color, 0.08),
}}
>
{visual.icon}
</Box>
<Typography
variant="body2"
sx={{
minWidth: 0,
flex: 1,
wordBreak: "break-word",
textDecoration: todo.status === "cancelled" ? "line-through" : undefined,
}}
>
{todo.content}
</Typography>
<Chip
size="small"
label={visual.label}
sx={{
height: 22,
borderRadius: "11px",
fontSize: "0.68rem",
fontWeight: 800,
color: visual.color,
bgcolor: alpha(visual.color, 0.08),
"& .MuiChip-label": { px: 0.85 },
}}
/>
</Stack>
);
})}
</Stack>
) : null}
</Box>
);
};
export const AgentTurn = React.memo(
({
message,
messageSpeechState,
onSpeak,
onPause,
onResume,
onStopSpeech,
isTtsSupported,
onRegenerate,
onCreateBranch,
onReplyPermission,
onReplyQuestion,
onRejectQuestion,
}: AgentTurnProps) => {
const theme = useTheme();
const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError);
const [isHovered, setIsHovered] = React.useState(false);
const isProgressComplete = message.progress?.some(
(item) => item.phase === "complete" && item.status === "completed",
) ?? false;
const isProgressRunning = !isErrorMessage && !isProgressComplete && (
message.progress?.some((item) => item.status === "running") ?? false
);
const parsedAssistantSections = useMemo(
() =>
!isUser && !isErrorMessage
? parseAssistantMessageSections(message.content)
: null,
[isErrorMessage, isUser, message.content],
);
const answerContent = parsedAssistantSections?.answer ?? message.content;
const contentSegments: ContentSegment[] = useMemo(
() =>
!isUser && !isErrorMessage
? parseContentWithToolCalls(answerContent).segments
: [{ type: "text", content: answerContent }],
[answerContent, isErrorMessage, isUser],
);
if (isUser) {
return (
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8 }}
transition={{ type: "spring", stiffness: 350, damping: 25 }}
style={{ alignSelf: "flex-end", maxWidth: "86%", position: "relative" }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Paper
elevation={4}
sx={{
p: 2,
borderRadius: 5,
borderBottomRightRadius: 2,
color: "#fff",
background: `linear-gradient(135deg, #0288d1, #00acc1)`,
boxShadow: `0 8px 24px -8px ${alpha("#00acc1", 0.5)}, inset 0 2px 4px ${alpha("#fff", 0.2)}`,
backdropFilter: "blur(10px)",
"--chat-md-text": alpha("#fff", 0.96),
"--chat-md-heading": "#fff",
"--chat-md-link": "#e0f7fa",
"--chat-md-link-hover": "#fff",
"--chat-md-inline-code-bg": "rgba(255,255,255,0.15)",
"--chat-md-inline-code-border": alpha("#fff", 0.1),
"--chat-md-inline-code-text": "#fff",
"--chat-md-pre-bg": "rgba(0, 0, 0, 0.25)",
"--chat-md-pre-border": alpha("#fff", 0.1),
"--chat-md-pre-text": "#F8FAFC",
"--chat-md-quote-border": alpha("#fff", 0.4),
"--chat-md-quote-bg": alpha("#fff", 0.05),
"--chat-md-quote-text": alpha("#fff", 0.8),
}}
>
<MarkdownBlock>{message.content}</MarkdownBlock>
</Paper>
</motion.div>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ type: "spring", stiffness: 320, damping: 26 }}
style={{ width: "100%", position: "relative" }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Stack direction="row" spacing={1.5} alignItems="flex-start">
<Avatar
sx={{
width: 34,
height: 34,
background: alpha("#ffffff", 0.9),
boxShadow: `0 4px 12px ${alpha("#00acc1", 0.25)}`,
border: `1.5px solid ${alpha("#fff", 0.8)}`,
color: "#00acc1",
mt: 0.25,
p: 0.5,
}}
>
<Image
src="/ai-agent.svg"
alt="TJWater Agent"
width={18}
height={18}
style={{ objectFit: "contain" }}
/>
</Avatar>
<Paper
elevation={0}
sx={{
flex: 1,
minWidth: 0,
p: 2,
borderRadius: 5,
bgcolor: alpha("#ffffff", 0.65),
border: `1px solid ${alpha("#fff", 0.8)}`,
boxShadow: `0 10px 30px -10px ${alpha(theme.palette.common.black, 0.08)}`,
backdropFilter: "blur(20px)",
position: "relative",
"--chat-md-text": "text.primary",
"--chat-md-heading": "text.primary",
"--chat-md-link": "#00838f",
"--chat-md-link-hover": "#00acc1",
"--chat-md-inline-code-bg": alpha("#00acc1", 0.08),
"--chat-md-inline-code-border": alpha("#00acc1", 0.15),
"--chat-md-inline-code-text": "#006064",
"--chat-md-pre-bg": "#1e293b",
"--chat-md-pre-border": "#475569",
"--chat-md-pre-text": "#f1f5f9",
"--chat-md-quote-border": "#00acc1",
"--chat-md-quote-bg": alpha("#00acc1", 0.04),
"--chat-md-quote-text": "text.secondary",
}}
>
<Stack spacing={1.5}>
{message.progress?.length ? (
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
) : null}
{message.permissions?.length ? (
<PermissionRequestGroup
permissions={message.permissions}
isRunning={isProgressRunning}
onReply={onReplyPermission}
/>
) : null}
{message.questions?.length ? (
<QuestionRequestGroup
questions={message.questions}
onReply={onReplyQuestion}
onReject={onRejectQuestion}
/>
) : null}
{message.todos?.length ? (
<TodoPlanCard todoUpdate={message.todos[message.todos.length - 1]} />
) : null}
<Box
sx={{
p: 1.5,
borderRadius: 4,
bgcolor: alpha("#fff", 0.4),
border: `1px solid ${alpha("#fff", 0.6)}`,
}}
>
<Stack spacing={1.2}>
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
</Typography>
{contentSegments.map((segment, segIdx) => {
if (segment.type === "text") {
const text = segment.content.trim();
if (!text && contentSegments.length > 1) return null;
return <MarkdownBlock key={segIdx}>{text || "..."}</MarkdownBlock>;
}
if (segment.type === "tool_call") {
if (
segment.toolCall.tool === "chart" ||
segment.toolCall.tool === "show_chart"
) {
const p = segment.toolCall.params;
return (
<ChatInlineChart
key={segment.toolCall.id}
title={(p.title as string) ?? undefined}
chart_type={
(p.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={(p.x_data as string[]) ?? []}
series={(p.series as ChatChartSeries[]) ?? []}
x_axis_name={(p.x_axis_name as string) ?? undefined}
y_axis_name={(p.y_axis_name as string) ?? undefined}
/>
);
}
return (
<ChatToolCallBlock
key={segment.toolCall.id}
toolCall={segment.toolCall}
/>
);
}
if (segment.type === "tool_call_pending") {
return (
<Typography key="tool-pending" variant="caption" color="text.secondary">
...
</Typography>
);
}
return null;
})}
</Stack>
</Box>
</Stack>
<AnimatePresence>
{isHovered && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 5 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 5 }}
transition={{ duration: 0.15 }}
style={{ position: "absolute", top: -14, right: 12, zIndex: 10 }}
>
<Paper
elevation={4}
sx={{
display: "flex",
gap: 0.5,
p: 0.5,
borderRadius: "16px",
bgcolor: alpha("#fff", 0.8),
backdropFilter: "blur(16px)",
border: `1px solid ${alpha("#fff", 0.9)}`,
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
}}
>
<Tooltip title="复制">
<IconButton
size="small"
aria-label="复制"
onClick={() => {
navigator.clipboard.writeText(
normalizeClipboardText(message.content),
);
// Could add a toast here
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
>
<ContentCopyRounded sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Tooltip title="重新生成">
<IconButton
size="small"
aria-label="重新生成"
onClick={() => {
onRegenerate(message.id);
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
>
<RefreshRounded sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Tooltip title="拆分为新会话">
<IconButton
size="small"
aria-label="拆分为新会话"
onClick={() => {
onCreateBranch(message.id);
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
>
<TbArrowsSplit2 size={16} />
</IconButton>
</Tooltip>
</Paper>
</motion.div>
)}
</AnimatePresence>
</Paper>
</Stack>
{!isErrorMessage && isTtsSupported ? (
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 0.5, ml: 6, mb: 1 }}>
<Stack direction="row" spacing={0.5} sx={{ opacity: isHovered ? 1 : 0.4, transition: "opacity 0.2s" }}>
{messageSpeechState === "idle" ? (
<IconButton
size="small"
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
aria-label="朗读消息"
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
>
<VolumeUpRounded sx={{ fontSize: 16 }} />
</IconButton>
) : null}
{messageSpeechState === "playing" ? (
<>
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
<PauseRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
) : null}
{messageSpeechState === "paused" ? (
<>
<IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}>
<PlayArrowRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
) : null}
</Stack>
</Stack>
) : null}
</motion.div>
);
},
);
AgentTurn.displayName = "AgentTurn";