379 lines
15 KiB
TypeScript
379 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import React, { useMemo } from "react";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import {
|
|
Avatar,
|
|
Box,
|
|
IconButton,
|
|
Paper,
|
|
Stack,
|
|
Tooltip,
|
|
Typography,
|
|
alpha,
|
|
useTheme,
|
|
} from "@mui/material";
|
|
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
|
|
import { TbArrowsSplit2 } from "react-icons/tb";
|
|
import type { PermissionReply } from "@/lib/chatStream";
|
|
import {
|
|
parseAssistantMessageSections,
|
|
parseContentWithToolCalls,
|
|
type ContentSegment,
|
|
} from "./chatMessageSections";
|
|
import type { Message, SpeechState } from "./GlobalChatbox.types";
|
|
import { stripMarkdown } from "./GlobalChatbox.utils";
|
|
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
|
import { ChatInlineChart } from "./ChatInlineChart";
|
|
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
|
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
|
|
import { PermissionRequestGroup } from "./AgentPermissionRequests";
|
|
import { QuestionRequestGroup } from "./AgentQuestionRequests";
|
|
import { TodoPlanCard } from "./AgentTodoPlanCard";
|
|
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";
|
|
|
|
type AgentTurnProps = {
|
|
message: Message;
|
|
isStreaming: boolean;
|
|
messageSpeechState: SpeechState;
|
|
onSpeak: (messageId: string, text: string) => void;
|
|
onPause: () => void;
|
|
onResume: () => void;
|
|
onStopSpeech: () => void;
|
|
isTtsSupported: boolean;
|
|
onCreateBranch: (messageId: string) => void;
|
|
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
|
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
|
onRejectQuestion: (requestId: string) => void;
|
|
};
|
|
|
|
export const AgentTurn = React.memo(
|
|
({
|
|
message,
|
|
isStreaming,
|
|
messageSpeechState,
|
|
onSpeak,
|
|
onPause,
|
|
onResume,
|
|
onStopSpeech,
|
|
isTtsSupported,
|
|
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 ? (
|
|
<TodoPlanCard todoUpdate={message.todos} />
|
|
) : 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 ?? p.xData ?? p.labels ?? p.categories}
|
|
series={p.series}
|
|
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 && !isStreaming && (
|
|
<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={() => {
|
|
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";
|