diff --git a/components/chat-message-display.tsx b/components/chat-message-display.tsx index 4bed4f8..27bfbf9 100644 --- a/components/chat-message-display.tsx +++ b/components/chat-message-display.tsx @@ -7,16 +7,12 @@ import { ChevronDown, ChevronUp, Copy, - Cpu, FileCode, FileText, - MessageSquare, Pencil, RotateCcw, - Search, ThumbsDown, ThumbsUp, - Trash2, X, } from "lucide-react" import Image from "next/image" @@ -29,16 +25,9 @@ import { ReasoningContent, ReasoningTrigger, } from "@/components/ai-elements/reasoning" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog" +import { ChatLobby } from "@/components/chat/ChatLobby" +import { ToolCallCard } from "@/components/chat/ToolCallCard" +import type { DiagramOperation, ToolPartLike } from "@/components/chat/types" import { ScrollArea } from "@/components/ui/scroll-area" import { useDictionary } from "@/hooks/use-dictionary" import { getApiEndpoint } from "@/lib/base-path" @@ -46,18 +35,10 @@ 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( @@ -71,60 +52,10 @@ function getCompleteOperations( ["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) @@ -294,13 +225,6 @@ export function ChatMessageDisplay({ const [expandedPdfSections, setExpandedPdfSections] = useState< Record >({}) - // Track whether examples section is expanded (collapsed by default when there's history) - const [examplesExpanded, setExamplesExpanded] = useState(false) - // Delete confirmation dialog state - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) - const [sessionToDelete, setSessionToDelete] = useState(null) - // Search filter for history - const [searchQuery, setSearchQuery] = useState("") const setCopyState = ( messageId: string, @@ -700,383 +624,18 @@ export function ChatMessageDisplay({ // 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 - // Default to collapsed if tool is complete, expanded if still streaming - const isExpanded = expandedTools[callId] ?? state !== "output-available" - const toolName = part.type?.replace("tool-", "") - const isCopied = copiedToolCallId === callId - - 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 - } - } - - const handleCopy = () => { - let textToCopy = "" - - if (input && typeof input === "object") { - if (input.xml) { - textToCopy = input.xml - } else if ( - input.operations && - Array.isArray(input.operations) - ) { - textToCopy = JSON.stringify(input.operations, null, 2) - } else if (Object.keys(input).length > 0) { - textToCopy = JSON.stringify(input, null, 2) - } - } - - if ( - output && - toolName === "get_shape_library" && - typeof output === "string" - ) { - textToCopy = output - } - - if (textToCopy) { - copyMessageToClipboard(callId, textToCopy, true) - } - } - - return ( -
-
-
-
- -
- - {getToolDisplayName(toolName)} - -
-
- {state === "input-streaming" && ( -
- )} - {state === "output-available" && ( - <> - - {dict.tools.complete} - - {isExpanded && ( - - )} - - )} - {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)}
-                            
-
- )} -
- ) - } - - // Helper to format session date - const formatSessionDate = (timestamp: number): string => { - const date = new Date(timestamp) - const now = new Date() - const diffMs = now.getTime() - date.getTime() - const diffMins = Math.floor(diffMs / (1000 * 60)) - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) - - if (diffMins < 1) return dict.sessionHistory?.justNow || "Just now" - if (diffMins < 60) return `${diffMins}m ago` - if (diffHours < 24) return `${diffHours}h ago` - - return date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - }) - } - - const hasHistory = sessions.length > 0 - return (
{messages.length === 0 && isRestored ? ( - hasHistory ? ( - // Show history + collapsible examples when there are sessions -
- {/* Recent Chats Section */} -
-

- {dict.sessionHistory?.recentChats || - "Recent Chats"} -

- {/* Search Bar */} -
- - - setSearchQuery(e.target.value) - } - className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all" - /> - {searchQuery && ( - - )} -
-
- {sessions - .filter((session) => - session.title - .toLowerCase() - .includes( - searchQuery.toLowerCase(), - ), - ) - .map((session) => ( - // biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error -
- onSelectSession?.(session.id) - } - onKeyDown={(e) => { - if ( - e.key === "Enter" || - e.key === " " - ) { - e.preventDefault() - onSelectSession?.( - session.id, - ) - } - }} - > - {session.thumbnailDataUrl ? ( -
- -
- ) : ( -
- -
- )} -
-
- {session.title} -
-
- {formatSessionDate( - session.updatedAt, - )} -
-
- {onDeleteSession && ( - - )} -
- ))} - {sessions.filter((s) => - s.title - .toLowerCase() - .includes(searchQuery.toLowerCase()), - ).length === 0 && - searchQuery && ( -

- {dict.sessionHistory?.noResults || - "No chats found"} -

- )} -
-
- - {/* Collapsible Examples Section */} -
- - {examplesExpanded && ( -
- -
- )} -
-
- ) : ( - // Show full examples when no history - - ) + {})} + onDeleteSession={onDeleteSession} + setInput={setInput} + setFiles={setFiles} + dict={dict} + /> ) : messages.length === 0 ? null : (
{messages.map((message, messageIndex) => { @@ -1353,9 +912,30 @@ export function ChatMessageDisplay({ return groups.map( (group, groupIndex) => { if (group.type === "tool") { - return renderToolPart( - group - .parts[0] as ToolPartLike, + return ( + ) } @@ -1718,42 +1298,6 @@ export function ChatMessageDisplay({
)}
- - {/* Delete Confirmation Dialog */} - - - - - {dict.sessionHistory?.deleteTitle || - "Delete this chat?"} - - - {dict.sessionHistory?.deleteDescription || - "This will permanently delete this chat session and its diagram. This action cannot be undone."} - - - - - {dict.common.cancel} - - { - if (sessionToDelete && onDeleteSession) { - onDeleteSession(sessionToDelete) - } - setDeleteDialogOpen(false) - setSessionToDelete(null) - }} - className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400" - > - {dict.common.delete} - - - - ) } diff --git a/components/chat/ChatLobby.tsx b/components/chat/ChatLobby.tsx new file mode 100644 index 0000000..484dad0 --- /dev/null +++ b/components/chat/ChatLobby.tsx @@ -0,0 +1,274 @@ +"use client" + +import { + ChevronDown, + ChevronUp, + MessageSquare, + Search, + Trash2, + X, +} from "lucide-react" +import Image from "next/image" +import { useState } from "react" +import ExamplePanel from "@/components/chat-example-panel" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" + +interface SessionMetadata { + id: string + title: string + updatedAt: number + thumbnailDataUrl?: string +} + +interface ChatLobbyProps { + sessions: SessionMetadata[] + onSelectSession: (id: string) => void + onDeleteSession?: (id: string) => void + setInput: (input: string) => void + setFiles: (files: File[]) => void + dict: { + sessionHistory?: { + recentChats?: string + searchPlaceholder?: string + noResults?: string + justNow?: string + deleteTitle?: string + deleteDescription?: string + } + examples?: { + quickExamples?: string + } + common: { + delete: string + cancel: string + } + } +} + +// Helper to format session date +function formatSessionDate( + timestamp: number, + dict?: { justNow?: string }, +): string { + const date = new Date(timestamp) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / (1000 * 60)) + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) + + if (diffMins < 1) return dict?.justNow || "Just now" + if (diffMins < 60) return `${diffMins}m ago` + if (diffHours < 24) return `${diffHours}h ago` + + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }) +} + +export function ChatLobby({ + sessions, + onSelectSession, + onDeleteSession, + setInput, + setFiles, + dict, +}: ChatLobbyProps) { + // Track whether examples section is expanded (collapsed by default when there's history) + const [examplesExpanded, setExamplesExpanded] = useState(false) + // Delete confirmation dialog state + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [sessionToDelete, setSessionToDelete] = useState(null) + // Search filter for history + const [searchQuery, setSearchQuery] = useState("") + + const hasHistory = sessions.length > 0 + + if (!hasHistory) { + // Show full examples when no history + return + } + + // Show history + collapsible examples when there are sessions + return ( +
+ {/* Recent Chats Section */} +
+

+ {dict.sessionHistory?.recentChats || "Recent Chats"} +

+ {/* Search Bar */} +
+ + setSearchQuery(e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all" + /> + {searchQuery && ( + + )} +
+
+ {sessions + .filter((session) => + session.title + .toLowerCase() + .includes(searchQuery.toLowerCase()), + ) + .map((session) => ( + // biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error +
onSelectSession(session.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + onSelectSession(session.id) + } + }} + > + {session.thumbnailDataUrl ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ {session.title} +
+
+ {formatSessionDate( + session.updatedAt, + dict.sessionHistory, + )} +
+
+ {onDeleteSession && ( + + )} +
+ ))} + {sessions.filter((s) => + s.title + .toLowerCase() + .includes(searchQuery.toLowerCase()), + ).length === 0 && + searchQuery && ( +

+ {dict.sessionHistory?.noResults || + "No chats found"} +

+ )} +
+
+ + {/* Collapsible Examples Section */} +
+ + {examplesExpanded && ( +
+ +
+ )} +
+ + {/* Delete Confirmation Dialog */} + + + + + {dict.sessionHistory?.deleteTitle || + "Delete this chat?"} + + + {dict.sessionHistory?.deleteDescription || + "This will permanently delete this chat session and its diagram. This action cannot be undone."} + + + + + {dict.common.cancel} + + { + if (sessionToDelete && onDeleteSession) { + onDeleteSession(sessionToDelete) + } + setDeleteDialogOpen(false) + setSessionToDelete(null) + }} + className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400" + > + {dict.common.delete} + + + + +
+ ) +} diff --git a/components/chat/ToolCallCard.tsx b/components/chat/ToolCallCard.tsx new file mode 100644 index 0000000..ff2fbde --- /dev/null +++ b/components/chat/ToolCallCard.tsx @@ -0,0 +1,250 @@ +"use client" + +import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react" +import type { Dispatch, SetStateAction } from "react" +import { CodeBlock } from "@/components/code-block" +import { isMxCellXmlComplete } from "@/lib/utils" +import type { DiagramOperation, ToolPartLike } from "./types" + +interface ToolCallCardProps { + part: ToolPartLike + expandedTools: Record + setExpandedTools: Dispatch>> + onCopy: (callId: string, text: string, isToolCall: boolean) => void + copiedToolCallId: string | null + copyFailedToolCallId: string | null + dict: { + tools: { complete: string } + chat: { copied: string; failedToCopy: string; copyResponse: string } + } +} + +function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) { + return ( +
+ {operations.map((op, index) => ( +
+
+ + {op.operation} + + + cell_id: {op.cell_id} + +
+ {op.new_xml && ( +
+
+                                {op.new_xml}
+                            
+
+ )} +
+ ))} +
+ ) +} + +export function ToolCallCard({ + part, + expandedTools, + setExpandedTools, + onCopy, + copiedToolCallId, + copyFailedToolCallId, + dict, +}: ToolCallCardProps) { + const callId = part.toolCallId + const { state, input, output } = part + // Default to collapsed if tool is complete, expanded if still streaming + const isExpanded = expandedTools[callId] ?? state !== "output-available" + const toolName = part.type?.replace("tool-", "") + const isCopied = copiedToolCallId === callId + + 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 + } + } + + const handleCopy = () => { + let textToCopy = "" + + if (input && typeof input === "object") { + if (input.xml) { + textToCopy = input.xml + } else if (input.operations && Array.isArray(input.operations)) { + textToCopy = JSON.stringify(input.operations, null, 2) + } else if (Object.keys(input).length > 0) { + textToCopy = JSON.stringify(input, null, 2) + } + } + + if ( + output && + toolName === "get_shape_library" && + typeof output === "string" + ) { + textToCopy = output + } + + if (textToCopy) { + onCopy(callId, textToCopy, true) + } + } + + return ( +
+
+
+
+ +
+ + {getToolDisplayName(toolName)} + +
+
+ {state === "input-streaming" && ( +
+ )} + {state === "output-available" && ( + <> + + {dict.tools.complete} + + {isExpanded && ( + + )} + + )} + {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)}
+                        
+
+ )} +
+ ) +} diff --git a/components/chat/types.ts b/components/chat/types.ts new file mode 100644 index 0000000..3a98d96 --- /dev/null +++ b/components/chat/types.ts @@ -0,0 +1,16 @@ +export interface DiagramOperation { + operation: "update" | "add" | "delete" + cell_id: string + new_xml?: string +} + +export interface ToolPartLike { + type: string + toolCallId: string + state?: string + input?: { + xml?: string + operations?: DiagramOperation[] + } & Record + output?: string +}