"use client"; import Image from "next/image"; import React, { useMemo } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { Avatar, Box, CircularProgress, 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 "./globalChatboxUtils"; import { findSpeechSelectionStartOffset } from "./speechStartOptions"; import { AgentProgressTimeline } from "./AgentProgressTimeline"; import { ChartGenerationSkeleton, 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, options?: { startOffset?: number }, ) => 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; }; const StreamingStatus = () => { const theme = useTheme(); return ( {[0, 1, 2].map((index) => ( ))} 正在生成 ); }; const StreamingMarkdownBlock = ({ text, isStreaming, segmentKey, }: { text: string; isStreaming: boolean; segmentKey: string; }) => { const [streamTextState, setStreamTextState] = React.useState<{ displayText: string; animatedTailLength: number; }>({ displayText: text, animatedTailLength: 0, }); React.useLayoutEffect(() => { setStreamTextState((current) => { if (current.displayText === text) { return current; } if (!isStreaming) { return { displayText: text, animatedTailLength: 0, }; } if (current.displayText === text) { return current; } return { displayText: text, animatedTailLength: text.length > current.displayText.length && text.startsWith(current.displayText) ? Math.min(48, text.length - current.displayText.length) : 0, }; }); }, [isStreaming, text]); return ( {streamTextState.displayText} ); }; 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 isStreamingAssistant = !isUser && !isErrorMessage && isStreaming; const [isHovered, setIsHovered] = React.useState(false); const answerContentRef = React.useRef(null); const [selectedSpeechStart, setSelectedSpeechStart] = React.useState<{ offset: number; preview: string; } | null>(null); 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 speechText = useMemo( () => stripMarkdown(answerContent), [answerContent], ); const handleCaptureSpeechSelection = React.useCallback(() => { const selection = window.getSelection(); const container = answerContentRef.current; if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !container) { return; } const range = selection.getRangeAt(0); if (!container.contains(range.commonAncestorContainer)) { return; } const selectedText = selection.toString(); const startOffset = findSpeechSelectionStartOffset(speechText, selectedText); if (startOffset === null) { setSelectedSpeechStart(null); return; } const preview = selectedText.replace(/\s+/g, " ").trim().slice(0, 24); setSelectedSpeechStart({ offset: startOffset, preview: preview.length === 24 ? `${preview}...` : preview, }); }, [speechText]); React.useEffect(() => { setSelectedSpeechStart(null); }, [message.id, speechText]); const handleSpeakFromCurrentStart = () => { onSpeak(message.id, speechText, { startOffset: selectedSpeechStart?.offset ?? 0, }); }; const contentSegments: ContentSegment[] = useMemo( () => !isUser && !isErrorMessage ? parseContentWithToolCalls(answerContent).segments : [{ type: "text", content: answerContent }], [answerContent, isErrorMessage, isUser], ); const hasInlineChart = contentSegments.some( (segment) => segment.type === "tool_call" && (segment.toolCall.tool === "chart" || segment.toolCall.tool === "show_chart"), ); const visibleChartArtifacts = useMemo( () => hasInlineChart ? [] : (message.artifacts?.filter((artifact) => artifact.kind === "chart") ?? []), [hasInlineChart, message.artifacts], ); if (isUser) { return ( setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {message.content} ); } return ( setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > TJWater Agent {message.progress?.length ? ( ) : null} {message.permissions?.length ? ( ) : null} {message.questions?.length ? ( ) : null} {message.todos ? ( ) : null} 分析结果 {isStreamingAssistant ? : null} {contentSegments.map((segment, segIdx) => { if (segment.type === "text") { const text = segment.content.trim(); if (!text && contentSegments.length > 1) return null; return ( ); } 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; })} {visibleChartArtifacts.map((artifact) => ( ))} {isHovered && !isStreaming && ( { 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) } }} > { onCreateBranch(message.id); }} sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} > )} {!isErrorMessage && isTtsSupported ? ( {messageSpeechState === "idle" ? ( ) : null} {messageSpeechState === "loading" ? ( <> ) : null} {messageSpeechState === "playing" ? ( <> ) : null} {messageSpeechState === "paused" ? ( <> ) : null} ) : null} ); }, ); AgentTurn.displayName = "AgentTurn";