Agent 初版设计
This commit is contained in:
@@ -1,39 +1,8 @@
|
||||
"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";
|
||||
import { Box, Stack } from "@mui/material";
|
||||
|
||||
export const TypingIndicator = () => {
|
||||
return (
|
||||
@@ -105,401 +74,3 @@ export const Blob = ({
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user