"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 { useDictionary } from "@/hooks/use-dictionary" import { getApiEndpoint } from "@/lib/base-path" import { applyDiagramOperations, convertToLegalXml, extractCompleteMxCells, isMxCellXmlComplete, replaceNodes, validateAndFixXml, } from "@/lib/utils" import ExamplePanel from "./chat-example-panel" import { CodeBlock } from "./code-block" interface DiagramOperation { operation: "update" | "add" | "delete" cell_id: string new_xml?: string } // Helper to extract complete operations from streaming input function getCompleteOperations( operations: DiagramOperation[] | undefined, ): DiagramOperation[] { if (!operations || !Array.isArray(operations)) return [] return operations.filter( (op) => op && typeof op.operation === "string" && ["update", "add", "delete"].includes(op.operation) && typeof op.cell_id === "string" && op.cell_id.length > 0 && // delete doesn't need new_xml, update/add do (op.operation === "delete" || typeof op.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.operation} 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> editDiagramOriginalXmlRef: 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, editDiagramOriginalXmlRef, sessionId, onRegenerate, onEditMessage, status = "idle", }: ChatMessageDisplayProps) { const dict = useDictionary() 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 // Refs for edit_diagram streaming const pendingEditRef = useRef<{ operations: DiagramOperation[] toolCallId: string } | null>(null) const editDebounceTimeoutRef = useRef | null>( null, ) 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(dict.chat.failedToCopyDetail) 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(getApiEndpoint("/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(dict.errors.failedToRecordFeedback) // Revert optimistic UI update setFeedback((prev) => { const next = { ...prev } delete next[messageId] return next }) } } const handleDisplayChart = useCallback( (xml: string, showToast = false) => { let currentXml = xml || "" const startTime = performance.now() // During streaming (showToast=false), extract only complete mxCell elements // This allows progressive rendering even with partial/incomplete trailing XML if (!showToast) { const completeCells = extractCompleteMxCells(currentXml) if (!completeCells) { return } currentXml = completeCells } const convertedXml = convertToLegalXml(currentXml) if (convertedXml !== previousXML.current) { // Parse and validate XML BEFORE calling replaceNodes const parser = new DOMParser() // Wrap in root element for parsing multiple mxCell elements const testDoc = parser.parseFromString( `${convertedXml}`, "text/xml", ) 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(dict.errors.malformedXml) } 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 || `` const replacedXML = replaceNodes(baseXML, convertedXml) const xmlProcessTime = performance.now() - startTime // During streaming (showToast=false), skip heavy validation for lower latency // The quick DOM parse check above catches malformed XML // Full validation runs on final output (showToast=true) if (!showToast) { previousXML.current = convertedXml const loadStartTime = performance.now() onDisplayChart(replacedXML, true) console.log( `[Streaming] XML processing: ${xmlProcessTime.toFixed(1)}ms, drawio load: ${(performance.now() - loadStartTime).toFixed(1)}ms`, ) return } // Final output: run full validation and auto-fix const validation = validateAndFixXml(replacedXML) 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 const loadStartTime = performance.now() onDisplayChart(xmlToLoad, true) console.log( `[Final] XML processing: ${xmlProcessTime.toFixed(1)}ms, validation+load: ${(performance.now() - loadStartTime).toFixed(1)}ms`, ) } else { console.error( "[ChatMessageDisplay] XML validation failed:", validation.error, ) toast.error(dict.errors.validationFailed) } } 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(dict.errors.failedToProcess) } } } }, [chartXML, onDisplayChart], ) useEffect(() => { if (messagesEndRef.current) { messagesEndRef.current.scrollIntoView({ behavior: "smooth" }) } }, [messages]) useEffect(() => { if (editingMessageId && editTextareaRef.current) { editTextareaRef.current.focus() } }, [editingMessageId]) useEffect(() => { // 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) { 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) { handleDisplayChart( pendingXml, false, ) lastProcessedXmlRef.current.set( toolCallId, pendingXml, ) } }, STREAMING_DEBOUNCE_MS, ) } } 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) } } // Handle edit_diagram streaming - apply operations incrementally for preview // Uses shared editDiagramOriginalXmlRef to coordinate with tool handler if ( part.type === "tool-edit_diagram" && input?.operations ) { const completeOps = getCompleteOperations( input.operations as DiagramOperation[], ) if (completeOps.length === 0) return // Capture original XML when streaming starts (store in shared ref) if ( !editDiagramOriginalXmlRef.current.has( toolCallId, ) ) { if (!chartXML) { console.warn( "[edit_diagram streaming] No chart XML available", ) return } editDiagramOriginalXmlRef.current.set( toolCallId, chartXML, ) } const originalXml = editDiagramOriginalXmlRef.current.get( toolCallId, ) if (!originalXml) return // Skip if no change from last processed state const lastCount = lastProcessedXmlRef.current.get( toolCallId + "-opCount", ) if (lastCount === String(completeOps.length)) return if ( state === "input-streaming" || state === "input-available" ) { // Queue the operations for debounced processing pendingEditRef.current = { operations: completeOps, toolCallId, } if (!editDebounceTimeoutRef.current) { editDebounceTimeoutRef.current = setTimeout( () => { const pending = pendingEditRef.current editDebounceTimeoutRef.current = null pendingEditRef.current = null if (pending) { const origXml = editDiagramOriginalXmlRef.current.get( pending.toolCallId, ) if (!origXml) return try { const { result: editedXml, } = applyDiagramOperations( origXml, pending.operations, ) handleDisplayChart( editedXml, false, ) lastProcessedXmlRef.current.set( pending.toolCallId + "-opCount", String( pending.operations .length, ), ) } catch (e) { console.warn( `[edit_diagram streaming] Operation failed:`, e instanceof Error ? e.message : e, ) } } }, STREAMING_DEBOUNCE_MS, ) } } else if ( state === "output-available" && !processedToolCalls.current.has(toolCallId) ) { // Final state - cleanup streaming refs (tool handler does final application) if (editDebounceTimeoutRef.current) { clearTimeout(editDebounceTimeoutRef.current) editDebounceTimeoutRef.current = null } lastProcessedXmlRef.current.delete( toolCallId + "-opCount", ) processedToolCalls.current.add(toolCallId) // Note: Don't delete editDiagramOriginalXmlRef here - tool handler needs it } } } }) } }) // NOTE: Don't cleanup debounce timeouts here! // The cleanup runs on every re-render (when messages changes), // which would cancel the timeout before it fires. // Let the timeouts complete naturally - they're harmless if component unmounts. }, [messages, handleDisplayChart, chartXML]) 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" case "get_shape_library": return "Get Shape Library" 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}
) })()} {/* Show get_shape_library output on success */} {output && toolName === "get_shape_library" && state === "output-available" && isExpanded && (
Library loaded ( {typeof output === "string" ? output.length : 0}{" "} chars)
                                {typeof output === "string"
                                    ? output.substring(0, 800) +
                                      (output.length > 800 ? "\n..." : "")
                                    : String(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" ? (