"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 }) => (
{children}
);
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 (
setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{isEditing ? (
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,
}}
/>
{ setEditDraft(message.content); setIsEditing(false); }}
sx={{
bgcolor: alpha("#000", 0.05),
color: "text.secondary",
width: 34, height: 34,
"&:hover": { bgcolor: alpha("#000", 0.1) }
}}
>
{
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) }
}}
>
) : (
<>
{message.content}
{isHovered && !isEditing && (
{ 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" }
}}
>
)}
{branchState && branchState.total > 1 ? (
onCycleBranch(rootMessageId, -1)}
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
>
{branchState.activeIndex + 1} / {branchState.total}
onCycleBranch(rootMessageId, 1)}
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
>
) : null}
>
)}
);
}
return (
setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{message.progress?.length ? (
) : null}
分析结果
{contentSegments.map((segment, segIdx) => {
if (segment.type === "text") {
const text = segment.content.trim();
if (!text && contentSegments.length > 1) return null;
return {text || "..."};
}
if (segment.type === "tool_call") {
if (
segment.toolCall.tool === "chart" ||
segment.toolCall.tool === "show_chart"
) {
const p = segment.toolCall.params;
return (
);
}
return (
);
}
if (segment.type === "tool_call_pending") {
return (
正在准备工具调用...
);
}
return null;
})}
{isHovered && (
{
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) } }}
>
{
onRegenerate();
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
>
)}
{(!isErrorMessage && isTtsSupported) || (branchState && branchState.total > 1) ? (
{!isErrorMessage && isTtsSupported ? (
<>
{messageSpeechState === "idle" ? (
onSpeak(message.id, stripMarkdown(answerContent))}
aria-label="朗读消息"
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
>
) : null}
{messageSpeechState === "playing" ? (
<>
>
) : null}
{messageSpeechState === "paused" ? (
<>
>
) : null}
>
) : null}
{branchState && branchState.total > 1 ? (
onCycleBranch(rootMessageId, -1)}
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
>
{branchState.activeIndex + 1} / {branchState.total}
onCycleBranch(rootMessageId, 1)}
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
>
) : null}
) : null}
);
},
);
AgentTurn.displayName = "AgentTurn";