"use client" import type { UIMessage } from "ai" import { Check, ChevronDown, ChevronUp, Copy, Cpu, FileCode, FileText, Pencil, RotateCcw, ThumbsDown, ThumbsUp, X, } from "lucide-react" import Image from "next/image" import type { MutableRefObject } from "react" import { useCallback, useEffect, useRef, useState } from "react" import ReactMarkdown from "react-markdown" import { toast } from "sonner" import { Reasoning, ReasoningContent, ReasoningTrigger, } from "@/components/ai-elements/reasoning" import { ScrollArea } from "@/components/ui/scroll-area" import { convertToLegalXml, isMxCellXmlComplete, replaceNodes, validateAndFixXml, } from "@/lib/utils" import ExamplePanel from "./chat-example-panel" import { CodeBlock } from "./code-block" interface DiagramOperation { type: "update" | "add" | "delete" cell_id: string new_xml?: string } // Tool part interface for type safety interface ToolPartLike { type: string toolCallId: string state?: string input?: { xml?: string operations?: DiagramOperation[] } & Record output?: string } function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) { return (
{operations.map((op, index) => (
{op.type} cell_id: {op.cell_id}
{op.new_xml && (
                                {op.new_xml}
                            
)}
))}
) } import { useDiagram } from "@/contexts/diagram-context" // Helper to split text content into regular text and file sections (PDF or text files) interface TextSection { type: "text" | "file" content: string filename?: string charCount?: number fileType?: "pdf" | "text" } function splitTextIntoFileSections(text: string): TextSection[] { const sections: TextSection[] = [] // Match [PDF: filename] or [File: filename] patterns const filePattern = /\[(PDF|File):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File):|$)/g let lastIndex = 0 let match while ((match = filePattern.exec(text)) !== null) { // Add text before this file section const beforeText = text.slice(lastIndex, match.index).trim() if (beforeText) { sections.push({ type: "text", content: beforeText }) } // Add file section const fileType = match[1].toLowerCase() === "pdf" ? "pdf" : "text" const filename = match[2].trim() const fileContent = match[3].trim() sections.push({ type: "file", content: fileContent, filename, charCount: fileContent.length, fileType, }) lastIndex = match.index + match[0].length } // Add remaining text after last file section const remainingText = text.slice(lastIndex).trim() if (remainingText) { sections.push({ type: "text", content: remainingText }) } // If no file sections found, return original text if (sections.length === 0) { sections.push({ type: "text", content: text }) } return sections } const getMessageTextContent = (message: UIMessage): string => { if (!message.parts) return "" return message.parts .filter((part) => part.type === "text") .map((part) => (part as { text: string }).text) .join("\n") } // Get only the user's original text, excluding appended file content const getUserOriginalText = (message: UIMessage): string => { const fullText = getMessageTextContent(message) // Strip out [PDF: ...] and [File: ...] sections that were appended const filePattern = /\n\n\[(PDF|File):\s*[^\]]+\]\n[\s\S]*$/ return fullText.replace(filePattern, "").trim() } interface ChatMessageDisplayProps { messages: UIMessage[] setInput: (input: string) => void setFiles: (files: File[]) => void processedToolCallsRef: MutableRefObject> sessionId?: string onRegenerate?: (messageIndex: number) => void onEditMessage?: (messageIndex: number, newText: string) => void status?: "streaming" | "submitted" | "idle" | "error" | "ready" } export function ChatMessageDisplay({ messages, setInput, setFiles, processedToolCallsRef, sessionId, onRegenerate, onEditMessage, status = "idle", }: ChatMessageDisplayProps) { const { chartXML, loadDiagram: onDisplayChart } = useDiagram() const messagesEndRef = useRef(null) const previousXML = useRef("") const processedToolCalls = processedToolCallsRef // Track the last processed XML per toolCallId to skip redundant processing during streaming const lastProcessedXmlRef = useRef>(new Map()) // Debounce streaming diagram updates - store pending XML and timeout const pendingXmlRef = useRef(null) const debounceTimeoutRef = useRef | null>( null, ) const STREAMING_DEBOUNCE_MS = 150 // Only update diagram every 150ms during streaming const [expandedTools, setExpandedTools] = useState>( {}, ) const [copiedMessageId, setCopiedMessageId] = useState(null) const [copyFailedMessageId, setCopyFailedMessageId] = useState< string | null >(null) const [feedback, setFeedback] = useState>({}) const [editingMessageId, setEditingMessageId] = useState( null, ) const editTextareaRef = useRef(null) const [editText, setEditText] = useState("") // Track which PDF sections are expanded (key: messageId-sectionIndex) const [expandedPdfSections, setExpandedPdfSections] = useState< Record >({}) const copyMessageToClipboard = async (messageId: string, text: string) => { try { await navigator.clipboard.writeText(text) setCopiedMessageId(messageId) setTimeout(() => setCopiedMessageId(null), 2000) } catch (err) { // Fallback for non-secure contexts (HTTP) or permission denied const textarea = document.createElement("textarea") textarea.value = text textarea.style.position = "fixed" textarea.style.left = "-9999px" textarea.style.opacity = "0" document.body.appendChild(textarea) try { textarea.select() const success = document.execCommand("copy") if (!success) { throw new Error("Copy command failed") } setCopiedMessageId(messageId) setTimeout(() => setCopiedMessageId(null), 2000) } catch (fallbackErr) { console.error("Failed to copy message:", fallbackErr) toast.error( "Failed to copy message. Please copy manually or check clipboard permissions.", ) setCopyFailedMessageId(messageId) setTimeout(() => setCopyFailedMessageId(null), 2000) } finally { document.body.removeChild(textarea) } } } const submitFeedback = async (messageId: string, value: "good" | "bad") => { // Toggle off if already selected if (feedback[messageId] === value) { setFeedback((prev) => { const next = { ...prev } delete next[messageId] return next }) return } setFeedback((prev) => ({ ...prev, [messageId]: value })) try { await fetch("/api/log-feedback", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messageId, feedback: value, sessionId, }), }) } catch (error) { console.error("Failed to log feedback:", error) toast.error("Failed to record your feedback. Please try again.") // Revert optimistic UI update setFeedback((prev) => { const next = { ...prev } delete next[messageId] return next }) } } const handleDisplayChart = useCallback( (xml: string, showToast = false) => { console.time("perf:handleDisplayChart") const currentXml = xml || "" const convertedXml = convertToLegalXml(currentXml) if (convertedXml !== previousXML.current) { // Parse and validate XML BEFORE calling replaceNodes console.time("perf:DOMParser") const parser = new DOMParser() const testDoc = parser.parseFromString(convertedXml, "text/xml") console.timeEnd("perf:DOMParser") const parseError = testDoc.querySelector("parsererror") if (parseError) { // Use console.warn instead of console.error to avoid triggering // Next.js dev mode error overlay for expected streaming states // (partial XML during streaming is normal and will be fixed by subsequent updates) if (showToast) { // Only log as error and show toast if this is the final XML console.error( "[ChatMessageDisplay] Malformed XML detected in final output", ) toast.error( "AI generated invalid diagram XML. Please try regenerating.", ) } console.timeEnd("perf:handleDisplayChart") return // Skip this update } try { // If chartXML is empty, create a default mxfile structure to use with replaceNodes // This ensures the XML is properly wrapped in mxfile/diagram/mxGraphModel format const baseXML = chartXML || `` console.time("perf:replaceNodes") const replacedXML = replaceNodes(baseXML, convertedXml) console.timeEnd("perf:replaceNodes") // Validate and auto-fix the XML console.time("perf:validateAndFixXml") const validation = validateAndFixXml(replacedXML) console.timeEnd("perf:validateAndFixXml") if (validation.valid) { previousXML.current = convertedXml // Use fixed XML if available, otherwise use original const xmlToLoad = validation.fixed || replacedXML if (validation.fixes.length > 0) { console.log( "[ChatMessageDisplay] Auto-fixed XML issues:", validation.fixes, ) } // Skip validation in loadDiagram since we already validated above onDisplayChart(xmlToLoad, true) } else { console.error( "[ChatMessageDisplay] XML validation failed:", validation.error, ) // Only show toast if this is the final XML (not during streaming) if (showToast) { toast.error( "Diagram validation failed. Please try regenerating.", ) } } } catch (error) { console.error( "[ChatMessageDisplay] Error processing XML:", error, ) // Only show toast if this is the final XML (not during streaming) if (showToast) { toast.error( "Failed to process diagram. Please try regenerating.", ) } } console.timeEnd("perf:handleDisplayChart") } else { console.timeEnd("perf:handleDisplayChart") } }, [chartXML, onDisplayChart], ) useEffect(() => { if (messagesEndRef.current) { messagesEndRef.current.scrollIntoView({ behavior: "smooth" }) } }, [messages]) useEffect(() => { if (editingMessageId && editTextareaRef.current) { editTextareaRef.current.focus() } }, [editingMessageId]) useEffect(() => { console.time("perf:message-display-useEffect") let processedCount = 0 let skippedCount = 0 let debouncedCount = 0 // Only process the last message for streaming performance // Previous messages are already processed and won't change const messagesToProcess = messages.length > 0 ? [messages[messages.length - 1]] : [] messagesToProcess.forEach((message) => { if (message.parts) { message.parts.forEach((part) => { if (part.type?.startsWith("tool-")) { const toolPart = part as ToolPartLike const { toolCallId, state, input } = toolPart if (state === "output-available") { setExpandedTools((prev) => ({ ...prev, [toolCallId]: false, })) } if ( part.type === "tool-display_diagram" && input?.xml ) { const xml = input.xml as string // Skip if XML hasn't changed since last processing const lastXml = lastProcessedXmlRef.current.get(toolCallId) if (lastXml === xml) { skippedCount++ return // Skip redundant processing } if ( state === "input-streaming" || state === "input-available" ) { // Debounce streaming updates - queue the XML and process after delay pendingXmlRef.current = xml if (!debounceTimeoutRef.current) { // No pending timeout - set one up debounceTimeoutRef.current = setTimeout( () => { const pendingXml = pendingXmlRef.current debounceTimeoutRef.current = null pendingXmlRef.current = null if (pendingXml) { console.log( "perf:debounced-handleDisplayChart executing", ) handleDisplayChart( pendingXml, false, ) lastProcessedXmlRef.current.set( toolCallId, pendingXml, ) } }, STREAMING_DEBOUNCE_MS, ) } debouncedCount++ } else if ( state === "output-available" && !processedToolCalls.current.has(toolCallId) ) { // Final output - process immediately (clear any pending debounce) if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current) debounceTimeoutRef.current = null pendingXmlRef.current = null } // Show toast only if final XML is malformed handleDisplayChart(xml, true) processedToolCalls.current.add(toolCallId) // Clean up the ref entry - tool is complete, no longer needed lastProcessedXmlRef.current.delete(toolCallId) processedCount++ } } } }) } }) console.log( `perf:message-display-useEffect processed=${processedCount} skipped=${skippedCount} debounced=${debouncedCount}`, ) console.timeEnd("perf:message-display-useEffect") // Cleanup: clear any pending debounce timeout on unmount return () => { if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current) debounceTimeoutRef.current = null } } }, [messages, handleDisplayChart]) const renderToolPart = (part: ToolPartLike) => { const callId = part.toolCallId const { state, input, output } = part const isExpanded = expandedTools[callId] ?? true const toolName = part.type?.replace("tool-", "") const toggleExpanded = () => { setExpandedTools((prev) => ({ ...prev, [callId]: !isExpanded, })) } const getToolDisplayName = (name: string) => { switch (name) { case "display_diagram": return "Generate Diagram" case "edit_diagram": return "Edit Diagram" default: return name } } return (
{getToolDisplayName(toolName)}
{state === "input-streaming" && (
)} {state === "output-available" && ( Complete )} {state === "output-error" && (() => { // Check if this is a truncation (incomplete XML) vs real error const isTruncated = (toolName === "display_diagram" || toolName === "append_diagram") && !isMxCellXmlComplete(input?.xml) return isTruncated ? ( Truncated ) : ( Error ) })()} {input && Object.keys(input).length > 0 && ( )}
{input && isExpanded && (
{typeof input === "object" && input.xml ? ( ) : typeof input === "object" && input.operations && Array.isArray(input.operations) ? ( ) : typeof input === "object" && Object.keys(input).length > 0 ? ( ) : null}
)} {output && state === "output-error" && (() => { const isTruncated = (toolName === "display_diagram" || toolName === "append_diagram") && !isMxCellXmlComplete(input?.xml) return (
{isTruncated ? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength." : output}
) })()}
) } return ( {messages.length === 0 ? ( ) : (
{messages.map((message, messageIndex) => { const userMessageText = message.role === "user" ? getMessageTextContent(message) : "" const isLastAssistantMessage = message.role === "assistant" && (messageIndex === messages.length - 1 || messages .slice(messageIndex + 1) .every((m) => m.role !== "assistant")) const isLastUserMessage = message.role === "user" && (messageIndex === messages.length - 1 || messages .slice(messageIndex + 1) .every((m) => m.role !== "user")) const isEditing = editingMessageId === message.id return (
{message.role === "user" && userMessageText && !isEditing && (
{/* Edit button - only on last user message */} {onEditMessage && isLastUserMessage && ( )}
)}
{/* Reasoning blocks - displayed first for assistant messages */} {message.role === "assistant" && message.parts?.map( (part, partIndex) => { if (part.type === "reasoning") { const reasoningPart = part as { type: "reasoning" text: string } const isLastPart = partIndex === (message.parts ?.length ?? 0) - 1 const isLastMessage = message.id === messages[ messages.length - 1 ]?.id const isStreamingReasoning = status === "streaming" && isLastPart && isLastMessage return ( { reasoningPart.text } ) } return null }, )} {/* Edit mode for user messages */} {isEditing && message.role === "user" ? (