diff --git a/components/Chat/ChatLayout.tsx b/components/Chat/ChatLayout.tsx index 8a27fa5..1554c45 100644 --- a/components/Chat/ChatLayout.tsx +++ b/components/Chat/ChatLayout.tsx @@ -33,6 +33,14 @@ import { useDisclosure } from '@mantine/hooks'; import { getInstalledModels, type OllamaModel } from '@/app/actions/ollama'; import { useThemeContext } from '@/components/DynamicThemeProvider'; import { SettingsModal } from '@/components/Settings/SettingsModal'; +import { + addMessageToLocalChat, + getLocalChat, + getLocalChats, + mergeChats, + saveLocalChat, + setLocalChats, +} from '@/lib/chatStorage'; import { MarkdownMessage } from './MarkdownMessage'; import classes from './ChatLayout.module.css'; @@ -155,22 +163,28 @@ export default function ChatLayout() { }; const fetchChats = async () => { + // Load from localStorage first for instant display + const localChats = getLocalChats(); + if (localChats.length > 0) { + setChats(localChats); + } + + // Then fetch from database and merge setIsLoadingChats(true); try { const res = await fetch('/api/chats'); if (res.ok) { - const data = await res.json(); - if (Array.isArray(data)) { - setChats(data); - } else { - setChats([]); + const remoteChats = await res.json(); + if (Array.isArray(remoteChats)) { + // Merge local and remote, update both state and localStorage + const merged = mergeChats(localChats, remoteChats); + setChats(merged); + setLocalChats(merged); } - } else { - setChats([]); } } catch (e) { - console.error('Failed to fetch chats', e); - setChats([]); + console.error('Failed to fetch chats from server:', e); + // Keep using local chats if server fails } finally { setIsLoadingChats(false); } @@ -178,14 +192,27 @@ export default function ChatLayout() { const handleSelectChat = (chat: Chat) => { setActiveChatId(chat.id); - if (chat.messages) { + + // Try to load from localStorage first (faster), fall back to passed chat data + const localChat = getLocalChat(chat.id); + if (localChat?.messages && localChat.messages.length > 0) { + setMessages(localChat.messages); + } else if (chat.messages) { setMessages(chat.messages); } else { setMessages([]); } + if (mobileOpened) { toggleMobile(); } + // Scroll to bottom after messages load + setTimeout(() => { + const viewport = scrollViewportRef.current; + if (viewport) { + viewport.scrollTop = viewport.scrollHeight; + } + }, 0); }; const handleNewChat = () => { @@ -274,9 +301,9 @@ export default function ChatLayout() { content: fullContent, }; - // Save both user message and assistant response to database + // Save to both localStorage and database try { - // Save user message + // Save user message to database const userSaveRes = await fetch('/api/chats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -287,13 +314,14 @@ export default function ChatLayout() { }); const userSaveData = await userSaveRes.json(); const savedChatId = userSaveData.chatId; + const chatTitle = userSaveData.title || userMessage.content.slice(0, 50); // Update activeChatId if this was a new chat if (!activeChatId && savedChatId) { setActiveChatId(savedChatId); } - // Save assistant response + // Save assistant response to database await fetch('/api/chats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -303,6 +331,15 @@ export default function ChatLayout() { }), }); + // Save to localStorage + const finalMessages = [...newMessages, responseMessage]; + saveLocalChat({ + id: savedChatId, + title: chatTitle, + updatedAt: new Date().toISOString(), + messages: finalMessages, + }); + // Refresh chat list fetchChats(); } catch (saveError) { diff --git a/lib/chatStorage.ts b/lib/chatStorage.ts new file mode 100644 index 0000000..cc894da --- /dev/null +++ b/lib/chatStorage.ts @@ -0,0 +1,135 @@ +/** + * Local storage utilities for chat persistence + * Provides fast local access while syncing with the database + */ + +interface Message { + id: string; + role: 'user' | 'assistant'; + content: string; +} + +interface Chat { + id: string; + title: string; + updatedAt: string; + messages: Message[]; +} + +const CHATS_STORAGE_KEY = 'chat-gpz-chats'; + +/** + * Get all chats from localStorage + */ +export function getLocalChats(): Chat[] { + if (typeof window === 'undefined') { + return []; + } + try { + const stored = localStorage.getItem(CHATS_STORAGE_KEY); + if (!stored) { + return []; + } + return JSON.parse(stored) as Chat[]; + } catch (e) { + console.error('Failed to parse local chats:', e); + return []; + } +} + +/** + * Save all chats to localStorage + */ +export function setLocalChats(chats: Chat[]): void { + if (typeof window === 'undefined') { + return; + } + try { + localStorage.setItem(CHATS_STORAGE_KEY, JSON.stringify(chats)); + } catch (e) { + console.error('Failed to save local chats:', e); + } +} + +/** + * Get a single chat by ID from localStorage + */ +export function getLocalChat(chatId: string): Chat | null { + const chats = getLocalChats(); + return chats.find((c) => c.id === chatId) || null; +} + +/** + * Save or update a single chat in localStorage + */ +export function saveLocalChat(chat: Chat): void { + const chats = getLocalChats(); + const existingIndex = chats.findIndex((c) => c.id === chat.id); + + if (existingIndex >= 0) { + chats[existingIndex] = chat; + } else { + chats.unshift(chat); // Add to beginning (most recent) + } + + setLocalChats(chats); +} + +/** + * Add a message to an existing chat in localStorage + */ +export function addMessageToLocalChat(chatId: string, message: Message): void { + const chats = getLocalChats(); + const chat = chats.find((c) => c.id === chatId); + + if (chat) { + chat.messages.push(message); + chat.updatedAt = new Date().toISOString(); + setLocalChats(chats); + } +} + +/** + * Delete a chat from localStorage + */ +export function deleteLocalChat(chatId: string): void { + const chats = getLocalChats(); + const filtered = chats.filter((c) => c.id !== chatId); + setLocalChats(filtered); +} + +/** + * Merge remote chats with local chats + * Remote chats take precedence for conflicts (based on updatedAt) + */ +export function mergeChats(localChats: Chat[], remoteChats: Chat[]): Chat[] { + const chatMap = new Map(); + + // Add local chats first + for (const chat of localChats) { + chatMap.set(chat.id, chat); + } + + // Override with remote chats (they're the source of truth) + for (const chat of remoteChats) { + const local = chatMap.get(chat.id); + if (!local || new Date(chat.updatedAt) >= new Date(local.updatedAt)) { + chatMap.set(chat.id, chat); + } + } + + // Sort by updatedAt descending + return Array.from(chatMap.values()).sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); +} + +/** + * Clear all local chats (useful for logout) + */ +export function clearLocalChats(): void { + if (typeof window === 'undefined') { + return; + } + localStorage.removeItem(CHATS_STORAGE_KEY); +}