506 lines
17 KiB
TypeScript
506 lines
17 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,
|
|
Chip,
|
|
IconButton,
|
|
LinearProgress,
|
|
Paper,
|
|
Stack,
|
|
Typography,
|
|
alpha,
|
|
} from "@mui/material";
|
|
import type { Theme } from "@mui/material/styles";
|
|
import AutoAwesome from "@mui/icons-material/AutoAwesome";
|
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
|
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
|
import HourglassEmptyRounded from "@mui/icons-material/HourglassEmptyRounded";
|
|
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 {
|
|
parseAssistantMessageSections,
|
|
parseContentWithToolCalls,
|
|
type ContentSegment,
|
|
} from "./chatMessageSections";
|
|
import { ChatInlineChart } from "./ChatInlineChart";
|
|
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
|
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
|
import type { ChatProgress, Message, SpeechState } from "./GlobalChatbox.types";
|
|
import { stripMarkdown } from "./GlobalChatbox.utils";
|
|
|
|
export const TypingIndicator = () => {
|
|
return (
|
|
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ p: 1 }}>
|
|
{[0, 1, 2].map((i) => (
|
|
<motion.div
|
|
key={i}
|
|
initial={{ y: 0 }}
|
|
animate={{ y: [-4, 4, -4] }}
|
|
transition={{
|
|
duration: 0.6,
|
|
repeat: Infinity,
|
|
delay: i * 0.15,
|
|
ease: "easeInOut",
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: "50%",
|
|
background: "linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%)",
|
|
}}
|
|
/>
|
|
</motion.div>
|
|
))}
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
export const Blob = ({
|
|
color,
|
|
size,
|
|
top,
|
|
left,
|
|
delay,
|
|
}: {
|
|
color: string;
|
|
size: number;
|
|
top: string;
|
|
left: string;
|
|
delay: number;
|
|
}) => (
|
|
<motion.div
|
|
initial={{ scale: 0.8, opacity: 0.3, x: 0, y: 0 }}
|
|
animate={{
|
|
scale: [0.8, 1.2, 0.8],
|
|
opacity: [0.3, 0.5, 0.3],
|
|
x: [0, 30, 0],
|
|
y: [0, -30, 0],
|
|
}}
|
|
transition={{
|
|
duration: 8,
|
|
repeat: Infinity,
|
|
ease: "easeInOut",
|
|
delay,
|
|
}}
|
|
style={{
|
|
position: "absolute",
|
|
top,
|
|
left,
|
|
width: size,
|
|
height: size,
|
|
borderRadius: "50%",
|
|
background: color,
|
|
filter: "blur(60px)",
|
|
zIndex: 0,
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
);
|
|
|
|
type ChatMessageItemProps = {
|
|
message: Message;
|
|
theme: Theme;
|
|
messageSpeechState: SpeechState;
|
|
onSpeak: (messageId: string, text: string) => void;
|
|
onPause: () => void;
|
|
onResume: () => void;
|
|
onStopSpeech: () => void;
|
|
isTtsSupported: boolean;
|
|
sseChartParams?: Array<{ tool: string; params: Record<string, unknown> }>;
|
|
};
|
|
|
|
export const ChatMessageItem = React.memo(
|
|
({
|
|
message,
|
|
theme,
|
|
messageSpeechState,
|
|
onSpeak,
|
|
onPause,
|
|
onResume,
|
|
onStopSpeech,
|
|
isTtsSupported,
|
|
sseChartParams,
|
|
}: ChatMessageItemProps) => {
|
|
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 }];
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }}
|
|
animate={{ opacity: 1, scale: 1, x: 0 }}
|
|
exit={{ opacity: 0, scale: 0.8 }}
|
|
transition={{ type: "spring", stiffness: 350, damping: 25 }}
|
|
style={{
|
|
alignSelf: isUser ? "flex-end" : "flex-start",
|
|
maxWidth: "85%",
|
|
display: "flex",
|
|
flexDirection: isUser ? "row-reverse" : "row",
|
|
gap: 12,
|
|
alignItems: "flex-end",
|
|
}}
|
|
>
|
|
{!isUser && (
|
|
<Avatar
|
|
sx={{
|
|
width: 28,
|
|
height: 28,
|
|
bgcolor: isErrorMessage
|
|
? alpha(theme.palette.error.main, 0.12)
|
|
: alpha(theme.palette.secondary.main, 0.1),
|
|
mb: 0.5,
|
|
}}
|
|
>
|
|
{isErrorMessage ? (
|
|
<ErrorOutlineRounded sx={{ fontSize: 16, color: "error.main" }} />
|
|
) : (
|
|
<AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} />
|
|
)}
|
|
</Avatar>
|
|
)}
|
|
|
|
<Box>
|
|
<Paper
|
|
elevation={isUser ? 8 : isErrorMessage ? 1 : 2}
|
|
sx={{
|
|
p: 2.5,
|
|
borderRadius: 4,
|
|
borderBottomRightRadius: isUser ? 4 : 24,
|
|
borderBottomLeftRadius: !isUser ? 4 : 24,
|
|
bgcolor: isUser
|
|
? "primary.main"
|
|
: isErrorMessage
|
|
? alpha(theme.palette.error.light, 0.18)
|
|
: "#fff",
|
|
color: isUser ? "#fff" : isErrorMessage ? "error.dark" : "text.primary",
|
|
background: isUser
|
|
? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`
|
|
: isErrorMessage
|
|
? `linear-gradient(135deg, ${alpha(theme.palette.error.light, 0.28)}, ${alpha(theme.palette.error.main, 0.12)})`
|
|
: undefined,
|
|
border: isErrorMessage
|
|
? `1px solid ${alpha(theme.palette.error.main, 0.35)}`
|
|
: "none",
|
|
boxShadow: isUser
|
|
? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}`
|
|
: isErrorMessage
|
|
? `0 4px 16px -4px ${alpha(theme.palette.error.main, 0.2)}`
|
|
: `0 4px 16px -4px ${alpha("#000", 0.05)}`,
|
|
"--chat-md-text": isUser
|
|
? alpha("#fff", 0.96)
|
|
: isErrorMessage
|
|
? theme.palette.error.dark
|
|
: "#1f2937",
|
|
"--chat-md-heading": isUser
|
|
? "#fff"
|
|
: isErrorMessage
|
|
? theme.palette.error.dark
|
|
: "#111827",
|
|
"--chat-md-link": isUser
|
|
? "#E3F2FD"
|
|
: isErrorMessage
|
|
? theme.palette.error.main
|
|
: "#7C3AED",
|
|
"--chat-md-link-hover": isUser
|
|
? "#fff"
|
|
: isErrorMessage
|
|
? theme.palette.error.dark
|
|
: "#6D28D9",
|
|
"--chat-md-inline-code-bg": isUser
|
|
? "rgba(255,255,255,0.2)"
|
|
: isErrorMessage
|
|
? alpha(theme.palette.error.main, 0.08)
|
|
: "#EEF2FF",
|
|
"--chat-md-inline-code-border": isUser
|
|
? alpha("#fff", 0.16)
|
|
: isErrorMessage
|
|
? alpha(theme.palette.error.main, 0.25)
|
|
: "#CBD5E1",
|
|
"--chat-md-inline-code-text": isUser
|
|
? "#fff"
|
|
: isErrorMessage
|
|
? theme.palette.error.dark
|
|
: "#334155",
|
|
"--chat-md-pre-bg": isUser
|
|
? "rgba(11, 18, 32, 0.56)"
|
|
: isErrorMessage
|
|
? alpha(theme.palette.error.main, 0.08)
|
|
: "#111827",
|
|
"--chat-md-pre-border": isUser
|
|
? alpha("#fff", 0.12)
|
|
: isErrorMessage
|
|
? alpha(theme.palette.error.main, 0.3)
|
|
: "#64748B",
|
|
"--chat-md-pre-text": isUser
|
|
? "#F8FAFC"
|
|
: isErrorMessage
|
|
? theme.palette.error.dark
|
|
: "#E5E7EB",
|
|
"--chat-md-quote-border": isErrorMessage
|
|
? alpha(theme.palette.error.main, 0.5)
|
|
: isUser
|
|
? alpha("#fff", 0.5)
|
|
: "#7C3AED",
|
|
"--chat-md-quote-bg": isUser
|
|
? alpha("#fff", 0.08)
|
|
: isErrorMessage
|
|
? alpha(theme.palette.error.main, 0.06)
|
|
: "#F5F3FF",
|
|
"--chat-md-quote-text": isUser
|
|
? alpha("#fff", 0.9)
|
|
: isErrorMessage
|
|
? theme.palette.error.dark
|
|
: "#475569",
|
|
}}
|
|
>
|
|
{!isUser && !isErrorMessage && message.progress?.length ? (
|
|
<ChatProgressPanel progress={message.progress} />
|
|
) : null}
|
|
{contentSegments.map((segment, segIdx) => {
|
|
if (segment.type === "text") {
|
|
const text = segment.content.trim();
|
|
if (!text && contentSegments.length > 1) return null;
|
|
return (
|
|
<div key={segIdx} className={markdownStyles.markdown}>
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
{text || "..."}
|
|
</ReactMarkdown>
|
|
</div>
|
|
);
|
|
}
|
|
if (segment.type === "tool_call") {
|
|
if (segment.toolCall.tool === "chart") {
|
|
return (
|
|
<ChatInlineChart
|
|
key={segment.toolCall.id}
|
|
{...(segment.toolCall.params as Record<string, unknown>)}
|
|
/>
|
|
);
|
|
}
|
|
if (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 import("./ChatInlineChart").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 (
|
|
<motion.div
|
|
key="tool-pending"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: [0.4, 1, 0.4] }}
|
|
transition={{
|
|
duration: 1.5,
|
|
repeat: Infinity,
|
|
ease: "easeInOut",
|
|
}}
|
|
style={{
|
|
marginTop: 8,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<AutoAwesome sx={{ fontSize: 14, color: "primary.main" }} />
|
|
<Typography variant="caption" color="text.secondary">
|
|
正在准备工具调用...
|
|
</Typography>
|
|
</motion.div>
|
|
);
|
|
}
|
|
return null;
|
|
})}
|
|
{sseChartParams?.map((chart, idx) => (
|
|
<ChatInlineChart
|
|
key={`sse-chart-${idx}`}
|
|
title={(chart.params.title as string) ?? undefined}
|
|
chart_type={
|
|
(chart.params.chart_type as "line" | "bar" | "pie") ?? "line"
|
|
}
|
|
x_data={(chart.params.x_data as string[]) ?? []}
|
|
series={
|
|
(chart.params.series as import("./ChatInlineChart").ChatChartSeries[]) ??
|
|
[]
|
|
}
|
|
x_axis_name={(chart.params.x_axis_name as string) ?? undefined}
|
|
y_axis_name={(chart.params.y_axis_name as string) ?? undefined}
|
|
/>
|
|
))}
|
|
</Paper>
|
|
{!isUser && !isErrorMessage && isTtsSupported && (
|
|
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 0.5 }}>
|
|
{messageSpeechState === "idle" && (
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
|
|
aria-label="朗读消息"
|
|
sx={{
|
|
color: "text.secondary",
|
|
opacity: 0.6,
|
|
"&:hover": { opacity: 1 },
|
|
p: 0.5,
|
|
}}
|
|
>
|
|
<VolumeUpRounded sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
)}
|
|
{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>
|
|
</>
|
|
)}
|
|
{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>
|
|
</>
|
|
)}
|
|
</Stack>
|
|
)}
|
|
</Box>
|
|
</motion.div>
|
|
);
|
|
},
|
|
);
|
|
|
|
ChatMessageItem.displayName = "ChatMessageItem";
|
|
|
|
const ChatProgressPanel = ({ progress }: { progress: ChatProgress[] }) => {
|
|
const isComplete = progress.some(
|
|
(item) => item.phase === "complete" && item.status === "completed",
|
|
);
|
|
const latestRunning = isComplete
|
|
? undefined
|
|
: [...progress].reverse().find((item) => item.status === "running");
|
|
return (
|
|
<Box
|
|
sx={{
|
|
mb: 1.5,
|
|
p: 1.25,
|
|
borderRadius: 2.5,
|
|
bgcolor: "rgba(99, 102, 241, 0.06)",
|
|
border: "1px solid rgba(99, 102, 241, 0.14)",
|
|
}}
|
|
>
|
|
<Stack spacing={1}>
|
|
<Stack direction="row" spacing={1} alignItems="center">
|
|
<AutoAwesome sx={{ fontSize: 16, color: "primary.main" }} />
|
|
<Typography variant="caption" fontWeight={800} color="text.primary">
|
|
Agent 过程
|
|
</Typography>
|
|
{latestRunning ? (
|
|
<Chip
|
|
size="small"
|
|
label={latestRunning.title}
|
|
sx={{ height: 20, fontSize: "0.68rem", bgcolor: "rgba(124, 58, 237, 0.08)" }}
|
|
/>
|
|
) : null}
|
|
</Stack>
|
|
{latestRunning ? <LinearProgress sx={{ height: 4, borderRadius: 99 }} /> : null}
|
|
<Stack spacing={0.7}>
|
|
{progress.slice(-5).map((item) => (
|
|
<Stack key={item.id} direction="row" spacing={0.8} alignItems="flex-start">
|
|
{item.status === "completed" ? (
|
|
<CheckCircleRounded sx={{ fontSize: 15, color: "success.main", mt: 0.2 }} />
|
|
) : item.status === "error" ? (
|
|
<ErrorOutlineRounded sx={{ fontSize: 15, color: "error.main", mt: 0.2 }} />
|
|
) : (
|
|
<HourglassEmptyRounded sx={{ fontSize: 15, color: "primary.main", mt: 0.2 }} />
|
|
)}
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<Typography variant="caption" color="text.primary" fontWeight={700}>
|
|
{item.title}
|
|
</Typography>
|
|
{item.detail ? (
|
|
<Typography
|
|
variant="caption"
|
|
component="pre"
|
|
color="text.secondary"
|
|
sx={{
|
|
display: "block",
|
|
mt: 0.25,
|
|
m: 0,
|
|
whiteSpace: "pre-wrap",
|
|
fontFamily: "inherit",
|
|
fontSize: "0.7rem",
|
|
}}
|
|
>
|
|
{item.detail}
|
|
</Typography>
|
|
) : null}
|
|
</Box>
|
|
</Stack>
|
|
))}
|
|
</Stack>
|
|
</Stack>
|
|
</Box>
|
|
);
|
|
};
|