diff --git a/app/[lang]/page.tsx b/app/[lang]/page.tsx index 6b7ee1d..ffbf4a8 100644 --- a/app/[lang]/page.tsx +++ b/app/[lang]/page.tsx @@ -1,6 +1,6 @@ "use client" import { usePathname, useRouter } from "next/navigation" -import { useCallback, useEffect, useRef, useState } from "react" +import { Suspense, useCallback, useEffect, useRef, useState } from "react" import { DrawIoEmbed } from "react-drawio" import type { ImperativePanelHandle } from "react-resizable-panels" import ChatPanel from "@/components/chat-panel" @@ -22,7 +22,6 @@ export default function Home() { handleDiagramExport, onDrawioLoad, resetDrawioReady, - saveDiagramToStorage, showSaveDialog, setShowSaveDialog, } = useDiagram() @@ -110,8 +109,7 @@ export default function Home() { onDrawioLoad() }, [onDrawioLoad]) - const handleDarkModeChange = async () => { - await saveDiagramToStorage() + const handleDarkModeChange = () => { const newValue = !darkMode setDarkMode(newValue) localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue)) @@ -120,8 +118,7 @@ export default function Home() { resetDrawioReady() } - const handleDrawioUiChange = async () => { - await saveDiagramToStorage() + const handleDrawioUiChange = () => { const newUi = drawioUi === "min" ? "sketch" : "min" localStorage.setItem("drawio-theme", newUi) setDrawioUi(newUi) @@ -129,7 +126,7 @@ export default function Home() { resetDrawioReady() } - // Check mobile - save diagram and reset draw.io before crossing breakpoint + // Check mobile - reset draw.io before crossing breakpoint const isInitialRenderRef = useRef(true) useEffect(() => { const checkMobile = () => { @@ -138,7 +135,6 @@ export default function Home() { !isInitialRenderRef.current && newIsMobile !== isMobileRef.current ) { - saveDiagramToStorage().catch(() => {}) setIsDrawioReady(false) resetDrawioReady() } @@ -150,7 +146,7 @@ export default function Home() { checkMobile() window.addEventListener("resize", checkMobile) return () => window.removeEventListener("resize", checkMobile) - }, [saveDiagramToStorage, resetDrawioReady]) + }, [resetDrawioReady]) const toggleChatPanel = () => { const panel = chatPanelRef.current @@ -266,16 +262,24 @@ export default function Home() { onExpand={() => setIsChatVisible(true)} >
- + + Loading chat... +
+ } + > + + diff --git a/app/globals.css b/app/globals.css index b1f23dd..d91c503 100644 --- a/app/globals.css +++ b/app/globals.css @@ -74,8 +74,8 @@ --accent: oklch(0.94 0.03 280); --accent-foreground: oklch(0.35 0.08 270); - /* Coral destructive */ - --destructive: oklch(0.6 0.2 25); + /* Muted rose destructive */ + --destructive: oklch(0.45 0.12 10); /* Subtle borders */ --border: oklch(0.92 0.01 260); @@ -122,7 +122,7 @@ --accent: oklch(0.3 0.04 280); --accent-foreground: oklch(0.9 0.03 270); - --destructive: oklch(0.65 0.22 25); + --destructive: oklch(0.55 0.12 10); --border: oklch(0.28 0.015 260); --input: oklch(0.25 0.015 260); diff --git a/components/chat-example-panel.tsx b/components/chat-example-panel.tsx index 984601d..a74f42c 100644 --- a/components/chat-example-panel.tsx +++ b/components/chat-example-panel.tsx @@ -70,9 +70,11 @@ function ExampleCard({ export default function ExamplePanel({ setInput, setFiles, + minimal = false, }: { setInput: (input: string) => void setFiles: (files: File[]) => void + minimal?: boolean }) { const dict = useDictionary() @@ -120,49 +122,55 @@ export default function ExamplePanel({ } return ( -
- {/* MCP Server Notice */} - -
-
- -
-
-
- - {dict.examples.mcpServer} - - - {dict.examples.preview} - + - - - {/* Welcome section */} -
-

- {dict.examples.title} -

-

- {dict.examples.subtitle} -

-
+ + )} {/* Examples grid */}
-

- {dict.examples.quickExamples} -

+ {!minimal && ( +

+ {dict.examples.quickExamples} +

+ )}
) => void onChange: (e: React.ChangeEvent) => void - onClearChat: () => void files?: File[] onFileChange?: (files: File[]) => void pdfData?: Map< @@ -163,7 +160,6 @@ export function ChatInput({ status, onSubmit, onChange, - onClearChat, files = [], onFileChange = () => {}, pdfData = new Map(), @@ -181,7 +177,6 @@ export function ChatInput({ const textareaRef = useRef(null) const fileInputRef = useRef(null) const [isDragging, setIsDragging] = useState(false) - const [showClearDialog, setShowClearDialog] = useState(false) const [showHistory, setShowHistory] = useState(false) const [showSaveDialog, setShowSaveDialog] = useState(false) // Allow retry when there's an error (even if status is still "streaming" or "submitted") @@ -313,11 +308,6 @@ export function ChatInput({ } } - const handleClear = () => { - onClearChat() - setShowClearDialog(false) - } - return (
-
+
setShowClearDialog(true)} - tooltipContent={dict.chat.clearConversation} - className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10" + onClick={() => setShowHistory(true)} + disabled={isDisabled || diagramHistory.length === 0} + tooltipContent={dict.chat.diagramHistory} + className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground" > - + - -
- -
-
- setShowHistory(true)} - disabled={ - isDisabled || diagramHistory.length === 0 - } - tooltipContent={dict.chat.diagramHistory} - className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground" - > - - - - setShowSaveDialog(true)} - disabled={isDisabled} - tooltipContent={dict.chat.saveDiagram} - className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground" - > - - - - - - - - -
- -
- + + + + + + + +
+ +
+
{ return fullText.replace(filePattern, "").trim() } +interface SessionMetadata { + id: string + title: string + updatedAt: number + thumbnailDataUrl?: string +} + interface ChatMessageDisplayProps { messages: UIMessage[] setInput: (input: string) => void @@ -194,6 +214,10 @@ interface ChatMessageDisplayProps { 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> } export function ChatMessageDisplay({ @@ -207,14 +231,32 @@ export function ChatMessageDisplay({ onEditMessage, status = "idle", isRestored = false, + sessions = [], + onSelectSession, + onDeleteSession, + loadedMessageIdsRef, }: ChatMessageDisplayProps) { const dict = useDictionary() 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]) // Debounce streaming diagram updates - store pending XML and timeout const pendingXmlRef = useRef(null) const debounceTimeoutRef = useRef | null>( @@ -252,15 +294,13 @@ export function ChatMessageDisplay({ const [expandedPdfSections, setExpandedPdfSections] = useState< Record >({}) - // Track message IDs that were restored from localStorage (skip animation for these) - const restoredMessageIdsRef = useRef | null>(null) - - // Capture restored message IDs once when isRestored becomes true - useEffect(() => { - if (isRestored && restoredMessageIdsRef.current === null) { - restoredMessageIdsRef.current = new Set(messages.map((m) => m.id)) - } - }, [isRestored, messages]) + // 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, @@ -358,7 +398,6 @@ export function ChatMessageDisplay({ 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 @@ -382,14 +421,8 @@ export function ChatMessageDisplay({ 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) + // Only show toast if this is the final XML (not during streaming) 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 @@ -403,18 +436,12 @@ export function ChatMessageDisplay({ `` 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 } @@ -424,30 +451,12 @@ export function ChatMessageDisplay({ 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, - ) + console.error("Error processing XML:", error) // Only show toast if this is the final XML (not during streaming) if (showToast) { toast.error(dict.errors.failedToProcess) @@ -458,8 +467,22 @@ export function ChatMessageDisplay({ [chartXML, onDisplayChart], ) + // Track previous message count to detect bulk loads vs streaming + const prevMessageCountRef = useRef(0) + useEffect(() => { - if (messagesEndRef.current) { + 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 + } + + // Single message added - smooth scroll messagesEndRef.current.scrollIntoView({ behavior: "smooth" }) } }, [messages]) @@ -869,10 +892,191 @@ export function ChatMessageDisplay({ ) } + // 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 + + ) ) : messages.length === 0 ? null : (
{messages.map((message, messageIndex) => { @@ -893,12 +1097,10 @@ export function ChatMessageDisplay({ .slice(messageIndex + 1) .every((m) => m.role !== "user")) const isEditing = editingMessageId === message.id - // Skip animation for restored messages - // If isRestored but ref not set yet, we're in first render after restoration - treat all as restored + // Skip animation for loaded messages (from session restore) const isRestoredMessage = - isRestored && - (restoredMessageIdsRef.current === null || - restoredMessageIdsRef.current.has(message.id)) + loadedMessageIdsRef?.current.has(message.id) ?? + false return (
)}
+ + {/* 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-panel.tsx b/components/chat-panel.tsx index f57d55b..61e3611 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -9,6 +9,7 @@ import { Settings, } from "lucide-react" import Image from "next/image" +import { useRouter, useSearchParams } from "next/navigation" import type React from "react" import { useCallback, @@ -22,27 +23,25 @@ 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 { useSessionManager } from "@/hooks/use-session-manager" 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 { sanitizeMessages } from "@/lib/session-storage" import { type FileData, useFileProcessor } from "@/lib/use-file-processor" import { useQuotaManager } from "@/lib/use-quota-manager" -import { cn, formatXML } from "@/lib/utils" +import { cn, formatXML, isRealDiagram } 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" @@ -119,10 +118,17 @@ export default function ChatPanel({ handleExportWithoutHistory, resolverRef, chartXML, + latestSvg, clearDiagram, + getThumbnailSvg, + diagramHistory, + setDiagramHistory, } = useDiagram() const dict = useDictionary() + const router = useRouter() + const searchParams = useSearchParams() + const urlSessionId = searchParams.get("session") const onFetchChart = (saveToHistory = true) => { return Promise.race([ @@ -158,11 +164,14 @@ export default function ChatPanel({ // Model configuration hook const modelConfig = useModelConfig() + + // Session manager for chat history (pass URL session ID for restoration) + const sessionManager = useSessionManager({ initialSessionId: urlSessionId }) + 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) @@ -222,10 +231,22 @@ export default function ChatPanel({ // Ref to track latest chartXML for use in callbacks (avoids stale closure) const chartXMLRef = useRef(chartXML) + // Track session ID that was loaded without a diagram (to prevent thumbnail contamination) + const justLoadedSessionIdRef = useRef(null) useEffect(() => { chartXMLRef.current = chartXML + // Clear the no-diagram flag when a diagram is generated + if (chartXML) { + justLoadedSessionIdRef.current = null + } }, [chartXML]) + // Ref to track latest SVG for thumbnail generation + const latestSvgRef = useRef(latestSvg) + useEffect(() => { + latestSvgRef.current = latestSvg + }, [latestSvg]) + // Ref to track consecutive auto-retry count (reset on user action) const autoRetryCountRef = useRef(0) // Ref to track continuation retry count (for truncation handling) @@ -307,32 +328,6 @@ export default function ChatPanel({ // 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 @@ -378,15 +373,7 @@ export default function ChatPanel({ 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) - }, + onFinish: () => {}, sendAutomaticallyWhen: ({ messages }) => { const isInContinuationMode = partialXmlRef.current.length > 0 @@ -444,61 +431,199 @@ export default function ChatPanel({ messagesRef.current = messages }, [messages]) - const messagesEndRef = useRef(null) + // Track last synced session ID to detect external changes (e.g., URL back/forward) + const lastSyncedSessionIdRef = useRef(null) - // Restore messages and XML snapshots from localStorage on mount - // useLayoutEffect runs synchronously before browser paint, so messages appear immediately + // Helper: Sync UI state with session data (eliminates duplication) + // Track message IDs that are being loaded from session (to skip animations/scroll) + const loadedMessageIdsRef = useRef>(new Set()) + // Track when session was just loaded (to skip auto-save on load) + const justLoadedSessionRef = useRef(false) + + const syncUIWithSession = useCallback( + ( + data: { + messages: unknown[] + xmlSnapshots: [number, string][] + diagramXml: string + diagramHistory?: { svg: string; xml: string }[] + } | null, + ) => { + const hasRealDiagram = isRealDiagram(data?.diagramXml) + if (data) { + // Mark all message IDs as loaded from session + const messageIds = (data.messages as any[]).map( + (m: any) => m.id, + ) + loadedMessageIdsRef.current = new Set(messageIds) + setMessages(data.messages as any) + xmlSnapshotsRef.current = new Map(data.xmlSnapshots) + if (hasRealDiagram) { + onDisplayChart(data.diagramXml, true) + chartXMLRef.current = data.diagramXml + } else { + clearDiagram() + // Clear refs to prevent stale data from being saved + chartXMLRef.current = "" + latestSvgRef.current = "" + } + setDiagramHistory(data.diagramHistory || []) + } else { + loadedMessageIdsRef.current = new Set() + setMessages([]) + xmlSnapshotsRef.current.clear() + clearDiagram() + // Clear refs to prevent stale data from being saved + chartXMLRef.current = "" + latestSvgRef.current = "" + setDiagramHistory([]) + } + }, + [setMessages, onDisplayChart, clearDiagram, setDiagramHistory], + ) + + // Helper: Build session data object for saving (eliminates duplication) + const buildSessionData = useCallback( + async (options: { withThumbnail?: boolean } = {}) => { + const currentDiagramXml = chartXMLRef.current || "" + // Only capture thumbnail if there's a meaningful diagram (not just empty template) + const hasRealDiagram = isRealDiagram(currentDiagramXml) + let thumbnailDataUrl: string | undefined + if (hasRealDiagram && options.withThumbnail) { + const freshThumb = await getThumbnailSvg() + if (freshThumb) { + latestSvgRef.current = freshThumb + thumbnailDataUrl = freshThumb + } else if (latestSvgRef.current) { + // Use cached thumbnail only if we have a real diagram + thumbnailDataUrl = latestSvgRef.current + } + } + return { + messages: sanitizeMessages(messagesRef.current), + xmlSnapshots: Array.from(xmlSnapshotsRef.current.entries()), + diagramXml: currentDiagramXml, + thumbnailDataUrl, + diagramHistory, + } + }, + [diagramHistory, getThumbnailSvg], + ) + + // Restore messages and XML snapshots from session manager on mount + // This effect syncs with the session manager's loaded session useLayoutEffect(() => { if (hasRestoredRef.current) return + if (sessionManager.isLoading) return // Wait for session manager to load + 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) + const currentSession = sessionManager.currentSession + if (currentSession && currentSession.messages.length > 0) { + // Restore from session manager (IndexedDB) + justLoadedSessionRef.current = true + syncUIWithSession(currentSession) } + // Initialize lastSyncedSessionIdRef to prevent sync effect from firing immediately + lastSyncedSessionIdRef.current = sessionManager.currentSessionId + // Note: Migration from old localStorage format is handled by session-storage.ts } 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) + console.error("Failed to restore session:", error) toast.error(dict.errors.sessionCorrupted) } finally { setIsRestored(true) } - }, [setMessages, dict.errors.sessionCorrupted]) + }, [ + sessionManager.isLoading, + sessionManager.currentSession, + syncUIWithSession, + dict.errors.sessionCorrupted, + ]) + + // Sync UI when session changes externally (e.g., URL navigation via back/forward) + // This handles changes AFTER initial restore + useEffect(() => { + if (!isRestored) return // Wait for initial restore to complete + if (!sessionManager.isAvailable) return + + const newSessionId = sessionManager.currentSessionId + const newSession = sessionManager.currentSession + + // Skip if session ID hasn't changed (our own saves don't change the ID) + if (newSessionId === lastSyncedSessionIdRef.current) return + + // Update last synced ID + lastSyncedSessionIdRef.current = newSessionId + + // Sync UI with new session + if (newSession && newSession.messages.length > 0) { + justLoadedSessionRef.current = true + syncUIWithSession(newSession) + } else if (!newSession) { + syncUIWithSession(null) + } + }, [ + isRestored, + sessionManager.isAvailable, + sessionManager.currentSessionId, + sessionManager.currentSession, + syncUIWithSession, + ]) + + // Save messages to session manager (debounced, only when not streaming) + // Destructure stable values to avoid effect re-running on every render + const { + isAvailable: sessionIsAvailable, + currentSessionId, + saveCurrentSession, + } = sessionManager + + // Use ref for saveCurrentSession to avoid infinite loop + // (saveCurrentSession changes after each save, which would re-trigger the effect) + const saveCurrentSessionRef = useRef(saveCurrentSession) + saveCurrentSessionRef.current = saveCurrentSession - // Save messages to localStorage whenever they change (debounced to prevent blocking during streaming) useEffect(() => { if (!hasRestoredRef.current) return + if (!sessionIsAvailable) return + // Only save when not actively streaming to avoid write storms + if (status === "streaming" || status === "submitted") return + + // Skip auto-save if session was just loaded (to prevent re-ordering) + if (justLoadedSessionRef.current) { + justLoadedSessionRef.current = false + return + } // Clear any pending save if (localStorageDebounceRef.current) { clearTimeout(localStorageDebounceRef.current) } + // Capture current session ID at schedule time to verify at save time + const scheduledForSessionId = currentSessionId + // Capture whether there's a REAL diagram NOW (not just empty template) + const hasDiagramNow = isRealDiagram(chartXMLRef.current) + // Check if this session was just loaded without a diagram + const isNodiagramSession = + justLoadedSessionIdRef.current === scheduledForSessionId + // Debounce: save after 1 second of no changes - localStorageDebounceRef.current = setTimeout(() => { + localStorageDebounceRef.current = setTimeout(async () => { try { - localStorage.setItem( - STORAGE_MESSAGES_KEY, - JSON.stringify(messages), - ) + if (messages.length > 0) { + const sessionData = await buildSessionData({ + // Only capture thumbnail if there was a diagram AND this isn't a no-diagram session + withThumbnail: hasDiagramNow && !isNodiagramSession, + }) + await saveCurrentSessionRef.current( + sessionData, + scheduledForSessionId, + ) + } } catch (error) { - console.error("Failed to save messages to localStorage:", error) + console.error("Failed to save session:", error) } }, LOCAL_STORAGE_DEBOUNCE_MS) @@ -508,63 +633,62 @@ export default function ChatPanel({ clearTimeout(localStorageDebounceRef.current) } } - }, [messages]) + }, [ + messages, + status, + sessionIsAvailable, + currentSessionId, + buildSessionData, + ]) - // 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, - ) + // Update URL when a new session is created (first message sent) + useEffect(() => { + if (sessionManager.currentSessionId && !urlSessionId) { + // A session was created but URL doesn't have the session param yet + router.replace(`?session=${sessionManager.currentSessionId}`, { + scroll: false, + }) } - }, []) + }, [sessionManager.currentSessionId, urlSessionId, router]) // Save session ID to localStorage useEffect(() => { localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId) }, [sessionId]) + // Save session when page becomes hidden (tab switch, close, navigate away) + // This is more reliable than beforeunload for async IndexedDB operations useEffect(() => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: "smooth" }) - } - }, [messages]) + if (!sessionManager.isAvailable) return - // 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) + const handleVisibilityChange = async () => { + if ( + document.visibilityState === "hidden" && + messagesRef.current.length > 0 + ) { + try { + // Attempt to save session - browser may not wait for completion + // Skip thumbnail capture as it may not complete in time + const sessionData = await buildSessionData({ + withThumbnail: false, + }) + await sessionManager.saveCurrentSession(sessionData) + } catch (error) { + console.error( + "Failed to save session on visibility change:", + error, + ) } - localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId) - } catch (error) { - console.error("Failed to persist state before unload:", error) } } - window.addEventListener("beforeunload", handleBeforeUnload) + document.addEventListener("visibilitychange", handleVisibilityChange) return () => - window.removeEventListener("beforeunload", handleBeforeUnload) - }, [sessionId]) + document.removeEventListener( + "visibilitychange", + handleVisibilityChange, + ) + }, [sessionManager, buildSessionData]) const onFormSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -648,7 +772,6 @@ export default function ChatPanel({ // 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) @@ -662,30 +785,97 @@ export default function ChatPanel({ } } - const handleNewChat = useCallback(() => { + // Handle session switching from history dropdown + const handleSelectSession = useCallback( + async (sessionId: string) => { + if (!sessionManager.isAvailable) return + + // Save current session before switching + if (messages.length > 0) { + const sessionData = await buildSessionData({ + withThumbnail: true, + }) + await sessionManager.saveCurrentSession(sessionData) + } + + // Switch to selected session + const sessionData = await sessionManager.switchSession(sessionId) + if (sessionData) { + const hasRealDiagram = isRealDiagram(sessionData.diagramXml) + justLoadedSessionRef.current = true + + // CRITICAL: Update latestSvgRef with the NEW session's thumbnail + // This prevents stale thumbnail from previous session being used by auto-save + latestSvgRef.current = sessionData.thumbnailDataUrl || "" + + // Track if this session has no real diagram - to prevent thumbnail contamination + if (!hasRealDiagram) { + justLoadedSessionIdRef.current = sessionId + } else { + justLoadedSessionIdRef.current = null + } + syncUIWithSession(sessionData) + router.replace(`?session=${sessionId}`, { scroll: false }) + } + }, + [sessionManager, messages, buildSessionData, syncUIWithSession, router], + ) + + // Handle session deletion from history dropdown + const handleDeleteSession = useCallback( + async (sessionId: string) => { + if (!sessionManager.isAvailable) return + const result = await sessionManager.deleteSession(sessionId) + + if (result.wasCurrentSession) { + // Deleted current session - clear UI and URL + syncUIWithSession(null) + router.replace(window.location.pathname, { scroll: false }) + } + }, + [sessionManager, syncUIWithSession, router], + ) + + const handleNewChat = useCallback(async () => { + // Save current session before creating new one + if (sessionManager.isAvailable && messages.length > 0) { + const sessionData = await buildSessionData({ withThumbnail: true }) + await sessionManager.saveCurrentSession(sessionData) + // Refresh sessions list to ensure dropdown shows the saved session + await sessionManager.refreshSessions() + } + + // Clear session manager state BEFORE clearing URL to prevent race condition + // (otherwise the URL update effect would restore the old session URL) + sessionManager.clearCurrentSession() + + // Clear UI state (can't use syncUIWithSession here because we also need to clear files) setMessages([]) clearDiagram() + setDiagramHistory([]) 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) - } + sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY) + toast.success(dict.dialogs.clearSuccess) - setShowNewChatDialog(false) - }, [clearDiagram, handleFileChange, setMessages, setSessionId]) + // Clear URL param to show blank state + router.replace(window.location.pathname, { scroll: false }) + }, [ + clearDiagram, + handleFileChange, + setMessages, + setSessionId, + sessionManager, + messages, + router, + dict.dialogs.clearSuccess, + buildSessionData, + setDiagramHistory, + ]) const handleInputChange = ( e: React.ChangeEvent, @@ -722,7 +912,6 @@ export default function ChatPanel({ xmlSnapshotsRef.current.delete(key) } } - saveXmlSnapshots() } // Send chat message with headers @@ -958,7 +1147,12 @@ export default function ChatPanel({ className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`} >
-
+
+
setShowNewChatDialog(true)} + onClick={handleNewChat} className="hover:bg-accent" > @@ -1055,7 +1253,6 @@ export default function ChatPanel({ status={status} onSubmit={onFormSubmit} onChange={handleInputChange} - onClearChat={handleNewChat} files={files} onFileChange={handleFileChange} pdfData={pdfData} @@ -1086,12 +1283,6 @@ export default function ChatPanel({ onOpenChange={setShowModelConfigDialog} modelConfig={modelConfig} /> - -
) } diff --git a/contexts/diagram-context.tsx b/contexts/diagram-context.tsx index a43aeaf..dd3af3b 100644 --- a/contexts/diagram-context.tsx +++ b/contexts/diagram-context.tsx @@ -3,15 +3,19 @@ import type React from "react" import { createContext, useContext, useEffect, useRef, useState } from "react" import type { DrawIoEmbedRef } from "react-drawio" -import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel" import type { ExportFormat } from "@/components/save-dialog" import { getApiEndpoint } from "@/lib/base-path" -import { extractDiagramXML, validateAndFixXml } from "../lib/utils" +import { + extractDiagramXML, + isRealDiagram, + validateAndFixXml, +} from "../lib/utils" interface DiagramContextType { chartXML: string latestSvg: string diagramHistory: { svg: string; xml: string }[] + setDiagramHistory: (history: { svg: string; xml: string }[]) => void loadDiagram: (chart: string, skipValidation?: boolean) => string | null handleExport: () => void handleExportWithoutHistory: () => void @@ -24,7 +28,7 @@ interface DiagramContextType { format: ExportFormat, sessionId?: string, ) => void - saveDiagramToStorage: () => Promise + getThumbnailSvg: () => Promise isDrawioReady: boolean onDrawioLoad: () => void resetDrawioReady: () => void @@ -41,72 +45,52 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { { svg: string; xml: string }[] >([]) const [isDrawioReady, setIsDrawioReady] = useState(false) - const [canSaveDiagram, setCanSaveDiagram] = useState(false) const [showSaveDialog, setShowSaveDialog] = useState(false) const hasCalledOnLoadRef = useRef(false) const drawioRef = useRef(null) const resolverRef = useRef<((value: string) => void) | null>(null) // Track if we're expecting an export for history (user-initiated) const expectHistoryExportRef = useRef(false) - // Track if diagram has been restored from localStorage + // Track if diagram has been restored after DrawIO remount (e.g., theme change) const hasDiagramRestoredRef = useRef(false) + // Track latest chartXML for restoration after remount + const chartXMLRef = useRef("") const onDrawioLoad = () => { // Only set ready state once to prevent infinite loops if (hasCalledOnLoadRef.current) return hasCalledOnLoadRef.current = true - // console.log("[DiagramContext] DrawIO loaded, setting ready state") setIsDrawioReady(true) } const resetDrawioReady = () => { - // console.log("[DiagramContext] Resetting DrawIO ready state") hasCalledOnLoadRef.current = false setIsDrawioReady(false) } - // Restore diagram XML when DrawIO becomes ready - // eslint-disable-next-line react-hooks/exhaustive-deps -- loadDiagram uses refs internally and is stable + // Keep chartXMLRef in sync with state for restoration after remount useEffect(() => { - // Reset restore flag when DrawIO is not ready (e.g., theme/UI change remounts it) + chartXMLRef.current = chartXML + }, [chartXML]) + + // Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change) + useEffect(() => { + // Reset restore flag when DrawIO is not ready (preparing for next restore cycle) if (!isDrawioReady) { hasDiagramRestoredRef.current = false - setCanSaveDiagram(false) return } + // Only restore once per ready cycle if (hasDiagramRestoredRef.current) return hasDiagramRestoredRef.current = true - try { - const savedDiagramXml = localStorage.getItem( - STORAGE_DIAGRAM_XML_KEY, - ) - if (savedDiagramXml) { - // Skip validation for trusted saved diagrams - loadDiagram(savedDiagramXml, true) - } - } catch (error) { - console.error("Failed to restore diagram from localStorage:", error) + // Restore diagram from ref if we have one + const xmlToRestore = chartXMLRef.current + if (isRealDiagram(xmlToRestore) && drawioRef.current) { + drawioRef.current.load({ xml: xmlToRestore }) } - - // Allow saving after restore is complete - setTimeout(() => { - setCanSaveDiagram(true) - }, 500) }, [isDrawioReady]) - // Save diagram XML to localStorage whenever it changes (debounced) - useEffect(() => { - if (!canSaveDiagram) return - if (!chartXML || chartXML.length <= 300) return - - const timeoutId = setTimeout(() => { - localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML) - }, 1000) - - return () => clearTimeout(timeoutId) - }, [chartXML, canSaveDiagram]) - // Track if we're expecting an export for file save (stores raw export data) const saveResolverRef = useRef<{ resolver: ((data: string) => void) | null @@ -132,27 +116,32 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { } } - // Save current diagram to localStorage (used before theme/UI changes) - const saveDiagramToStorage = async (): Promise => { - if (!drawioRef.current) return + // Get current diagram as SVG for thumbnail (used by session storage) + const getThumbnailSvg = async (): Promise => { + if (!drawioRef.current) return null + // Don't export if diagram is empty + if (!isRealDiagram(chartXML)) return null try { - const currentXml = await Promise.race([ + const svgData = await Promise.race([ new Promise((resolve) => { resolverRef.current = resolve drawioRef.current?.exportDiagram({ format: "xmlsvg" }) }), new Promise((_, reject) => - setTimeout(() => reject(new Error("Export timeout")), 2000), + setTimeout(() => reject(new Error("Export timeout")), 3000), ), ]) - // Only save if diagram has meaningful content (not empty template) - if (currentXml && currentXml.length > 300) { - localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, currentXml) + // Update latestSvg so it's available for future saves + if (svgData?.includes(" Promise + deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }> + // forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes) + saveCurrentSession: ( + data: SessionData, + forSessionId?: string | null, + ) => Promise + refreshSessions: () => Promise + clearCurrentSession: () => void +} + +interface UseSessionManagerOptions { + /** Session ID from URL param - if provided, load this session; if null, start blank */ + initialSessionId?: string | null +} + +export function useSessionManager( + options: UseSessionManagerOptions = {}, +): UseSessionManagerReturn { + const { initialSessionId } = options + const [sessions, setSessions] = useState([]) + const [currentSessionId, setCurrentSessionId] = useState( + null, + ) + const [currentSession, setCurrentSession] = useState( + null, + ) + const [isLoading, setIsLoading] = useState(true) + const [isAvailable, setIsAvailable] = useState(false) + + const isInitializedRef = useRef(false) + // Sequence guard for URL changes - prevents out-of-order async resolution + const urlChangeSequenceRef = useRef(0) + + // Load sessions list + const refreshSessions = useCallback(async () => { + if (!isIndexedDBAvailable()) return + try { + const metadata = await getAllSessionMetadata() + setSessions(metadata) + } catch (error) { + console.error("Failed to refresh sessions:", error) + } + }, []) + + // Initialize on mount + useEffect(() => { + if (isInitializedRef.current) return + isInitializedRef.current = true + + async function init() { + setIsLoading(true) + + if (!isIndexedDBAvailable()) { + setIsAvailable(false) + setIsLoading(false) + return + } + + setIsAvailable(true) + + try { + // Run migration first (one-time conversion from localStorage) + await migrateFromLocalStorage() + + // Load sessions list + const metadata = await getAllSessionMetadata() + setSessions(metadata) + + // Only load a session if initialSessionId is provided (from URL param) + if (initialSessionId) { + const session = await getSession(initialSessionId) + if (session) { + setCurrentSession(session) + setCurrentSessionId(session.id) + } + // If session not found, stay in blank state (URL has invalid session ID) + } + // If no initialSessionId, start with blank state (no auto-restore) + } catch (error) { + console.error("Failed to initialize session manager:", error) + } finally { + setIsLoading(false) + } + } + + init() + }, [initialSessionId]) + + // Handle URL session ID changes after initialization + // Note: intentionally NOT including currentSessionId in deps to avoid race conditions + // when clearCurrentSession() is called before URL updates + useEffect(() => { + if (!isInitializedRef.current) return // Wait for initial load + if (!isAvailable) return + + // Increment sequence to invalidate any pending async operations + urlChangeSequenceRef.current++ + const currentSequence = urlChangeSequenceRef.current + + async function handleSessionIdChange() { + if (initialSessionId) { + // URL has session ID - load it + const session = await getSession(initialSessionId) + + // Check if this request is still the latest (sequence guard) + // If not, a newer URL change happened while we were loading + if (currentSequence !== urlChangeSequenceRef.current) { + return + } + + if (session) { + // Only update if the session is different from current + setCurrentSessionId((current) => { + if (current !== session.id) { + setCurrentSession(session) + return session.id + } + return current + }) + } + } + // Removed: else clause that clears session + // Clearing is now handled explicitly by clearCurrentSession() + // This prevents race conditions when URL update is async + } + + handleSessionIdChange() + }, [initialSessionId, isAvailable]) + + // Refresh sessions on window focus (multi-tab sync) + useEffect(() => { + const handleFocus = () => { + refreshSessions() + } + window.addEventListener("focus", handleFocus) + return () => window.removeEventListener("focus", handleFocus) + }, [refreshSessions]) + + // Switch to a different session + const switchSession = useCallback( + async (id: string): Promise => { + if (id === currentSessionId) return null + + // Save current session first if it has messages + if (currentSession && currentSession.messages.length > 0) { + await saveSession(currentSession) + } + + // Load the target session + const session = await getSession(id) + if (!session) { + console.error("Session not found:", id) + return null + } + + // Update state + setCurrentSession(session) + setCurrentSessionId(session.id) + + return { + messages: session.messages, + xmlSnapshots: session.xmlSnapshots, + diagramXml: session.diagramXml, + thumbnailDataUrl: session.thumbnailDataUrl, + diagramHistory: session.diagramHistory, + } + }, + [currentSessionId, currentSession], + ) + + // Delete a session + const deleteSession = useCallback( + async (id: string): Promise<{ wasCurrentSession: boolean }> => { + const wasCurrentSession = id === currentSessionId + await deleteSessionFromDB(id) + + // If deleting current session, clear state (caller will show new empty session) + if (wasCurrentSession) { + setCurrentSession(null) + setCurrentSessionId(null) + } + + await refreshSessions() + + return { wasCurrentSession } + }, + [currentSessionId, refreshSessions], + ) + + // Save current session data (debounced externally by caller) + // forSessionId: if provided, verify save targets correct session (prevents stale debounce writes) + const saveCurrentSession = useCallback( + async ( + data: SessionData, + forSessionId?: string | null, + ): Promise => { + // If forSessionId is provided, verify it matches current session + // This prevents stale debounced saves from overwriting a newly switched session + if ( + forSessionId !== undefined && + forSessionId !== currentSessionId + ) { + return + } + + if (!currentSession) { + // Create a new session if none exists + const newSession: ChatSession = { + ...createEmptySession(), + messages: data.messages, + xmlSnapshots: data.xmlSnapshots, + diagramXml: data.diagramXml, + thumbnailDataUrl: data.thumbnailDataUrl, + diagramHistory: data.diagramHistory, + title: extractTitle(data.messages), + } + await saveSession(newSession) + await enforceSessionLimit() + setCurrentSession(newSession) + setCurrentSessionId(newSession.id) + await refreshSessions() + return + } + + // Update existing session + const updatedSession: ChatSession = { + ...currentSession, + messages: data.messages, + xmlSnapshots: data.xmlSnapshots, + diagramXml: data.diagramXml, + thumbnailDataUrl: + data.thumbnailDataUrl ?? currentSession.thumbnailDataUrl, + diagramHistory: + data.diagramHistory ?? currentSession.diagramHistory, + updatedAt: Date.now(), + // Update title if it's still default and we have messages + title: + currentSession.title === "New Chat" && + data.messages.length > 0 + ? extractTitle(data.messages) + : currentSession.title, + } + + await saveSession(updatedSession) + setCurrentSession(updatedSession) + + // Update sessions list metadata + setSessions((prev) => + prev.map((s) => + s.id === updatedSession.id + ? { + ...s, + title: updatedSession.title, + updatedAt: updatedSession.updatedAt, + messageCount: updatedSession.messages.length, + hasDiagram: + !!updatedSession.diagramXml && + updatedSession.diagramXml.trim().length > 0, + thumbnailDataUrl: updatedSession.thumbnailDataUrl, + } + : s, + ), + ) + }, + [currentSession, currentSessionId, refreshSessions], + ) + + // Clear current session state (for starting fresh without loading another session) + const clearCurrentSession = useCallback(() => { + setCurrentSession(null) + setCurrentSessionId(null) + }, []) + + return { + sessions, + currentSessionId, + currentSession, + isLoading, + isAvailable, + switchSession, + deleteSession, + saveCurrentSession, + refreshSessions, + clearCurrentSession, + } +} diff --git a/lib/i18n/dictionaries/en.json b/lib/i18n/dictionaries/en.json index d3c70c9..8583de2 100644 --- a/lib/i18n/dictionaries/en.json +++ b/lib/i18n/dictionaries/en.json @@ -212,6 +212,22 @@ "contactMe": "Contact Me", "usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details." }, + "sessionHistory": { + "tooltip": "Chat History", + "newChat": "New Chat", + "empty": "No chat history yet", + "emptyHint": "Start a conversation to begin", + "today": "Today", + "yesterday": "Yesterday", + "thisWeek": "This Week", + "earlier": "Earlier", + "deleteTitle": "Delete this chat?", + "deleteDescription": "This will permanently delete this chat session and its diagram. This action cannot be undone.", + "recentChats": "Recent Chats", + "justNow": "Just now", + "searchPlaceholder": "Search chats...", + "noResults": "No chats found" + }, "modelConfig": { "title": "AI Model Configuration", "description": "Configure multiple AI providers and models", diff --git a/lib/i18n/dictionaries/ja.json b/lib/i18n/dictionaries/ja.json index c215835..a001fd5 100644 --- a/lib/i18n/dictionaries/ja.json +++ b/lib/i18n/dictionaries/ja.json @@ -212,6 +212,22 @@ "contactMe": "お問い合わせ", "usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。" }, + "sessionHistory": { + "tooltip": "チャット履歴", + "newChat": "新しいチャット", + "empty": "チャット履歴はまだありません", + "emptyHint": "会話を始めてください", + "today": "今日", + "yesterday": "昨日", + "thisWeek": "今週", + "earlier": "それ以前", + "deleteTitle": "このチャットを削除しますか?", + "deleteDescription": "このチャットセッションとダイアグラムは完全に削除されます。この操作は取り消せません。", + "recentChats": "最近のチャット", + "justNow": "たった今", + "searchPlaceholder": "チャットを検索...", + "noResults": "チャットが見つかりません" + }, "modelConfig": { "title": "AIモデル設定", "description": "複数のAIプロバイダーとモデルを設定", diff --git a/lib/i18n/dictionaries/zh.json b/lib/i18n/dictionaries/zh.json index da0aca7..1d66bb6 100644 --- a/lib/i18n/dictionaries/zh.json +++ b/lib/i18n/dictionaries/zh.json @@ -212,6 +212,22 @@ "contactMe": "联系我", "usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。" }, + "sessionHistory": { + "tooltip": "聊天历史", + "newChat": "新对话", + "empty": "暂无聊天记录", + "emptyHint": "开始对话吧", + "today": "今天", + "yesterday": "昨天", + "thisWeek": "本周", + "earlier": "更早", + "deleteTitle": "删除此对话?", + "deleteDescription": "这将永久删除此聊天会话及其图表。此操作无法撤消。", + "recentChats": "最近对话", + "justNow": "刚刚", + "searchPlaceholder": "搜索对话...", + "noResults": "未找到对话" + }, "modelConfig": { "title": "AI 模型配置", "description": "配置多个 AI 提供商和模型", diff --git a/lib/session-storage.ts b/lib/session-storage.ts new file mode 100644 index 0000000..08f77be --- /dev/null +++ b/lib/session-storage.ts @@ -0,0 +1,338 @@ +import { type DBSchema, type IDBPDatabase, openDB } from "idb" +import { nanoid } from "nanoid" + +// Constants +const DB_NAME = "next-ai-drawio" +const DB_VERSION = 1 +const STORE_NAME = "sessions" +const MIGRATION_FLAG = "next-ai-drawio-migrated-to-idb" +const MAX_SESSIONS = 50 + +// Types +export interface ChatSession { + id: string + title: string + createdAt: number + updatedAt: number + messages: StoredMessage[] + xmlSnapshots: [number, string][] + diagramXml: string + thumbnailDataUrl?: string // Small PNG preview of the diagram + diagramHistory?: { svg: string; xml: string }[] // Version history of diagram edits +} + +export interface StoredMessage { + id: string + role: "user" | "assistant" | "system" + parts: Array<{ type: string; [key: string]: unknown }> +} + +export interface SessionMetadata { + id: string + title: string + createdAt: number + updatedAt: number + messageCount: number + hasDiagram: boolean + thumbnailDataUrl?: string +} + +interface ChatSessionDB extends DBSchema { + sessions: { + key: string + value: ChatSession + indexes: { "by-updated": number } + } +} + +// Database singleton +let dbPromise: Promise> | null = null + +async function getDB(): Promise> { + if (!dbPromise) { + dbPromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db, oldVersion) { + if (oldVersion < 1) { + const store = db.createObjectStore(STORE_NAME, { + keyPath: "id", + }) + store.createIndex("by-updated", "updatedAt") + } + // Future migrations: if (oldVersion < 2) { ... } + }, + }) + } + return dbPromise +} + +// Check if IndexedDB is available +export function isIndexedDBAvailable(): boolean { + if (typeof window === "undefined") return false + try { + return "indexedDB" in window && window.indexedDB !== null + } catch { + return false + } +} + +// CRUD Operations +export async function getAllSessionMetadata(): Promise { + if (!isIndexedDBAvailable()) return [] + try { + const db = await getDB() + const tx = db.transaction(STORE_NAME, "readonly") + const index = tx.store.index("by-updated") + const metadata: SessionMetadata[] = [] + + // Use cursor to read only metadata fields (avoids loading full messages/XML) + let cursor = await index.openCursor(null, "prev") // newest first + while (cursor) { + const s = cursor.value + metadata.push({ + id: s.id, + title: s.title, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + messageCount: s.messages.length, + hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0, + thumbnailDataUrl: s.thumbnailDataUrl, + }) + cursor = await cursor.continue() + } + return metadata + } catch (error) { + console.error("Failed to get session metadata:", error) + return [] + } +} + +export async function getSession(id: string): Promise { + if (!isIndexedDBAvailable()) return null + try { + const db = await getDB() + return (await db.get(STORE_NAME, id)) || null + } catch (error) { + console.error("Failed to get session:", error) + return null + } +} + +export async function saveSession(session: ChatSession): Promise { + if (!isIndexedDBAvailable()) return false + try { + const db = await getDB() + await db.put(STORE_NAME, session) + return true + } catch (error) { + // Handle quota exceeded + if ( + error instanceof DOMException && + error.name === "QuotaExceededError" + ) { + console.warn("Storage quota exceeded, deleting oldest session...") + await deleteOldestSession() + // Retry once + try { + const db = await getDB() + await db.put(STORE_NAME, session) + return true + } catch (retryError) { + console.error( + "Failed to save session after cleanup:", + retryError, + ) + return false + } + } else { + console.error("Failed to save session:", error) + return false + } + } +} + +export async function deleteSession(id: string): Promise { + if (!isIndexedDBAvailable()) return + try { + const db = await getDB() + await db.delete(STORE_NAME, id) + } catch (error) { + console.error("Failed to delete session:", error) + } +} + +export async function getSessionCount(): Promise { + if (!isIndexedDBAvailable()) return 0 + try { + const db = await getDB() + return await db.count(STORE_NAME) + } catch (error) { + console.error("Failed to get session count:", error) + return 0 + } +} + +export async function deleteOldestSession(): Promise { + if (!isIndexedDBAvailable()) return + try { + const db = await getDB() + const tx = db.transaction(STORE_NAME, "readwrite") + const index = tx.store.index("by-updated") + const cursor = await index.openCursor() + if (cursor) { + await cursor.delete() + } + await tx.done + } catch (error) { + console.error("Failed to delete oldest session:", error) + } +} + +// Enforce max sessions limit +export async function enforceSessionLimit(): Promise { + const count = await getSessionCount() + if (count > MAX_SESSIONS) { + const toDelete = count - MAX_SESSIONS + for (let i = 0; i < toDelete; i++) { + await deleteOldestSession() + } + } +} + +// Helper: Create a new empty session +export function createEmptySession(): ChatSession { + return { + id: nanoid(), + title: "New Chat", + createdAt: Date.now(), + updatedAt: Date.now(), + messages: [], + xmlSnapshots: [], + diagramXml: "", + } +} + +// Helper: Extract title from first user message (truncated to reasonable length) +const MAX_TITLE_LENGTH = 100 + +export function extractTitle(messages: StoredMessage[]): string { + const firstUserMessage = messages.find((m) => m.role === "user") + if (!firstUserMessage) return "New Chat" + + const textPart = firstUserMessage.parts.find((p) => p.type === "text") + if (!textPart || typeof textPart.text !== "string") return "New Chat" + + const text = textPart.text.trim() + if (!text) return "New Chat" + + // Truncate long titles + if (text.length > MAX_TITLE_LENGTH) { + return text.slice(0, MAX_TITLE_LENGTH).trim() + "..." + } + return text +} + +// Helper: Sanitize UIMessage to StoredMessage +export function sanitizeMessage(message: unknown): StoredMessage | null { + if (!message || typeof message !== "object") return null + + const msg = message as Record + if (!msg.id || !msg.role) return null + + const role = msg.role as string + if (!["user", "assistant", "system"].includes(role)) return null + + // Extract parts, removing streaming state artifacts + let parts: Array<{ type: string; [key: string]: unknown }> = [] + if (Array.isArray(msg.parts)) { + parts = msg.parts.map((part: unknown) => { + if (!part || typeof part !== "object") return { type: "unknown" } + const p = part as Record + // Remove streaming-related fields + const { isStreaming, streamingState, ...cleanPart } = p + return cleanPart as { type: string; [key: string]: unknown } + }) + } + + return { + id: msg.id as string, + role: role as "user" | "assistant" | "system", + parts, + } +} + +export function sanitizeMessages(messages: unknown[]): StoredMessage[] { + return messages + .map(sanitizeMessage) + .filter((m): m is StoredMessage => m !== null) +} + +// Migration from localStorage +export async function migrateFromLocalStorage(): Promise { + if (typeof window === "undefined") return null + if (!isIndexedDBAvailable()) return null + + // Check if already migrated + if (localStorage.getItem(MIGRATION_FLAG)) return null + + try { + const savedMessages = localStorage.getItem("next-ai-draw-io-messages") + const savedSnapshots = localStorage.getItem( + "next-ai-draw-io-xml-snapshots", + ) + const savedXml = localStorage.getItem("next-ai-draw-io-diagram-xml") + + let newSessionId: string | null = null + let migrationSucceeded = false + + if (savedMessages) { + const messages = JSON.parse(savedMessages) + if (Array.isArray(messages) && messages.length > 0) { + const sanitized = sanitizeMessages(messages) + const session: ChatSession = { + id: nanoid(), + title: extractTitle(sanitized), + createdAt: Date.now(), + updatedAt: Date.now(), + messages: sanitized, + xmlSnapshots: savedSnapshots + ? JSON.parse(savedSnapshots) + : [], + diagramXml: savedXml || "", + } + const saved = await saveSession(session) + if (saved) { + // Verify the session was actually written + const verified = await getSession(session.id) + if (verified) { + newSessionId = session.id + migrationSucceeded = true + } + } + } else { + // Empty array or invalid data - nothing to migrate, mark as success + migrationSucceeded = true + } + } else { + // No data to migrate - mark as success + migrationSucceeded = true + } + + // Only clean up old data if migration succeeded + if (migrationSucceeded) { + localStorage.setItem(MIGRATION_FLAG, "true") + localStorage.removeItem("next-ai-draw-io-messages") + localStorage.removeItem("next-ai-draw-io-xml-snapshots") + localStorage.removeItem("next-ai-draw-io-diagram-xml") + } else { + console.warn( + "Migration to IndexedDB failed - keeping localStorage data for retry", + ) + } + + return newSessionId + } catch (error) { + console.error("Migration failed:", error) + // Don't mark as migrated - allow retry on next load + return null + } +} diff --git a/lib/storage.ts b/lib/storage.ts index 12e7ba8..f64d1dd 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,13 +1,7 @@ -// Centralized localStorage keys -// Consolidates all storage keys from chat-panel.tsx and settings-dialog.tsx +// Centralized localStorage keys for quota tracking and settings +// Chat data is now stored in IndexedDB via session-storage.ts export const STORAGE_KEYS = { - // Chat data - messages: "next-ai-draw-io-messages", - xmlSnapshots: "next-ai-draw-io-xml-snapshots", - diagramXml: "next-ai-draw-io-diagram-xml", - sessionId: "next-ai-draw-io-session-id", - // Quota tracking requestCount: "next-ai-draw-io-request-count", requestDate: "next-ai-draw-io-request-date", diff --git a/lib/utils.ts b/lib/utils.ts index 89ba67a..4c64399 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -6,6 +6,25 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } +// ============================================================================ +// Diagram Constants +// ============================================================================ + +/** + * Minimum length for a "real" diagram XML (not just empty template). + * Empty mxfile templates are ~147-300 chars; real diagrams are larger. + */ +export const MIN_REAL_DIAGRAM_LENGTH = 300 + +/** + * Check if diagram XML represents a real diagram (not just empty template). + * @param xml - The diagram XML string to check + * @returns true if the XML is a real diagram with content + */ +export function isRealDiagram(xml: string | undefined | null): boolean { + return !!xml && xml.length > MIN_REAL_DIAGRAM_LENGTH +} + // ============================================================================ // XML Validation/Fix Constants // ============================================================================ diff --git a/package-lock.json b/package-lock.json index e280d7e..c2ff5d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "idb": "^8.0.3", "js-tiktoken": "^1.0.21", "jsdom": "^27.0.0", "jsonrepair": "^3.13.1", @@ -4305,7 +4306,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -4933,7 +4933,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -4975,7 +4974,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -5136,7 +5134,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5646,6 +5643,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -5667,6 +5665,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -5683,6 +5682,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -5697,6 +5697,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -7422,7 +7423,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -8310,7 +8310,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -8344,7 +8343,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -8360,7 +8358,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.208.0.tgz", "integrity": "sha512-jbzDw1q+BkwKFq9yxhjAJ9rjKldbt5AgIy1gmEIJjEV/WRxQ3B6HcLVkwbjJ3RcMif86BDNKR846KJ0tY0aOJA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.208.0", @@ -8466,7 +8463,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -10867,7 +10863,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -10878,7 +10873,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -10964,7 +10958,6 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -11538,7 +11531,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -11605,7 +11597,6 @@ "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.6.tgz", "integrity": "sha512-LM0eAMWVn3RTj+0X5O1m/8g+7QiTeWG5aN5FsDbdmCkAQHVg93XxLbljFOLzi0NMjuJgf7fKLKmWoPsrdMyqfw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@ai-sdk/gateway": "3.0.5", "@ai-sdk/provider": "3.0.1", @@ -11625,7 +11616,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -12301,7 +12291,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -13257,7 +13246,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -13622,7 +13612,6 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -13971,6 +13960,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -13991,6 +13981,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -14390,7 +14381,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -14576,7 +14566,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -16272,7 +16261,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -16281,6 +16270,12 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", @@ -19563,7 +19558,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz", "integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "16.1.1", "@swc/helpers": "0.5.15", @@ -20474,6 +20468,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -20491,6 +20486,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -20739,7 +20735,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -22434,8 +22429,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -22501,6 +22495,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -22564,6 +22559,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -22578,6 +22574,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -22695,7 +22692,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -23028,7 +23024,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23101,7 +23096,6 @@ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -23668,7 +23662,6 @@ "integrity": "sha512-9MUUneP1BnRE9XAYi94FXxHmiLGbO75EHQZsgWqSiOXjoXSqJCw8aQbIEPxCy19TclEl/kHUFYce8ST2W+Qpjw==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -23688,7 +23681,6 @@ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.54.0.tgz", "integrity": "sha512-bANFsjDwJLbprYoBK+hUDZsVbUv2SqJd8QvArLIcZk+fPq4h/Ohtj5vkKXD3k0s2bD1DXLk08D+hYmeNH+xC6A==", "license": "MIT OR Apache-2.0", - "peer": true, "dependencies": { "@cloudflare/kv-asset-handler": "0.4.1", "@cloudflare/unenv-preset": "2.7.13", @@ -24514,7 +24506,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.4.tgz", "integrity": "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 1002304..1d7ee6b 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "idb": "^8.0.3", "js-tiktoken": "^1.0.21", "jsdom": "^27.0.0", "jsonrepair": "^3.13.1", @@ -89,9 +90,9 @@ "zod": "^4.1.12" }, "optionalDependencies": { + "@tailwindcss/oxide-linux-x64-gnu": "^4.1.18", "lightningcss": "^1.30.2", - "lightningcss-linux-x64-gnu": "^1.30.2", - "@tailwindcss/oxide-linux-x64-gnu": "^4.1.18" + "lightningcss-linux-x64-gnu": "^1.30.2" }, "lint-staged": { "*.{js,ts,jsx,tsx,json,css}": [