"use client" import type { UIMessage } from "ai" import { BookmarkPlus, Check, ChevronDown, ChevronUp, Copy, FileCode, FileText, Link, Pencil, RotateCcw, ThumbsDown, ThumbsUp, X, } from "lucide-react" 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 { Shimmer } from "@/components/ai-elements/shimmer" import { ChatLobby } from "@/components/chat/ChatLobby" import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog" import { ToolCallCard } from "@/components/chat/ToolCallCard" import type { DiagramOperation, ToolPartLike } from "@/components/chat/types" import type { ValidationState } from "@/components/chat/ValidationCard" import { ValidationCard } from "@/components/chat/ValidationCard" import Image from "@/components/image-with-basepath" import { ScrollArea } from "@/components/ui/scroll-area" import { useDictionary } from "@/hooks/use-dictionary" import { getApiEndpoint } from "@/lib/base-path" import { convertToLegalXml, extractCompleteMxCells, replaceNodes, } from "@/lib/utils" import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts" import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts" // 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 && (op.operation === "delete" || typeof op.new_xml === "string"), ) } import { useDiagram } from "@/contexts/diagram-context" // Helper to split text content into regular text and file/URL sections (PDF, text files, or URLs) interface TextSection { type: "text" | "file" | "url" content: string filename?: string charCount?: number fileType?: "pdf" | "text" | "url" } function splitTextIntoFileSections(text: string): TextSection[] { const sections: TextSection[] = [] // Match [PDF: filename], [File: filename], or [URL: url] patterns const filePattern = /\[(PDF|File|URL):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File|URL):|$)/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/url section const sectionType = match[1].toLowerCase() const fileType = sectionType === "pdf" ? "pdf" : sectionType === "url" ? "url" : "text" const filename = match[2].trim() const content = match[3].trim() sections.push({ type: sectionType === "url" ? "url" : "file", content: content, filename, charCount: content.length, fileType, }) lastIndex = match.index + match[0].length } // Add remaining text after last section const remainingText = text.slice(lastIndex).trim() if (remainingText) { sections.push({ type: "text", content: remainingText }) } // If no file/url 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") } // Matches the [PDF: ...], [File: ...] and [URL: ...] sections appended to the user's text export const APPENDED_FILE_SECTIONS_PATTERN = /\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/ // Get only the user's original text, excluding appended file content const getUserOriginalText = (message: UIMessage): string => { const fullText = getMessageTextContent(message) return fullText.replace(APPENDED_FILE_SECTIONS_PATTERN, "").trim() } interface SessionMetadata { id: string title: string updatedAt: number thumbnailDataUrl?: string } interface ChatMessageDisplayProps { messages: UIMessage[] // Shown on an error that a model setting can fix (bad key, unknown model) onOpenModelConfig?: () => void 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" isRestored?: boolean sessions?: SessionMetadata[] onSelectSession?: (id: string) => void onDeleteSession?: (id: string) => void loadedMessageIdsRef?: MutableRefObject> validationStates?: Record onImproveWithSuggestions?: (feedback: string) => void onSendTemplate?: ( template: import("@/lib/template-storage").Template, ) => void currentInput?: string } export function ChatMessageDisplay({ messages, onOpenModelConfig, setInput, setFiles, processedToolCallsRef, editDiagramOriginalXmlRef, sessionId, onRegenerate, onEditMessage, status = "idle", isRestored = false, sessions = [], onSelectSession, onDeleteSession, loadedMessageIdsRef, validationStates = {}, onImproveWithSuggestions, onSendTemplate, currentInput = "", }: ChatMessageDisplayProps) { const dict = useDictionary() // The thinking header in the page language const thinkingMessage = (isStreaming: boolean, duration?: number) => { if (isStreaming || duration === 0) { return {dict.reasoning.thinking} } if (duration === undefined) return

{dict.reasoning.thoughtBrief}

return (

{duration === 1 ? dict.reasoning.thoughtForOne : dict.reasoning.thoughtFor.replace( "{duration}", String(duration), )}

) } const { chartXML, loadDiagram: onDisplayChart } = useDiagram() const messagesEndRef = useRef(null) const scrollTopRef = 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()) // Reset refs when messages become empty (new chat or session switch) // This ensures cached examples work correctly after starting a new session useEffect(() => { if (messages.length === 0) { previousXML.current = "" lastProcessedXmlRef.current.clear() // Note: processedToolCalls is passed from parent, so we clear it too processedToolCalls.current.clear() // Scroll to top to show newest history items scrollTopRef.current?.scrollIntoView({ behavior: "instant" }) } }, [messages.length, processedToolCalls]) const [expandedTools, setExpandedTools] = useState>( {}, ) const [copiedToolCallId, setCopiedToolCallId] = useState( null, ) const [copyFailedToolCallId, setCopyFailedToolCallId] = useState< string | null >(null) 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 >({}) // Track "Save as Template" dialog const [saveAsTemplateMessageId, setSaveAsTemplateMessageId] = useState< string | null >(null) const setCopyState = ( messageId: string, isToolCall: boolean, isSuccess: boolean, ) => { if (isSuccess) { if (isToolCall) { setCopiedToolCallId(messageId) setTimeout(() => setCopiedToolCallId(null), 2000) } else { setCopiedMessageId(messageId) setTimeout(() => setCopiedMessageId(null), 2000) } } else { if (isToolCall) { setCopyFailedToolCallId(messageId) setTimeout(() => setCopyFailedToolCallId(null), 2000) } else { setCopyFailedMessageId(messageId) setTimeout(() => setCopyFailedMessageId(null), 2000) } } } const copyMessageToClipboard = async ( messageId: string, text: string, isToolCall = false, ) => { try { await navigator.clipboard.writeText(text) setCopyState(messageId, isToolCall, true) } 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") } setCopyState(messageId, isToolCall, true) } catch (fallbackErr) { console.error("Failed to copy message:", fallbackErr) toast.error(dict.chat.failedToCopyDetail) setCopyState(messageId, isToolCall, false) } 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 }) } } // Streaming preview of display_diagram: draw the complete cells written // so far. The tool handler validates and loads the final diagram. const handleDisplayChart = useCallback( (xml: string) => { const completeCells = extractCompleteMxCells(xml || "") if (!completeCells) return const convertedXml = convertToLegalXml(completeCells) if (convertedXml === previousXML.current) return // Skip this update while the cells written so far don't parse const testDoc = new DOMParser().parseFromString( `${convertedXml}`, "text/xml", ) if (testDoc.querySelector("parsererror")) return try { // An empty canvas gets a default mxfile to put the cells in const baseXML = chartXML || BLANK_MXFILE const replacedXML = replaceNodes(baseXML, convertedXml) previousXML.current = convertedXml onDisplayChart(replacedXML, true) } catch (error) { console.error("Error processing XML:", error) } }, [chartXML, onDisplayChart], ) // Track previous message count to detect bulk loads vs streaming const prevMessageCountRef = useRef(0) const scrollThrottleRef = useRef | null>(null) useEffect(() => { if (messagesEndRef.current && messages.length > 0) { const prevCount = prevMessageCountRef.current const currentCount = messages.length prevMessageCountRef.current = currentCount // Bulk load (session restore) - instant scroll, no animation if (prevCount === 0 || currentCount - prevCount > 1) { messagesEndRef.current.scrollIntoView({ behavior: "instant" }) return } // Throttle scroll during streaming to avoid layout thrashing // Leading + trailing: scroll immediately, then once more after cooldown if (!scrollThrottleRef.current) { messagesEndRef.current.scrollIntoView({ behavior: "smooth" }) scrollThrottleRef.current = setTimeout(() => { scrollThrottleRef.current = null messagesEndRef.current?.scrollIntoView({ behavior: "smooth", }) }, 150) } } }, [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]] : [] // The diagram without streamed previews. Undoing a failed edit's // preview below changes it before chartXML catches up, and an edit // streaming right after must start from the undone diagram. let baseXml = chartXML messagesToProcess.forEach((message) => { // Messages restored from a saved session were applied before it was // saved; the saved diagram is authoritative, so don't replay them const isRestoredMessage = loadedMessageIdsRef?.current.has(message.id) ?? false if (message.parts) { message.parts.forEach((part) => { if (part.type?.startsWith("tool-")) { const toolPart = part as ToolPartLike const { toolCallId, state, input } = toolPart // Auto-collapse on completion, but only if user hasn't manually toggled if (state === "output-available") { setExpandedTools((prev) => { // Only auto-collapse if not already set (user hasn't interacted) if (prev[toolCallId] === undefined) { return { ...prev, [toolCallId]: false } } return prev }) } if (isRestoredMessage) return if ( part.type !== "tool-display_diagram" && part.type !== "tool-edit_diagram" ) { return } // Failed or stopped: if the original XML is still // stored, the tool handler never ran (invalid JSON, // or the user pressed stop), so undo the streamed // preview here. Invalid JSON leaves no input, so // check this first. if (state === "output-error") { const originalXml = editDiagramOriginalXmlRef.current.get( toolCallId, ) if (originalXml) { editDiagramOriginalXmlRef.current.delete( toolCallId, ) onDisplayChart(originalXml, true) baseXml = originalXml } return } // Input complete, or the tool handler, a stop or an // error took the call already: the tool handler loads // the checked diagram (with the original XML). The // messages update at most every 150 ms (useChat // throttle in chat-panel), so they can still show the // call streaming after that. if ( state !== "input-streaming" || processedToolCalls.current.has(toolCallId) ) { processedToolCalls.current.add(toolCallId) lastProcessedXmlRef.current.delete(toolCallId) lastProcessedXmlRef.current.delete( `${toolCallId}-opCount`, ) return } if (part.type === "tool-display_diagram") { const xml = input?.xml as string | undefined // Skip if XML hasn't changed since last processing if ( !xml || lastProcessedXmlRef.current.get(toolCallId) === xml ) { return } // Keep the diagram from before the preview, to // undo it on a stop or an error if ( !editDiagramOriginalXmlRef.current.has( toolCallId, ) ) { editDiagramOriginalXmlRef.current.set( toolCallId, baseXml || BLANK_MXFILE, ) } handleDisplayChart(xml) lastProcessedXmlRef.current.set(toolCallId, xml) return } // Handle edit_diagram streaming - apply operations incrementally for preview // Uses shared editDiagramOriginalXmlRef to coordinate with tool handler if (part.type === "tool-edit_diagram") { if (!input?.operations) return 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 (!baseXml) { console.warn( "[edit_diagram streaming] No chart XML available", ) return } editDiagramOriginalXmlRef.current.set( toolCallId, baseXml, ) } const originalXml = editDiagramOriginalXmlRef.current.get( toolCallId, ) if (!originalXml) return // Skip if no change from last processed state const countKey = `${toolCallId}-opCount` const opCount = String(completeOps.length) if ( lastProcessedXmlRef.current.get(countKey) === opCount ) { return } try { const { result } = applyDiagramOperations( originalXml, completeOps, ) // Load the full document so other pages stay intact onDisplayChart(result, true) lastProcessedXmlRef.current.set( countKey, opCount, ) } catch (e) { console.warn( "[edit_diagram streaming] Operation failed:", e instanceof Error ? e.message : e, ) } } } }) } }) }, [messages, handleDisplayChart, chartXML]) return (
{messages.length === 0 && isRestored ? ( {})} onDeleteSession={onDeleteSession} setInput={setInput} setFiles={setFiles} onSendTemplate={onSendTemplate} currentInput={currentInput} dict={dict} /> ) : messages.length === 0 ? null : (
{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 // Skip animation for loaded messages (from session restore) const isRestoredMessage = loadedMessageIdsRef?.current.has(message.id) ?? false return (
{message.role === "user" && userMessageText && !isEditing && (
{/* Edit button - only on last user message */} {onEditMessage && isLastUserMessage && ( )} {/* Save as Template button - only for user messages */}
)} {/* Save as Template Dialog */} {saveAsTemplateMessageId === message.id && ( { if (!open) setSaveAsTemplateMessageId(null) }} onSuccess={() => { setSaveAsTemplateMessageId(null) }} initialPrompt={getUserOriginalText( message, )} /> )}
{/* 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" ? (