Files
TJWaterFrontend_Refine/src/components/chat/AgentTurn.tsx
T
2026-04-29 17:15:49 +08:00

300 lines
12 KiB
TypeScript

"use client";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { motion } from "framer-motion";
import {
Avatar,
Box,
IconButton,
Paper,
Stack,
Typography,
alpha,
useTheme,
} from "@mui/material";
import AutoAwesome from "@mui/icons-material/AutoAwesome";
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 { AgentArtifactPanel } from "./AgentArtifactPanel";
import { AgentProgressTimeline } from "./AgentProgressTimeline";
import { ChatInlineChart } from "./ChatInlineChart";
import type { ChatChartSeries } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock";
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";
type AgentTurnProps = {
message: Message;
messageSpeechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPause: () => void;
onResume: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
};
const MarkdownBlock = ({ children }: { children: string }) => (
<div className={markdownStyles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
</div>
);
export const AgentTurn = React.memo(
({
message,
messageSpeechState,
onSpeak,
onPause,
onResume,
onStopSpeech,
isTtsSupported,
}: AgentTurnProps) => {
const theme = useTheme();
const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError);
const parsedAssistantSections =
!isUser && !isErrorMessage
? parseAssistantMessageSections(message.content)
: null;
const answerContent = parsedAssistantSections?.answer ?? message.content;
const contentSegments: ContentSegment[] =
!isUser && !isErrorMessage
? parseContentWithToolCalls(answerContent).segments
: [{ type: "text", content: answerContent }];
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%" }}
>
<Paper
elevation={8}
sx={{
p: 2,
borderRadius: 4,
borderBottomRightRadius: 1.5,
color: "#fff",
background: `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`,
boxShadow: `0 10px 28px -8px ${alpha(theme.palette.primary.main, 0.5)}`,
"--chat-md-text": alpha("#fff", 0.96),
"--chat-md-heading": "#fff",
"--chat-md-link": "#E3F2FD",
"--chat-md-link-hover": "#fff",
"--chat-md-inline-code-bg": "rgba(255,255,255,0.2)",
"--chat-md-inline-code-border": alpha("#fff", 0.16),
"--chat-md-inline-code-text": "#fff",
"--chat-md-pre-bg": "rgba(11, 18, 32, 0.56)",
"--chat-md-pre-border": alpha("#fff", 0.12),
"--chat-md-pre-text": "#F8FAFC",
"--chat-md-quote-border": alpha("#fff", 0.5),
"--chat-md-quote-bg": alpha("#fff", 0.08),
"--chat-md-quote-text": alpha("#fff", 0.9),
}}
>
<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%" }}
>
<Stack direction="row" spacing={1.25} alignItems="flex-start">
<Avatar
sx={{
width: 32,
height: 32,
bgcolor: isErrorMessage
? alpha(theme.palette.error.main, 0.12)
: alpha(theme.palette.secondary.main, 0.12),
mt: 0.25,
}}
>
{isErrorMessage ? (
<ErrorOutlineRounded sx={{ fontSize: 17, color: "error.main" }} />
) : (
<AutoAwesome sx={{ fontSize: 17, color: "secondary.main" }} />
)}
</Avatar>
<Paper
elevation={0}
sx={{
flex: 1,
minWidth: 0,
p: 1.5,
borderRadius: 4,
bgcolor: alpha("#fff", 0.84),
border: `1px solid ${alpha(
isErrorMessage ? theme.palette.error.main : theme.palette.divider,
isErrorMessage ? 0.34 : 0.16,
)}`,
boxShadow: `0 14px 40px -24px ${alpha(theme.palette.common.black, 0.32)}`,
"--chat-md-text": isErrorMessage ? theme.palette.error.dark : "#1f2937",
"--chat-md-heading": isErrorMessage ? theme.palette.error.dark : "#111827",
"--chat-md-link": isErrorMessage ? theme.palette.error.main : "#7C3AED",
"--chat-md-link-hover": isErrorMessage ? theme.palette.error.dark : "#6D28D9",
"--chat-md-inline-code-bg": isErrorMessage
? alpha(theme.palette.error.main, 0.08)
: "#EEF2FF",
"--chat-md-inline-code-border": isErrorMessage
? alpha(theme.palette.error.main, 0.25)
: "#CBD5E1",
"--chat-md-inline-code-text": isErrorMessage
? theme.palette.error.dark
: "#334155",
"--chat-md-pre-bg": isErrorMessage
? alpha(theme.palette.error.main, 0.08)
: "#111827",
"--chat-md-pre-border": isErrorMessage
? alpha(theme.palette.error.main, 0.3)
: "#64748B",
"--chat-md-pre-text": isErrorMessage ? theme.palette.error.dark : "#E5E7EB",
"--chat-md-quote-border": isErrorMessage
? alpha(theme.palette.error.main, 0.5)
: "#7C3AED",
"--chat-md-quote-bg": isErrorMessage
? alpha(theme.palette.error.main, 0.06)
: "#F5F3FF",
"--chat-md-quote-text": isErrorMessage ? theme.palette.error.dark : "#475569",
}}
>
<Stack spacing={1.4}>
{message.progress?.length && !isErrorMessage ? (
<AgentProgressTimeline progress={message.progress} />
) : null}
<Box
sx={{
p: 1.35,
borderRadius: 3,
bgcolor: isErrorMessage
? alpha(theme.palette.error.main, 0.055)
: alpha("#fff", 0.72),
border: `1px solid ${alpha(
isErrorMessage ? theme.palette.error.main : theme.palette.divider,
isErrorMessage ? 0.18 : 0.12,
)}`,
}}
>
<Stack spacing={1}>
{!isErrorMessage ? (
<Typography variant="caption" color="text.secondary" fontWeight={800}>
</Typography>
) : null}
{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>
{message.artifacts?.length ? (
<AgentArtifactPanel artifacts={message.artifacts} />
) : null}
</Stack>
</Paper>
</Stack>
{!isErrorMessage && isTtsSupported ? (
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 5.4 }}>
{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>
) : null}
</motion.div>
);
},
);
AgentTurn.displayName = "AgentTurn";