"use client" import { useChat } from "@ai-sdk/react" import { DefaultChatTransport } from "ai" import { MessageSquarePlus, PanelRightClose, PanelRightOpen, Settings, } from "lucide-react" import Image from "next/image" import type React from "react" import { useCallback, useEffect, useRef, useState } from "react" import { flushSync } from "react-dom" import { Toaster, toast } from "sonner" import { ButtonWithTooltip } from "@/components/button-with-tooltip" import { ChatInput } from "@/components/chat-input" import { ModelConfigDialog } from "@/components/model-config-dialog" import { ResetWarningModal } from "@/components/reset-warning-modal" import { SettingsDialog } from "@/components/settings-dialog" import { useDiagram } from "@/contexts/diagram-context" import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers" import { useDictionary } from "@/hooks/use-dictionary" import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config" import { getApiEndpoint } from "@/lib/base-path" import { findCachedResponse } from "@/lib/cached-responses" import { formatMessage } from "@/lib/i18n/utils" import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { type FileData, useFileProcessor } from "@/lib/use-file-processor" import { useQuotaManager } from "@/lib/use-quota-manager" import { formatXML } from "@/lib/utils" import { ChatMessageDisplay } from "./chat-message-display" import { DevXmlSimulator } from "./dev-xml-simulator" // localStorage keys for persistence const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages" const STORAGE_XML_SNAPSHOTS_KEY = "next-ai-draw-io-xml-snapshots" const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id" export const STORAGE_DIAGRAM_XML_KEY = "next-ai-draw-io-diagram-xml" // sessionStorage keys const SESSION_STORAGE_INPUT_KEY = "next-ai-draw-io-input" // Type for message parts (tool calls and their states) interface MessagePart { type: string state?: string toolName?: string input?: { xml?: string; [key: string]: unknown } [key: string]: unknown } interface ChatMessage { role: string parts?: MessagePart[] [key: string]: unknown } interface ChatPanelProps { isVisible: boolean onToggleVisibility: () => void drawioUi: "min" | "sketch" onToggleDrawioUi: () => void darkMode: boolean onToggleDarkMode: () => void isMobile?: boolean onCloseProtectionChange?: (enabled: boolean) => void } // Constants for tool states const TOOL_ERROR_STATE = "output-error" as const const DEBUG = process.env.NODE_ENV === "development" const MAX_AUTO_RETRY_COUNT = 1 const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retries /** * Check if auto-resubmit should happen based on tool errors. * Only checks the LAST tool part (most recent tool call), not all tool parts. */ function hasToolErrors(messages: ChatMessage[]): boolean { const lastMessage = messages[messages.length - 1] if (!lastMessage || lastMessage.role !== "assistant") { return false } const toolParts = (lastMessage.parts as MessagePart[] | undefined)?.filter((part) => part.type?.startsWith("tool-"), ) || [] if (toolParts.length === 0) { return false } const lastToolPart = toolParts[toolParts.length - 1] return lastToolPart?.state === TOOL_ERROR_STATE } export default function ChatPanel({ isVisible, onToggleVisibility, drawioUi, onToggleDrawioUi, darkMode, onToggleDarkMode, isMobile = false, onCloseProtectionChange, }: ChatPanelProps) { const { loadDiagram: onDisplayChart, handleExport: onExport, handleExportWithoutHistory, resolverRef, chartXML, clearDiagram, } = useDiagram() const dict = useDictionary() const onFetchChart = (saveToHistory = true) => { return Promise.race([ new Promise((resolve) => { if (resolverRef && "current" in resolverRef) { resolverRef.current = resolve } if (saveToHistory) { onExport() } else { handleExportWithoutHistory() } }), new Promise((_, reject) => setTimeout( () => reject( new Error( "Chart export timed out after 10 seconds", ), ), 10000, ), ), ]) } // File processing using extracted hook const { files, pdfData, handleFileChange, setFiles } = useFileProcessor() const [showSettingsDialog, setShowSettingsDialog] = useState(false) const [showModelConfigDialog, setShowModelConfigDialog] = useState(false) // Model configuration hook const modelConfig = useModelConfig() const [input, setInput] = useState("") const [dailyRequestLimit, setDailyRequestLimit] = useState(0) const [dailyTokenLimit, setDailyTokenLimit] = useState(0) const [tpmLimit, setTpmLimit] = useState(0) const [showNewChatDialog, setShowNewChatDialog] = useState(false) const [minimalStyle, setMinimalStyle] = useState(false) // Restore input from sessionStorage on mount (when ChatPanel remounts due to key change) useEffect(() => { const savedInput = sessionStorage.getItem(SESSION_STORAGE_INPUT_KEY) if (savedInput) { setInput(savedInput) } }, []) // Check config on mount useEffect(() => { fetch(getApiEndpoint("/api/config")) .then((res) => res.json()) .then((data) => { setDailyRequestLimit(data.dailyRequestLimit || 0) setDailyTokenLimit(data.dailyTokenLimit || 0) setTpmLimit(data.tpmLimit || 0) }) .catch(() => {}) }, []) // Quota management using extracted hook const quotaManager = useQuotaManager({ dailyRequestLimit, dailyTokenLimit, tpmLimit, onConfigModel: () => setShowModelConfigDialog(true), }) // Generate a unique session ID for Langfuse tracing (restore from localStorage if available) const [sessionId, setSessionId] = useState(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem(STORAGE_SESSION_ID_KEY) if (saved) return saved } return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` }) // Store XML snapshots for each user message (keyed by message index) const xmlSnapshotsRef = useRef>(new Map()) // Flag to track if we've restored from localStorage const hasRestoredRef = useRef(false) // Ref to track latest chartXML for use in callbacks (avoids stale closure) const chartXMLRef = useRef(chartXML) useEffect(() => { chartXMLRef.current = chartXML }, [chartXML]) // Ref to track consecutive auto-retry count (reset on user action) const autoRetryCountRef = useRef(0) // Ref to track continuation retry count (for truncation handling) const continuationRetryCountRef = useRef(0) // Ref to accumulate partial XML when output is truncated due to maxOutputTokens // When partialXmlRef.current.length > 0, we're in continuation mode const partialXmlRef = useRef("") // Persist processed tool call IDs so collapsing the chat doesn't replay old tool outputs const processedToolCallsRef = useRef>(new Set()) // Store original XML for edit_diagram streaming - shared between streaming preview and tool handler // Key: toolCallId, Value: original XML before any operations applied const editDiagramOriginalXmlRef = useRef>(new Map()) // Debounce timeout for localStorage writes (prevents blocking during streaming) const localStorageDebounceRef = useRef | null>(null) const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second // Diagram tool handlers (display_diagram, edit_diagram, append_diagram) const { handleToolCall } = useDiagramToolHandlers({ partialXmlRef, editDiagramOriginalXmlRef, chartXMLRef, onDisplayChart, onFetchChart, onExport, }) const { messages, sendMessage, addToolOutput, status, error, setMessages } = useChat({ transport: new DefaultChatTransport({ api: getApiEndpoint("/api/chat"), }), onToolCall: async ({ toolCall }) => { await handleToolCall({ toolCall }, addToolOutput) }, onError: (error) => { // Handle server-side quota limit (429 response) // AI SDK puts the full response body in error.message for non-OK responses try { const data = JSON.parse(error.message) if (data.type === "request") { quotaManager.showQuotaLimitToast(data.used, data.limit) return } if (data.type === "token") { quotaManager.showTokenLimitToast(data.used, data.limit) return } if (data.type === "tpm") { quotaManager.showTPMLimitToast(data.limit) return } } catch { // Not JSON, fall through to string matching for backwards compatibility } // Fallback to string matching if (error.message.includes("Daily request limit")) { quotaManager.showQuotaLimitToast() return } if (error.message.includes("Daily token limit")) { quotaManager.showTokenLimitToast() return } if ( error.message.includes("Rate limit exceeded") || error.message.includes("tokens per minute") ) { quotaManager.showTPMLimitToast() return } // Silence access code error in console since it's handled by UI if (!error.message.includes("Invalid or missing access code")) { console.error("Chat error:", error) // Debug: Log messages structure when error occurs console.log("[onError] messages count:", messages.length) messages.forEach((msg, idx) => { console.log(`[onError] Message ${idx}:`, { role: msg.role, partsCount: msg.parts?.length, }) if (msg.parts) { msg.parts.forEach((part: any, partIdx: number) => { console.log( `[onError] Part ${partIdx}:`, JSON.stringify({ type: part.type, toolName: part.toolName, hasInput: !!part.input, inputType: typeof part.input, inputKeys: part.input && typeof part.input === "object" ? Object.keys(part.input) : null, }), ) }) } }) } // Translate technical errors into user-friendly messages // The server now handles detailed error messages, so we can display them directly. // But we still handle connection/network errors that happen before reaching the server. let friendlyMessage = error.message // Simple check for network errors if message is generic if (friendlyMessage === "Failed to fetch") { friendlyMessage = "Network error. Please check your connection." } // Truncated tool input error (model output limit too low) if (friendlyMessage.includes("toolUse.input is invalid")) { friendlyMessage = "Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength." } // Translate image not supported error if ( friendlyMessage.includes("image content block") || friendlyMessage.toLowerCase().includes("image_url") ) { friendlyMessage = "This model doesn't support image input." } // Add system message for error so it can be cleared setMessages((currentMessages) => { const errorMessage = { id: `error-${Date.now()}`, role: "system" as const, content: friendlyMessage, parts: [ { type: "text" as const, text: friendlyMessage }, ], } return [...currentMessages, errorMessage] }) if (error.message.includes("Invalid or missing access code")) { // Show settings dialog to help user fix it setShowSettingsDialog(true) } }, onFinish: ({ message }) => { // Track actual token usage from server metadata const metadata = message?.metadata as | Record | undefined // DEBUG: Log finish reason to diagnose truncation console.log("[onFinish] finishReason:", metadata?.finishReason) }, sendAutomaticallyWhen: ({ messages }) => { const isInContinuationMode = partialXmlRef.current.length > 0 const shouldRetry = hasToolErrors( messages as unknown as ChatMessage[], ) if (!shouldRetry) { // No error, reset retry count and clear state autoRetryCountRef.current = 0 continuationRetryCountRef.current = 0 partialXmlRef.current = "" return false } // Continuation mode: limited retries for truncation handling if (isInContinuationMode) { if ( continuationRetryCountRef.current >= MAX_CONTINUATION_RETRY_COUNT ) { toast.error( formatMessage(dict.errors.continuationRetryLimit, { max: MAX_CONTINUATION_RETRY_COUNT, }), ) continuationRetryCountRef.current = 0 partialXmlRef.current = "" return false } continuationRetryCountRef.current++ } else { // Regular error: check retry count limit if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) { toast.error( formatMessage(dict.errors.retryLimit, { max: MAX_AUTO_RETRY_COUNT, }), ) autoRetryCountRef.current = 0 partialXmlRef.current = "" return false } // Increment retry count for actual errors autoRetryCountRef.current++ } return true }, }) // Ref to track latest messages for unload persistence const messagesRef = useRef(messages) useEffect(() => { messagesRef.current = messages }, [messages]) const messagesEndRef = useRef(null) // Restore messages and XML snapshots from localStorage on mount useEffect(() => { if (hasRestoredRef.current) return hasRestoredRef.current = true try { // Restore messages const savedMessages = localStorage.getItem(STORAGE_MESSAGES_KEY) if (savedMessages) { const parsed = JSON.parse(savedMessages) if (Array.isArray(parsed) && parsed.length > 0) { setMessages(parsed) } } // Restore XML snapshots const savedSnapshots = localStorage.getItem( STORAGE_XML_SNAPSHOTS_KEY, ) if (savedSnapshots) { const parsed = JSON.parse(savedSnapshots) xmlSnapshotsRef.current = new Map(parsed) } } catch (error) { console.error("Failed to restore from localStorage:", error) // On complete failure, clear storage to allow recovery localStorage.removeItem(STORAGE_MESSAGES_KEY) localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY) toast.error(dict.errors.sessionCorrupted) } }, [setMessages]) // Save messages to localStorage whenever they change (debounced to prevent blocking during streaming) useEffect(() => { if (!hasRestoredRef.current) return // Clear any pending save if (localStorageDebounceRef.current) { clearTimeout(localStorageDebounceRef.current) } // Debounce: save after 1 second of no changes localStorageDebounceRef.current = setTimeout(() => { try { localStorage.setItem( STORAGE_MESSAGES_KEY, JSON.stringify(messages), ) } catch (error) { console.error("Failed to save messages to localStorage:", error) } }, LOCAL_STORAGE_DEBOUNCE_MS) // Cleanup on unmount return () => { if (localStorageDebounceRef.current) { clearTimeout(localStorageDebounceRef.current) } } }, [messages]) // Save XML snapshots to localStorage whenever they change const saveXmlSnapshots = useCallback(() => { try { const snapshotsArray = Array.from(xmlSnapshotsRef.current.entries()) localStorage.setItem( STORAGE_XML_SNAPSHOTS_KEY, JSON.stringify(snapshotsArray), ) } catch (error) { console.error( "Failed to save XML snapshots to localStorage:", error, ) } }, []) // Save session ID to localStorage useEffect(() => { localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId) }, [sessionId]) useEffect(() => { if (messagesEndRef.current) { messagesEndRef.current.scrollIntoView({ behavior: "smooth" }) } }, [messages]) // Save state right before page unload (refresh/close) useEffect(() => { const handleBeforeUnload = () => { try { localStorage.setItem( STORAGE_MESSAGES_KEY, JSON.stringify(messagesRef.current), ) localStorage.setItem( STORAGE_XML_SNAPSHOTS_KEY, JSON.stringify( Array.from(xmlSnapshotsRef.current.entries()), ), ) const xml = chartXMLRef.current if (xml && xml.length > 300) { localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xml) } localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId) } catch (error) { console.error("Failed to persist state before unload:", error) } } window.addEventListener("beforeunload", handleBeforeUnload) return () => window.removeEventListener("beforeunload", handleBeforeUnload) }, [sessionId]) const onFormSubmit = async (e: React.FormEvent) => { e.preventDefault() const isProcessing = status === "streaming" || status === "submitted" if (input.trim() && !isProcessing) { // Check if input matches a cached example (only when no messages yet) if (messages.length === 0) { const cached = findCachedResponse( input.trim(), files.length > 0, ) if (cached) { // Add user message and fake assistant response to messages // The chat-message-display useEffect will handle displaying the diagram const toolCallId = `cached-${Date.now()}` // Build user message text including any file content const userText = await processFilesAndAppendContent( input, files, pdfData, ) setMessages([ { id: `user-${Date.now()}`, role: "user" as const, parts: [{ type: "text" as const, text: userText }], }, { id: `assistant-${Date.now()}`, role: "assistant" as const, parts: [ { type: "tool-display_diagram" as const, toolCallId, state: "output-available" as const, input: { xml: cached.xml }, output: "Successfully displayed the diagram.", }, ], }, ] as any) setInput("") sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY) setFiles([]) return } } try { let chartXml = await onFetchChart() chartXml = formatXML(chartXml) // Update ref directly to avoid race condition with React's async state update // This ensures edit_diagram has the correct XML before AI responds chartXMLRef.current = chartXml // Build user text by concatenating input with pre-extracted text // (Backend only reads first text part, so we must combine them) const parts: any[] = [] const userText = await processFilesAndAppendContent( input, files, pdfData, parts, ) // Add the combined text as the first part parts.unshift({ type: "text", text: userText }) // Get previous XML from the last snapshot (before this message) const snapshotKeys = Array.from( xmlSnapshotsRef.current.keys(), ).sort((a, b) => b - a) const previousXml = snapshotKeys.length > 0 ? xmlSnapshotsRef.current.get(snapshotKeys[0]) || "" : "" // Save XML snapshot for this message (will be at index = current messages.length) const messageIndex = messages.length xmlSnapshotsRef.current.set(messageIndex, chartXml) saveXmlSnapshots() sendChatMessage(parts, chartXml, previousXml, sessionId) // Token count is tracked in onFinish with actual server usage setInput("") sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY) setFiles([]) } catch (error) { console.error("Error fetching chart data:", error) } } } const handleNewChat = useCallback(() => { setMessages([]) clearDiagram() handleFileChange([]) // Use handleFileChange to also clear pdfData const newSessionId = `session-${Date.now()}-${Math.random() .toString(36) .slice(2, 9)}` setSessionId(newSessionId) xmlSnapshotsRef.current.clear() // Clear localStorage with error handling try { localStorage.removeItem(STORAGE_MESSAGES_KEY) localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY) localStorage.removeItem(STORAGE_DIAGRAM_XML_KEY) localStorage.setItem(STORAGE_SESSION_ID_KEY, newSessionId) sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY) toast.success(dict.dialogs.clearSuccess) } catch (error) { console.error("Failed to clear localStorage:", error) toast.warning(dict.errors.storageUpdateFailed) } setShowNewChatDialog(false) }, [clearDiagram, handleFileChange, setMessages, setSessionId]) const handleInputChange = ( e: React.ChangeEvent, ) => { saveInputToSessionStorage(e.target.value) setInput(e.target.value) } const saveInputToSessionStorage = (input: string) => { sessionStorage.setItem(SESSION_STORAGE_INPUT_KEY, input) } // Helper functions for message actions (regenerate/edit) // Extract previous XML snapshot before a given message index const getPreviousXml = (beforeIndex: number): string => { const snapshotKeys = Array.from(xmlSnapshotsRef.current.keys()) .filter((k) => k < beforeIndex) .sort((a, b) => b - a) return snapshotKeys.length > 0 ? xmlSnapshotsRef.current.get(snapshotKeys[0]) || "" : "" } // Restore diagram from snapshot and update ref const restoreDiagramFromSnapshot = (savedXml: string) => { onDisplayChart(savedXml, true) // Skip validation for trusted snapshots chartXMLRef.current = savedXml } // Clean up snapshots after a given message index const cleanupSnapshotsAfter = (messageIndex: number) => { for (const key of xmlSnapshotsRef.current.keys()) { if (key > messageIndex) { xmlSnapshotsRef.current.delete(key) } } saveXmlSnapshots() } // Send chat message with headers const sendChatMessage = ( parts: any, xml: string, previousXml: string, sessionId: string, ) => { // Reset all retry/continuation state on user-initiated message autoRetryCountRef.current = 0 continuationRetryCountRef.current = 0 partialXmlRef.current = "" const config = getSelectedAIConfig() sendMessage( { parts }, { body: { xml, previousXml, sessionId }, headers: { "x-access-code": config.accessCode, ...(config.aiProvider && { "x-ai-provider": config.aiProvider, ...(config.aiBaseUrl && { "x-ai-base-url": config.aiBaseUrl, }), ...(config.aiApiKey && { "x-ai-api-key": config.aiApiKey, }), ...(config.aiModel && { "x-ai-model": config.aiModel }), // AWS Bedrock credentials ...(config.awsAccessKeyId && { "x-aws-access-key-id": config.awsAccessKeyId, }), ...(config.awsSecretAccessKey && { "x-aws-secret-access-key": config.awsSecretAccessKey, }), ...(config.awsRegion && { "x-aws-region": config.awsRegion, }), ...(config.awsSessionToken && { "x-aws-session-token": config.awsSessionToken, }), }), ...(minimalStyle && { "x-minimal-style": "true", }), }, }, ) } // Process files and append content to user text (handles PDF, text, and optionally images) const processFilesAndAppendContent = async ( baseText: string, files: File[], pdfData: Map, imageParts?: any[], ): Promise => { let userText = baseText for (const file of files) { if (isPdfFile(file)) { const extracted = pdfData.get(file) if (extracted?.text) { userText += `\n\n[PDF: ${file.name}]\n${extracted.text}` } } else if (isTextFile(file)) { const extracted = pdfData.get(file) if (extracted?.text) { userText += `\n\n[File: ${file.name}]\n${extracted.text}` } } else if (imageParts) { // Handle as image (only if imageParts array provided) const reader = new FileReader() const dataUrl = await new Promise((resolve) => { reader.onload = () => resolve(reader.result as string) reader.readAsDataURL(file) }) imageParts.push({ type: "file", url: dataUrl, mediaType: file.type, }) } } return userText } const handleRegenerate = async (messageIndex: number) => { const isProcessing = status === "streaming" || status === "submitted" if (isProcessing) return // Find the user message before this assistant message let userMessageIndex = messageIndex - 1 while ( userMessageIndex >= 0 && messages[userMessageIndex].role !== "user" ) { userMessageIndex-- } if (userMessageIndex < 0) return const userMessage = messages[userMessageIndex] const userParts = userMessage.parts // Get the text from the user message const textPart = userParts?.find((p: any) => p.type === "text") if (!textPart) return // Get the saved XML snapshot for this user message const savedXml = xmlSnapshotsRef.current.get(userMessageIndex) if (!savedXml) { console.error( "No saved XML snapshot for message index:", userMessageIndex, ) return } // Get previous XML and restore diagram state const previousXml = getPreviousXml(userMessageIndex) restoreDiagramFromSnapshot(savedXml) // Clean up snapshots for messages after the user message (they will be removed) cleanupSnapshotsAfter(userMessageIndex) // Remove the user message AND assistant message onwards (sendMessage will re-add the user message) // Use flushSync to ensure state update is processed synchronously before sending const newMessages = messages.slice(0, userMessageIndex) flushSync(() => { setMessages(newMessages) }) // Now send the message after state is guaranteed to be updated sendChatMessage(userParts, savedXml, previousXml, sessionId) } const handleEditMessage = async (messageIndex: number, newText: string) => { const isProcessing = status === "streaming" || status === "submitted" if (isProcessing) return const message = messages[messageIndex] if (!message || message.role !== "user") return // Get the saved XML snapshot for this user message const savedXml = xmlSnapshotsRef.current.get(messageIndex) if (!savedXml) { console.error( "No saved XML snapshot for message index:", messageIndex, ) return } // Get previous XML and restore diagram state const previousXml = getPreviousXml(messageIndex) restoreDiagramFromSnapshot(savedXml) // Clean up snapshots for messages after the user message (they will be removed) cleanupSnapshotsAfter(messageIndex) // Create new parts with updated text const newParts = message.parts?.map((part: any) => { if (part.type === "text") { return { ...part, text: newText } } return part }) || [{ type: "text", text: newText }] // Remove the user message AND assistant message onwards (sendMessage will re-add the user message) // Use flushSync to ensure state update is processed synchronously before sending const newMessages = messages.slice(0, messageIndex) flushSync(() => { setMessages(newMessages) }) // Now send the edited message after state is guaranteed to be updated sendChatMessage(newParts, savedXml, previousXml, sessionId) } // Collapsed view (desktop only) if (!isVisible && !isMobile) { return (
{dict.nav.aiChat}
) } // Full view return (
{/* Header */}
Next AI Drawio

Next AI Drawio

setShowNewChatDialog(true)} className="hover:bg-accent" > setShowSettingsDialog(true)} className="hover:bg-accent" >
{!isMobile && ( )}
{/* Messages */}
{/* Dev XML Streaming Simulator - only in development */} {DEBUG && ( quotaManager.showQuotaLimitToast(50, 50) } /> )} {/* Input */}
setShowModelConfigDialog(true)} />
) }