"use client" import type { UIMessage } from "ai" import { Check, ChevronDown, ChevronUp, Copy, Cpu, FileCode, FileText, Minus, Pencil, Plus, 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 { Reasoning, ReasoningContent, ReasoningTrigger, } from "@/components/ai-elements/reasoning" import { ScrollArea } from "@/components/ui/scroll-area" import { convertToLegalXml, replaceNodes, validateMxCellStructure, } from "@/lib/utils" import ExamplePanel from "./chat-example-panel" import { CodeBlock } from "./code-block" interface EditPair { search: string replace: string } // Tool part interface for type safety interface ToolPartLike { type: string toolCallId: string state?: string input?: { xml?: string; edits?: EditPair[] } & Record output?: string } function EditDiffDisplay({ edits }: { edits: EditPair[] }) { return (
{edits.map((edit, index) => (
Change {index + 1}
{/* Search (old) */}
Remove
                                {edit.search}
                            
{/* Replace (new) */}
Add
                                {edit.replace}
                            
))}
) } 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 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) 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.warn("Failed to log feedback:", error) } } const handleDisplayChart = useCallback( (xml: string) => { const currentXml = xml || "" const convertedXml = convertToLegalXml(currentXml) if (convertedXml !== previousXML.current) { // 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 validationError = validateMxCellStructure(replacedXML) if (!validationError) { previousXML.current = convertedXml // Skip validation in loadDiagram since we already validated above onDisplayChart(replacedXML, true) } else { console.log( "[ChatMessageDisplay] XML validation failed:", validationError, ) } } }, [chartXML, onDisplayChart], ) useEffect(() => { if (messagesEndRef.current) { messagesEndRef.current.scrollIntoView({ behavior: "smooth" }) } }, [messages]) useEffect(() => { if (editingMessageId && editTextareaRef.current) { editTextareaRef.current.focus() } }, [editingMessageId]) useEffect(() => { messages.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 if ( state === "input-streaming" || state === "input-available" ) { handleDisplayChart(xml) } else if ( state === "output-available" && !processedToolCalls.current.has(toolCallId) ) { handleDisplayChart(xml) processedToolCalls.current.add(toolCallId) } } } }) } }) }, [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" && ( Error )} {input && Object.keys(input).length > 0 && ( )}
{input && isExpanded && (
{typeof input === "object" && input.xml ? ( ) : typeof input === "object" && input.edits && Array.isArray(input.edits) ? ( ) : typeof input === "object" && Object.keys(input).length > 0 ? ( ) : null}
)} {output && state === "output-error" && (
{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" ? (