"use client"; import Image from "next/image"; import React, { useMemo } from "react"; import { motion } from "framer-motion"; import { Avatar, Box, CircularProgress, Button, Grow, IconButton, Paper, Popper, 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"; const floatingActionSurfaceSx = { 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)}`, overflow: "hidden", } as const; const floatingActionTransitionTimeout = { enter: 150, exit: 120 } as const; const floatingIconButtonSx = { width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1), }, } as const; const floatingSpeechButtonSx = { minHeight: 34, px: 1.25, color: "text.primary", fontSize: 13, fontWeight: 700, letterSpacing: 0, whiteSpace: "nowrap", borderRadius: "12px", "&:hover": { bgcolor: alpha("#00acc1", 0.1), color: "#00acc1", }, } as const; type SpeechSelection = { startOffset: number; anchorRect: DOMRect; }; 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 [speechSelection, setSpeechSelection] = React.useState(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 captureSpeechSelection = React.useCallback(() => { if (!isTtsSupported || isStreamingAssistant) { setSpeechSelection(null); return; } const selection = window.getSelection(); const container = answerContentRef.current; if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !container) { setSpeechSelection(null); return; } const range = selection.getRangeAt(0); if (!container.contains(range.commonAncestorContainer)) { setSpeechSelection(null); return; } const selectedText = selection.toString(); const startOffset = findSpeechSelectionStartOffset(speechText, selectedText); if (startOffset === null) { setSpeechSelection(null); return; } const anchorRect = range.getBoundingClientRect(); if (anchorRect.width === 0 && anchorRect.height === 0) { setSpeechSelection(null); return; } setSpeechSelection({ startOffset, anchorRect }); }, [isStreamingAssistant, isTtsSupported, speechText]); const handleCaptureSpeechSelection = React.useCallback(() => { window.requestAnimationFrame(captureSpeechSelection); }, [captureSpeechSelection]); React.useEffect(() => { setSpeechSelection(null); }, [message.id, speechText]); React.useEffect(() => { if (!speechSelection) return; const closeSpeechSelection = () => setSpeechSelection(null); const handleSelectionChange = () => { if (window.getSelection()?.isCollapsed) { closeSpeechSelection(); } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { closeSpeechSelection(); } }; document.addEventListener("selectionchange", handleSelectionChange); document.addEventListener("keydown", handleKeyDown); window.addEventListener("resize", closeSpeechSelection); window.addEventListener("scroll", closeSpeechSelection, true); return () => { document.removeEventListener("selectionchange", handleSelectionChange); document.removeEventListener("keydown", handleKeyDown); window.removeEventListener("resize", closeSpeechSelection); window.removeEventListener("scroll", closeSpeechSelection, true); }; }, [speechSelection]); const handleSpeakMessage = () => { onSpeak(message.id, speechText); }; const handleSpeakFromSelection = () => { if (!speechSelection) return; onSpeak(message.id, speechText, { startOffset: speechSelection.startOffset, }); window.getSelection()?.removeAllRanges(); setSpeechSelection(null); }; const speechSelectionAnchor = React.useMemo( () => speechSelection ? { getBoundingClientRect: () => speechSelection.anchorRect, } : null, [speechSelection], ); const isSpeechSelectionOpen = Boolean(speechSelection); 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; })} {({ TransitionProps, placement }) => ( )} {visibleChartArtifacts.map((artifact) => ( ))} { navigator.clipboard.writeText( normalizeClipboardText(message.content), ); }} sx={floatingIconButtonSx} > onCreateBranch(message.id)} sx={floatingIconButtonSx} > {!isErrorMessage && isTtsSupported ? ( {messageSpeechState === "idle" ? ( ) : null} {messageSpeechState === "loading" ? ( <> ) : null} {messageSpeechState === "playing" ? ( <> ) : null} {messageSpeechState === "paused" ? ( <> ) : null} ) : null} ); }, ); AgentTurn.displayName = "AgentTurn";