1609 lines
56 KiB
TypeScript
1609 lines
56 KiB
TypeScript
"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";
|