300 lines
12 KiB
TypeScript
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";
|