564 lines
23 KiB
TypeScript
564 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import React, { useMemo } from "react";
|
|
import ReactMarkdown from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import {
|
|
Avatar,
|
|
Box,
|
|
Button,
|
|
IconButton,
|
|
Paper,
|
|
Stack,
|
|
Tooltip,
|
|
Typography,
|
|
alpha,
|
|
useTheme,
|
|
} from "@mui/material";
|
|
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
|
|
import RefreshRounded from "@mui/icons-material/RefreshRounded";
|
|
import EditRounded from "@mui/icons-material/EditRounded";
|
|
import CloseRounded from "@mui/icons-material/CloseRounded";
|
|
import ChevronLeftRounded from "@mui/icons-material/ChevronLeftRounded";
|
|
import ChevronRightRounded from "@mui/icons-material/ChevronRightRounded";
|
|
import {
|
|
parseAssistantMessageSections,
|
|
parseContentWithToolCalls,
|
|
type ContentSegment,
|
|
} from "./chatMessageSections";
|
|
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
|
import type { BranchState, Message, SpeechState } from "./GlobalChatbox.types";
|
|
import { stripMarkdown } from "./GlobalChatbox.utils";
|
|
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
|
import { ChatInlineChart } from "./ChatInlineChart";
|
|
import type { ChatChartSeries } from "./ChatInlineChart";
|
|
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
|
import { AgentArtifactPanel } from "./AgentArtifactPanel";
|
|
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 SendRounded from "@mui/icons-material/SendRounded";
|
|
|
|
type AgentTurnProps = {
|
|
message: Message;
|
|
branchState?: BranchState;
|
|
messageSpeechState: SpeechState;
|
|
onSpeak: (messageId: string, text: string) => void;
|
|
onPause: () => void;
|
|
onResume: () => void;
|
|
onStopSpeech: () => void;
|
|
isTtsSupported: boolean;
|
|
onRegenerate: () => void;
|
|
onEditResubmit: (messageId: string, newContent: string) => void;
|
|
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
|
};
|
|
|
|
const MarkdownBlock = ({ children }: { children: string }) => (
|
|
<div className={markdownStyles.markdown}>
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
|
|
</div>
|
|
);
|
|
|
|
export const AgentTurn = React.memo(
|
|
({
|
|
message,
|
|
branchState,
|
|
messageSpeechState,
|
|
onSpeak,
|
|
onPause,
|
|
onResume,
|
|
onStopSpeech,
|
|
isTtsSupported,
|
|
onRegenerate,
|
|
onEditResubmit,
|
|
onCycleBranch,
|
|
}: AgentTurnProps) => {
|
|
const theme = useTheme();
|
|
const isUser = message.role === "user";
|
|
const isErrorMessage = Boolean(message.isError);
|
|
const [isHovered, setIsHovered] = React.useState(false);
|
|
const [isEditing, setIsEditing] = React.useState(false);
|
|
const [editDraft, setEditDraft] = React.useState(message.content);
|
|
const rootMessageId = message.branchRootId ?? message.id;
|
|
|
|
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)}
|
|
>
|
|
{isEditing ? (
|
|
<Paper
|
|
elevation={12}
|
|
sx={{
|
|
p: 1.5,
|
|
borderRadius: 5,
|
|
bgcolor: alpha("#ffffff", 0.75),
|
|
backdropFilter: "blur(40px)",
|
|
border: `1px solid ${alpha("#ffffff", 0.9)}`,
|
|
boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
|
|
minWidth: { xs: 260, sm: 320, md: 400 },
|
|
maxWidth: "100%",
|
|
}}
|
|
>
|
|
<Box component="textarea"
|
|
autoFocus
|
|
value={editDraft}
|
|
onChange={(e) => setEditDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
if (editDraft.trim() !== message.content) {
|
|
onEditResubmit(message.id, editDraft);
|
|
}
|
|
setIsEditing(false);
|
|
} else if (e.key === "Escape") {
|
|
setEditDraft(message.content);
|
|
setIsEditing(false);
|
|
}
|
|
}}
|
|
sx={{
|
|
width: "100%",
|
|
minHeight: 60,
|
|
bgcolor: "transparent",
|
|
border: "none",
|
|
outline: "none",
|
|
resize: "none",
|
|
fontFamily: "inherit",
|
|
fontSize: "1rem",
|
|
color: "text.primary",
|
|
lineHeight: 1.6,
|
|
}}
|
|
/>
|
|
<Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 1 }}>
|
|
<IconButton
|
|
size="small"
|
|
aria-label="取消"
|
|
onClick={() => { setEditDraft(message.content); setIsEditing(false); }}
|
|
sx={{
|
|
bgcolor: alpha("#000", 0.05),
|
|
color: "text.secondary",
|
|
width: 34, height: 34,
|
|
"&:hover": { bgcolor: alpha("#000", 0.1) }
|
|
}}
|
|
>
|
|
<CloseRounded fontSize="small" />
|
|
</IconButton>
|
|
<IconButton
|
|
size="small"
|
|
aria-label="发送修改"
|
|
disabled={editDraft.trim() === "" || editDraft.trim() === message.content}
|
|
onClick={() => {
|
|
onEditResubmit(message.id, editDraft);
|
|
setIsEditing(false);
|
|
}}
|
|
sx={{
|
|
bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00acc1" : alpha("#000", 0.1),
|
|
color: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#fff" : "action.disabled",
|
|
width: 34, height: 34,
|
|
boxShadow: editDraft.trim() !== "" && editDraft.trim() !== message.content ? `0 4px 12px ${alpha("#00acc1", 0.4)}` : "none",
|
|
"&:hover": { bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00838f" : alpha("#000", 0.1) }
|
|
}}
|
|
>
|
|
<SendRounded fontSize="small" sx={{ ml: 0.2 }} />
|
|
</IconButton>
|
|
</Stack>
|
|
</Paper>
|
|
) : (
|
|
<>
|
|
<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>
|
|
|
|
<AnimatePresence>
|
|
{isHovered && !isEditing && (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.9 }}
|
|
transition={{ duration: 0.15 }}
|
|
style={{ position: "absolute", top: -12, right: -8, zIndex: 10 }}
|
|
>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => { setIsEditing(true); setEditDraft(message.content); }}
|
|
aria-label="编辑提问"
|
|
sx={{
|
|
width: 26,
|
|
height: 26,
|
|
bgcolor: alpha("#fff", 0.9),
|
|
color: "#00acc1",
|
|
boxShadow: `0 2px 8px ${alpha("#000", 0.15)}`,
|
|
"&:hover": { bgcolor: "#fff", color: "#00838f" }
|
|
}}
|
|
>
|
|
<EditRounded sx={{ fontSize: 14 }} />
|
|
</IconButton>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</Paper>
|
|
|
|
{branchState && branchState.total > 1 ? (
|
|
<Stack
|
|
direction="row"
|
|
justifyContent="flex-end"
|
|
sx={{ mt: 0.5, mr: 0.5 }}
|
|
>
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 0.5,
|
|
px: 0.5,
|
|
py: 0.25,
|
|
borderRadius: 4,
|
|
bgcolor: alpha("#000", 0.04),
|
|
backdropFilter: "blur(4px)",
|
|
border: `1px solid ${alpha("#000", 0.08)}`,
|
|
}}
|
|
>
|
|
<IconButton
|
|
size="small"
|
|
aria-label="上一分支"
|
|
onClick={() => onCycleBranch(rootMessageId, -1)}
|
|
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
|
|
>
|
|
<ChevronLeftRounded sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}>
|
|
{branchState.activeIndex + 1} / {branchState.total}
|
|
</Typography>
|
|
<IconButton
|
|
size="small"
|
|
aria-label="下一分支"
|
|
onClick={() => onCycleBranch(rootMessageId, 1)}
|
|
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
|
|
>
|
|
<ChevronRightRounded sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</Paper>
|
|
</Stack>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</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}
|
|
|
|
<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 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>
|
|
</Stack>
|
|
|
|
<AnimatePresence>
|
|
{isHovered && (
|
|
<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(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={() => {
|
|
onRegenerate();
|
|
}}
|
|
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
|
>
|
|
<RefreshRounded sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Paper>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
</Paper>
|
|
</Stack>
|
|
|
|
{(!isErrorMessage && isTtsSupported) || (branchState && branchState.total > 1) ? (
|
|
<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" }}>
|
|
{!isErrorMessage && isTtsSupported ? (
|
|
<>
|
|
{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}
|
|
</>
|
|
) : null}
|
|
</Stack>
|
|
|
|
{branchState && branchState.total > 1 ? (
|
|
<Stack
|
|
direction="row"
|
|
justifyContent="flex-start"
|
|
sx={{ mr: 0.5 }}
|
|
>
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 0.5,
|
|
px: 0.5,
|
|
py: 0.25,
|
|
borderRadius: 4,
|
|
bgcolor: alpha("#000", 0.04),
|
|
backdropFilter: "blur(4px)",
|
|
border: `1px solid ${alpha("#000", 0.08)}`,
|
|
}}
|
|
>
|
|
<IconButton
|
|
size="small"
|
|
aria-label="上一分支"
|
|
onClick={() => onCycleBranch(rootMessageId, -1)}
|
|
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
|
|
>
|
|
<ChevronLeftRounded sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}>
|
|
{branchState.activeIndex + 1} / {branchState.total}
|
|
</Typography>
|
|
<IconButton
|
|
size="small"
|
|
aria-label="下一分支"
|
|
onClick={() => onCycleBranch(rootMessageId, 1)}
|
|
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
|
|
>
|
|
<ChevronRightRounded sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</Paper>
|
|
</Stack>
|
|
) : null}
|
|
</Stack>
|
|
) : null}
|
|
</motion.div>
|
|
);
|
|
},
|
|
);
|
|
|
|
AgentTurn.displayName = "AgentTurn";
|