Browser Storage
Your Summary Memory chat is smart, but thereβs one problem - refresh the page and everything disappears! π±
Thatβs where localStorage comes in. Itβs like giving your chat app a memory that survives browser restarts. Youβll add a conversation sidebar, auto-save every message, and create a professional chat experience that feels like the apps you use every day.
Building on: This lesson enhances your existing Summary Memory implementation. Make sure you have Summary Memory working before starting here.
π― What Youβre Building Today
Section titled βπ― What Youβre Building TodayβYouβll transform your Summary Memory chat to include:
- β Auto-save conversations to browser storage after every message
- β Auto-restore chats when page loads, including summaries and settings
- β Conversation sidebar with saved conversation list
- β Simple conversation management (new, load, delete)
- β Fixed input position - stays at bottom, doesnβt scroll away
Why localStorage + Summary Memory works great:
// Perfect combination for learning and demosconst benefits = { setup: '15 minutes, zero backend changes', features: 'Smart summarization + persistent storage', privacy: 'Data never leaves user device', cost: 'Completely free'}
// The trade-offs you're makingconst limitations = { devices: 'Single device/browser only', storage: '5MB browser limit', sharing: 'No multi-user support'}
This combo gives you a professional chat experience perfect for demos, personal projects, and learning how persistence works!
πΎ Step 1: Add Simple Storage Helper Functions
Section titled βπΎ Step 1: Add Simple Storage Helper FunctionsβFirst, letβs add the localStorage functions to your Summary Memory component. These handle all the saving and loading logic.
Add these functions at the top of your src/App.jsx
file, right after your imports:
import { useState, useRef } from 'react'import { Send, Bot, User, Trash2, Plus, MessageSquare } from 'lucide-react'import ReactMarkdown from 'react-markdown'
// π LOCAL STORAGE: Simple storage functionsconst STORAGE_KEY = 'ai-conversations'
const saveConversation = (conversationId, messages, summary, conversationType) => { const conversations = getAllConversations()
const conversationData = { id: conversationId, messages: messages.filter(msg => !msg.isStreaming), summary, conversationType, title: generateTitle(messages), timestamp: new Date().toISOString(), messageCount: messages.length }
conversations[conversationId] = conversationData
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations)) console.log('πΎ Conversation saved to localStorage') } catch (error) { console.error('Failed to save:', error) }}
const getAllConversations = () => { try { const stored = localStorage.getItem(STORAGE_KEY) return stored ? JSON.parse(stored) : {} } catch (error) { console.error('Failed to load conversations:', error) return {} }}
const loadConversation = (conversationId) => { const conversations = getAllConversations() return conversations[conversationId] || null}
const deleteConversation = (conversationId) => { const conversations = getAllConversations() delete conversations[conversationId]
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations)) console.log('ποΈ Conversation deleted') } catch (error) { console.error('Failed to delete conversation:', error) }}
const generateTitle = (messages) => { if (!messages || messages.length === 0) return 'New Conversation'
const firstUserMessage = messages.find(msg => msg.isUser) if (firstUserMessage) { return firstUserMessage.text.length > 30 ? firstUserMessage.text.substring(0, 30) + '...' : firstUserMessage.text } return 'New Conversation'}
const generateId = () => { return Date.now().toString() + Math.random().toString(36).substr(2, 9)}
What these functions do:
- saveConversation - Stores conversation data to localStorage, including all messages and summary
- getAllConversations - Gets all saved conversations for the sidebar list
- loadConversation - Retrieves a specific conversation by ID
- deleteConversation - Removes a conversation from storage
- generateTitle - Creates a readable title from the first user message
- generateId - Makes unique IDs using timestamp + random string
π Step 2: Add Conversation Management State
Section titled βπ Step 2: Add Conversation Management StateβNow letβs add new state variables to track conversations and the sidebar. Add these lines right after your existing Summary Memory state:
function App() { // Your existing state (keep all of this) const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [isStreaming, setIsStreaming] = useState(false) const abortControllerRef = useRef(null)
// Summary Memory state (keep all of this) const [summary, setSummary] = useState(null) const [recentWindowSize, setRecentWindowSize] = useState(15) const [summaryThreshold, setSummaryThreshold] = useState(25) const [isCreatingSummary, setIsCreatingSummary] = useState(false) const [conversationType, setConversationType] = useState('general')
// π LOCAL STORAGE: Add conversation management state const [currentConversationId, setCurrentConversationId] = useState(null) const [conversations, setConversations] = useState({}) const [showSidebar, setShowSidebar] = useState(true)
What the new state does:
- currentConversationId - Tracks which conversation is currently active
- conversations - Stores all conversation data for the sidebar display
- showSidebar - Controls whether the sidebar is visible (you can collapse it)
π Step 3: Add Auto-Load and Auto-Save Logic
Section titled βπ Step 3: Add Auto-Load and Auto-Save LogicβAdd these useEffect hooks right after your state declarations. They handle loading conversations when the app starts and auto-saving when things change:
// Keep ALL your existing Summary Memory functions here// (buildConversationHistory, detectConversationType, createSummary, etc.)
// π LOCAL STORAGE: Load conversations on startupuseEffect(() => { const allConversations = getAllConversations() setConversations(allConversations)
// Load the most recent conversation if it exists const conversationIds = Object.keys(allConversations) if (conversationIds.length > 0) { const mostRecent = conversationIds .map(id => allConversations[id]) .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))[0]
if (mostRecent) { loadConversationById(mostRecent.id) } }}, [])
// π LOCAL STORAGE: Auto-save when conversation changesuseEffect(() => { if (currentConversationId && messages.length > 0) { saveConversation(currentConversationId, messages, summary, conversationType) // Refresh conversation list for sidebar setConversations(getAllConversations()) }}, [messages, summary, conversationType, currentConversationId])
What the auto-save logic does:
- First useEffect - Runs once when app loads, gets all saved conversations and loads the most recent one
- Second useEffect - Runs whenever messages, summary, or conversation type changes, automatically saves everything
- No manual saving needed - Everything happens in the background!
π οΈ Step 4: Add Conversation Management Functions
Section titled βπ οΈ Step 4: Add Conversation Management FunctionsβAdd these functions right after your existing Summary Memory helper functions:
// π LOCAL STORAGE: Conversation management functionsconst startNewConversation = () => { setCurrentConversationId(generateId()) setMessages([]) setSummary(null) setConversationType('general')}
const loadConversationById = (conversationId) => { const conversation = loadConversation(conversationId) if (conversation) { setCurrentConversationId(conversationId) setMessages(conversation.messages || []) setSummary(conversation.summary || null) setConversationType(conversation.conversationType || 'general') console.log(`β
Loaded conversation: ${conversation.title}`) }}
const deleteConversationById = (conversationId) => { deleteConversation(conversationId) setConversations(getAllConversations())
if (currentConversationId === conversationId) { startNewConversation() }}
What these functions do:
- startNewConversation - Creates a fresh conversation and clears all state
- loadConversationById - Loads a saved conversation and restores its complete state
- deleteConversationById - Removes a conversation and starts a new one if you were viewing the deleted one
π¨ Step 5: Fix the Layout Problem
Section titled βπ¨ Step 5: Fix the Layout ProblemβYour original layout had a problem - the input field scrolled away with long conversations! Letβs fix that so it stays put like professional chat apps.
The fix: Use a fixed-height layout where only the messages area scrolls.
Replace your entire return statement with this improved layout:
return ( <div className="h-screen bg-gray-100 flex">
{/* π LOCAL STORAGE: Conversation Sidebar */} {showSidebar && ( <div className="w-80 bg-white border-r border-gray-200 flex flex-col">
{/* Sidebar Header */} <div className="p-4 border-b border-gray-200"> <div className="flex items-center justify-between mb-3"> <h2 className="text-lg font-semibold text-gray-800">Conversations</h2> <button onClick={() => setShowSidebar(false)} className="text-gray-400 hover:text-gray-600" > β </button> </div> <button onClick={startNewConversation} className="w-full bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600 flex items-center justify-center space-x-2" > <Plus className="w-4 h-4" /> <span>New Chat</span> </button> </div>
{/* Conversation List */} <div className="flex-1 overflow-y-auto"> {Object.values(conversations) .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) .map(conversation => ( <div key={conversation.id} className={`p-3 border-b border-gray-100 cursor-pointer hover:bg-gray-50 ${ currentConversationId === conversation.id ? 'bg-blue-50 border-l-4 border-l-blue-500' : '' }`} onClick={() => loadConversationById(conversation.id)} > <div className="flex items-start justify-between"> <div className="flex-1 min-w-0"> <h3 className="text-sm font-medium text-gray-800 truncate"> {conversation.title} </h3> <div className="flex items-center space-x-2 mt-1"> <span className="text-xs text-gray-500"> {conversation.messageCount} messages </span> {conversation.summary && ( <span className="text-xs bg-green-100 text-green-600 px-2 py-0.5 rounded"> π Summary </span> )} <span className="text-xs bg-blue-100 text-blue-600 px-2 py-0.5 rounded"> {conversation.conversationType} </span> </div> <p className="text-xs text-gray-400 mt-1"> {new Date(conversation.timestamp).toLocaleDateString()} </p> </div> <button onClick={(e) => { e.stopPropagation() deleteConversationById(conversation.id) }} className="text-gray-400 hover:text-red-500 ml-2" > <Trash2 className="w-4 h-4" /> </button> </div> </div> )) }
{Object.keys(conversations).length === 0 && ( <div className="p-6 text-center text-gray-500"> <MessageSquare className="w-8 h-8 mx-auto mb-2 text-gray-300" /> <p className="text-sm">No conversations yet</p> <p className="text-xs">Start a new chat to begin</p> </div> )} </div> </div> )}
{/* Main Chat Area - Fixed Layout */} <div className="flex-1 flex flex-col min-h-0">
{/* Chat Header - Fixed at top */} <div className="bg-blue-500 text-white p-4 flex-shrink-0"> <div className="flex justify-between items-start"> <div className="flex items-center space-x-3"> {!showSidebar && ( <button onClick={() => setShowSidebar(true)} className="text-blue-100 hover:text-white" > <MessageSquare className="w-5 h-5" /> </button> )} <div> <h1 className="text-xl font-bold">Summary Memory + Local Storage</h1> <p className="text-blue-100 text-sm"> Smart conversation memory with persistent storage </p> </div> </div>
<div className="text-right space-y-2"> <div> <label className="block text-xs text-blue-100">Recent: {recentWindowSize}</label> <input type="range" min="5" max="30" value={recentWindowSize} onChange={(e) => setRecentWindowSize(parseInt(e.target.value))} className="w-20" disabled={isStreaming} /> </div> <div> <label className="block text-xs text-blue-100">Summary at: {summaryThreshold}</label> <input type="range" min="15" max="50" value={summaryThreshold} onChange={(e) => setSummaryThreshold(parseInt(e.target.value))} className="w-20" disabled={isStreaming} /> </div> </div> </div> </div>
{/* Memory Status Dashboard - Fixed below header */} <div className="bg-gray-50 px-4 py-3 border-b flex-shrink-0"> {(() => { const { totalMessages, recentMessages, summarizedMessages } = getMemoryStats();
return ( <div className="space-y-2"> <div className="flex justify-between items-center text-sm"> <div className="flex space-x-4 text-gray-600"> <span>π Total: {totalMessages}</span> <span>π₯ Recent: {recentMessages}</span> {summarizedMessages > 0 && ( <span>π Summarized: {summarizedMessages}</span> )} <span className="text-blue-600">π§ {conversationType}</span> </div>
<div className="flex items-center space-x-2 text-xs"> {summary && ( <span className="text-green-600">β
Summary Active</span> )} {isCreatingSummary && ( <span className="text-blue-600">π Creating Summary...</span> )} <span className="text-purple-600"> πΎ {Object.keys(conversations).length} saved </span> </div> </div>
<div className="w-full bg-gray-200 rounded-full h-2"> <div className="bg-blue-500 h-2 rounded-full transition-all duration-300" style={{ width: `${Math.min(100, (totalMessages / 50) * 100)}%` }} /> </div> </div> ); })()} </div>
{/* Active Summary Display - Fixed when present */} {summary && ( <div className="bg-blue-50 border-l-4 border-blue-400 p-3 mx-4 mt-2 rounded flex-shrink-0"> <div className="flex items-start"> <span className="text-blue-600 mr-2">π</span> <div className="flex-1"> <p className="text-xs font-medium text-blue-800 mb-1"> Active Summary ({conversationType}) β’ Auto-saved </p> <p className="text-xs text-blue-700 leading-relaxed"> {summary} </p> </div> </div> </div> )}
{/* Messages - ONLY this area scrolls */} <div className="flex-1 overflow-y-auto p-4 space-y-4 min-h-0"> {messages.length === 0 && ( <div className="text-center text-gray-500 mt-20"> <Bot className="w-12 h-12 mx-auto mb-4 text-gray-400" /> <p>Send a message to see Summary Memory + Local Storage in action!</p> {Object.keys(conversations).length > 0 && ( <p className="text-sm mt-2 text-blue-600"> πΎ Select a conversation from the sidebar or start a new one </p> )} </div> )}
{messages.map((message) => ( <div key={message.id} className={`flex items-start space-x-3 ${ message.isUser ? 'justify-end' : 'justify-start' }`} > {!message.isUser && ( <div className="bg-blue-500 p-2 rounded-full"> <Bot className="w-4 h-4 text-white" /> </div> )}
<div className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${ message.isUser ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-800' }`} > <MessageContent message={message} /> </div>
{message.isUser && ( <div className="bg-gray-500 p-2 rounded-full"> <User className="w-4 h-4 text-white" /> </div> )} </div> ))}
{/* Breathing room at bottom */} <div className="h-4"></div> </div>
{/* Input Area - Fixed at bottom */} <div className="border-t bg-white p-4 flex-shrink-0"> <div className="flex space-x-2"> <input type="text" value={input} onChange={(e) => setInput(e.target.value)} onKeyPress={handleKeyPress} placeholder="Type your message..." className="flex-1 border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" disabled={isStreaming} /> {isStreaming ? ( <button onClick={stopStreaming} className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-lg transition-colors" > Stop </button> ) : ( <button onClick={sendMessage} disabled={!input.trim()} className="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 text-white p-2 rounded-lg transition-colors" > <Send className="w-5 h-5" /> </button> )} </div> </div> </div> </div>)
Key layout improvements:
- h-screen - Uses full viewport height instead of growing content
- flex-shrink-0 - Keeps header, dashboard, and input fixed in place
- flex-1 overflow-y-auto - Makes only the messages area scrollable
- min-h-0 - Allows proper flex behavior for scrolling
π Step 6: Update Your Send Message Function
Section titled βπ Step 6: Update Your Send Message FunctionβFind your sendMessage
function and make one small but important change. After you create the user message, add logic to create a new conversation if none exists:
const sendMessage = async () => { if (!input.trim() || isStreaming) return
// π LOCAL STORAGE: Create new conversation if none exists if (!currentConversationId) { setCurrentConversationId(generateId()) }
const userMessage = { text: input, isUser: true, id: Date.now() } setMessages(prev => [...prev, userMessage])
// Rest of your sendMessage function stays exactly the same // (all the Summary Memory logic, streaming, error handling, etc.)
Why this change matters: The first time someone sends a message, thereβs no conversation ID yet. This creates one automatically so the auto-save logic works from the very first message.
π§ͺ Step 7: Test Your Complete Implementation
Section titled βπ§ͺ Step 7: Test Your Complete ImplementationβStart your servers:
Backend:
cd openai-backendnpm run dev
Frontend:
cd openai-frontendnpm run dev
Test the complete functionality with this flow:
Phase 1: Create and Save Conversations
Section titled βPhase 1: Create and Save Conversationsβ1. You: "Hi! My name is Alex and I'm a software engineer" AI: "Nice to meet you, Alex! Great to know you're in software engineering."
2. Continue until you have 10+ messages β’ Notice conversation appears in sidebar automatically β’ Watch memory stats update as you chat β’ Observe that input stays at bottom even in long conversations
3. Click "New Chat" button β’ Start fresh conversation about cooking β’ Watch both conversations appear in sidebar
Phase 2: Test Persistence
Section titled βPhase 2: Test Persistenceβ4. Refresh the page β’ All conversations should load in sidebar β’ Most recent conversation should auto-load β’ All summaries and settings should restore perfectly
5. Click different conversations in sidebar β’ Each loads with complete message history β’ Summaries and conversation types restore correctly β’ Layout stays professional with fixed input
Phase 3: Test Layout at Different Lengths
Section titled βPhase 3: Test Layout at Different Lengthsβ6. Create a conversation with 50+ messages β’ Scroll through the conversation β’ Input field should always stay visible at bottom β’ Only the message area should scroll β’ Sidebar should remain accessible
Success looks like:
- β Conversations auto-save after every message
- β Page refresh preserves all data perfectly
- β Sidebar shows conversation list with titles and metadata
- β Input field never scrolls away, stays fixed at bottom
- β All Summary Memory features continue working
- β Layout feels like a professional chat application
π§ Common Issues & Solutions
Section titled βπ§ Common Issues & Solutionsββ βConversations donβt save or sidebar is emptyβ
- β Check browser console for localStorage errors
- β
Verify
currentConversationId
gets set when sending first message - β Make sure the useEffect hooks are inside your component function
β βPage refresh doesnβt restore conversationβ
- β
Check that
getAllConversations()
returns data in browser console - β Verify the first useEffect runs on page load
- β Make sure youβre not clearing localStorage elsewhere
β βInput field scrolls away or layout looks brokenβ
- β
Verify youβre using
h-screen
instead ofmin-h-screen
on container - β
Check that messages area has
flex-1 overflow-y-auto min-h-0
- β
Ensure input area has
flex-shrink-0
class
β βSidebar doesnβt show conversation detailsβ
- β Check that conversations state is updating in the second useEffect
- β
Verify
generateTitle()
function creates proper titles - β Make sure conversation metadata (messageCount, timestamp) saves correctly
π‘ How the Complete System Works
Section titled βπ‘ How the Complete System WorksβStorage Structure
Section titled βStorage Structureβ// What localStorage contains:const conversations = { "conv_123": { id: "conv_123", title: "Hi! My name is Alex and I'm a...", messages: [...], // Complete message history summary: "User Alex is a software engineer...", // Summary Memory data conversationType: "technical", messageCount: 25, timestamp: "2024-01-15T10:30:00Z" }}
Layout Architecture
Section titled βLayout ArchitectureβContainer (h-screen)βββ Sidebar (w-80, flex flex-col)β βββ Header (flex-shrink-0)β βββ Conversation List (flex-1 overflow-y-auto)βββ Chat Area (flex-1 flex flex-col min-h-0) βββ Header (flex-shrink-0) βββ Memory Dashboard (flex-shrink-0) βββ Summary Display (flex-shrink-0) βββ Messages (flex-1 overflow-y-auto min-h-0) β ONLY SCROLLS βββ Input (flex-shrink-0) β ALWAYS VISIBLE
Auto-Save Flow
Section titled βAuto-Save Flowβ- User sends message β Messages state updates
- useEffect detects change β Calls
saveConversation()
- localStorage updated β Conversation data persisted
- Sidebar refreshes β Shows updated conversation list
π Step 8: Your Complete Updated App.jsx
Section titled βπ Step 8: Your Complete Updated App.jsxβHereβs your complete src/App.jsx
with all Local Storage + Summary Memory functionality integrated:
import { useState, useRef, useEffect } from 'react'import { Send, Bot, User, Trash2, Plus, MessageSquare } from 'lucide-react'import ReactMarkdown from 'react-markdown'
// π LOCAL STORAGE: Simple storage functionsconst STORAGE_KEY = 'ai-conversations'
const saveConversation = (conversationId, messages, summary, conversationType) => { const conversations = getAllConversations()
const conversationData = { id: conversationId, messages: messages.filter(msg => !msg.isStreaming), summary, conversationType, title: generateTitle(messages), timestamp: new Date().toISOString(), messageCount: messages.length }
conversations[conversationId] = conversationData
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations)) console.log('πΎ Conversation saved to localStorage') } catch (error) { console.error('Failed to save:', error) }}
const getAllConversations = () => { try { const stored = localStorage.getItem(STORAGE_KEY) return stored ? JSON.parse(stored) : {} } catch (error) { console.error('Failed to load conversations:', error) return {} }}
const loadConversation = (conversationId) => { const conversations = getAllConversations() return conversations[conversationId] || null}
const deleteConversation = (conversationId) => { const conversations = getAllConversations() delete conversations[conversationId]
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations)) console.log('ποΈ Conversation deleted') } catch (error) { console.error('Failed to delete conversation:', error) }}
const generateTitle = (messages) => { if (!messages || messages.length === 0) return 'New Conversation'
const firstUserMessage = messages.find(msg => msg.isUser) if (firstUserMessage) { return firstUserMessage.text.length > 30 ? firstUserMessage.text.substring(0, 30) + '...' : firstUserMessage.text } return 'New Conversation'}
const generateId = () => { return Date.now().toString() + Math.random().toString(36).substr(2, 9)}
// Component: Handles message content with markdown formattingfunction MessageContent({ message }) { if (message.isUser) { return ( <p className="text-sm leading-relaxed whitespace-pre-wrap"> {message.text} {message.isStreaming && ( <span className="inline-block w-2 h-4 bg-blue-500 ml-1 animate-pulse" /> )} </p> ) }
return ( <div className="text-sm leading-relaxed"> <ReactMarkdown components={{ h1: ({children}) => <h1 className="text-lg font-bold mb-2 text-slate-800">{children}</h1>, h2: ({children}) => <h2 className="text-base font-bold mb-2 text-slate-800">{children}</h2>, h3: ({children}) => <h3 className="text-sm font-bold mb-1 text-slate-800">{children}</h3>, p: ({children}) => <p className="mb-2 last:mb-0 text-slate-700">{children}</p>, ul: ({children}) => <ul className="list-disc list-inside mb-2 space-y-1">{children}</ul>, ol: ({children}) => <ol className="list-decimal list-inside mb-2 space-y-1">{children}</ol>, li: ({children}) => <li className="text-slate-700">{children}</li>, code: ({inline, children}) => { const copyToClipboard = (text) => { navigator.clipboard.writeText(text) }
if (inline) { return ( <code className="bg-slate-100 text-red-600 px-1.5 py-0.5 rounded text-xs font-mono border"> {children} </code> ) }
return ( <div className="relative group mb-2"> <code className="block bg-gray-900 text-green-400 p-4 rounded-lg text-xs font-mono overflow-x-auto whitespace-pre border-l-4 border-blue-400 shadow-sm"> {children} </code> <button onClick={() => copyToClipboard(children)} className="absolute top-2 right-2 bg-slate-600 hover:bg-slate-500 text-white px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity" > Copy </button> </div> ) }, pre: ({children}) => <div className="mb-2">{children}</div>, strong: ({children}) => <strong className="font-semibold text-slate-800">{children}</strong>, em: ({children}) => <em className="italic text-slate-700">{children}</em>, blockquote: ({children}) => ( <blockquote className="border-l-4 border-blue-200 pl-4 italic text-slate-600 mb-2"> {children} </blockquote> ), a: ({href, children}) => ( <a href={href} className="text-blue-600 hover:text-blue-800 underline" target="_blank" rel="noopener noreferrer"> {children} </a> ), }} > {message.text} </ReactMarkdown> {message.isStreaming && ( <span className="inline-block w-2 h-4 bg-blue-500 ml-1 animate-pulse" /> )} </div> )}
function App() { // State management const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [isStreaming, setIsStreaming] = useState(false) const abortControllerRef = useRef(null)
// Summary Memory state const [summary, setSummary] = useState(null) const [recentWindowSize, setRecentWindowSize] = useState(15) const [summaryThreshold, setSummaryThreshold] = useState(25) const [isCreatingSummary, setIsCreatingSummary] = useState(false) const [conversationType, setConversationType] = useState('general')
// π LOCAL STORAGE: Conversation management state const [currentConversationId, setCurrentConversationId] = useState(null) const [conversations, setConversations] = useState({}) const [showSidebar, setShowSidebar] = useState(true)
// MEMORY: Function to build conversation history const buildConversationHistory = (messages) => { return messages .filter(msg => !msg.isStreaming) .map(msg => ({ role: msg.isUser ? "user" : "assistant", content: msg.text })); };
// SUMMARY MEMORY: Detect conversation type automatically const detectConversationType = (messages) => { const recentText = messages.slice(-10).map(m => m.text).join(' ').toLowerCase();
if (recentText.includes('function') || recentText.includes('code') || recentText.includes('api')) { return 'technical'; } else if (recentText.includes('create') || recentText.includes('idea') || recentText.includes('design')) { return 'creative'; } else if (recentText.includes('problem') || recentText.includes('error') || recentText.includes('help')) { return 'support'; } return 'general'; };
// SUMMARY MEMORY: Create summary with intelligent timing const createSummary = async (messagesToSummarize) => { if (isCreatingSummary) return;
try { setIsCreatingSummary(true);
const detectedType = detectConversationType(messages);
const response = await fetch('http://localhost:8000/api/summarize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: messagesToSummarize, conversationType: detectedType }), });
const data = await response.json();
if (data.success) { setSummary(data.summary); setConversationType(data.conversationType); console.log(`π Summary created: ${data.messagesCount} messages summarized as ${data.conversationType}`); } } catch (error) { console.error("Failed to create summary:", error); } finally { setIsCreatingSummary(false); } };
// SUMMARY MEMORY: Smart summary triggers const shouldCreateSummary = (conversationHistory) => { return conversationHistory.length >= summaryThreshold && !summary; };
const shouldUpdateSummary = (conversationHistory) => { return conversationHistory.length >= summaryThreshold * 2 && summary; };
const isGoodTimeToSummarize = (conversationHistory) => { const recentMessages = conversationHistory.slice(-3);
const hasCodeDiscussion = recentMessages.some(msg => msg.content.includes('```') || msg.content.includes('function'));
const hasFollowUp = recentMessages.some(msg => msg.content.toLowerCase().includes('can you explain') || msg.content.toLowerCase().includes('tell me more') || msg.content.toLowerCase().includes('what about'));
return !hasCodeDiscussion && !hasFollowUp; };
// SUMMARY MEMORY: Calculate memory statistics const getMemoryStats = () => { const totalMessages = messages.filter(msg => !msg.isStreaming).length const recentMessages = Math.min(totalMessages, recentWindowSize) const summarizedMessages = Math.max(0, totalMessages - recentWindowSize)
return { totalMessages, recentMessages, summarizedMessages } };
// π LOCAL STORAGE: Load conversations on startup useEffect(() => { const allConversations = getAllConversations() setConversations(allConversations)
// Load the most recent conversation if it exists const conversationIds = Object.keys(allConversations) if (conversationIds.length > 0) { const mostRecent = conversationIds .map(id => allConversations[id]) .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))[0]
if (mostRecent) { loadConversationById(mostRecent.id) } } }, [])
// π LOCAL STORAGE: Auto-save when conversation changes useEffect(() => { if (currentConversationId && messages.length > 0) { saveConversation(currentConversationId, messages, summary, conversationType) // Refresh conversation list for sidebar setConversations(getAllConversations()) } }, [messages, summary, conversationType, currentConversationId])
// π LOCAL STORAGE: Conversation management functions const startNewConversation = () => { setCurrentConversationId(generateId()) setMessages([]) setSummary(null) setConversationType('general') }
const loadConversationById = (conversationId) => { const conversation = loadConversation(conversationId) if (conversation) { setCurrentConversationId(conversationId) setMessages(conversation.messages || []) setSummary(conversation.summary || null) setConversationType(conversation.conversationType || 'general') console.log(`β
Loaded conversation: ${conversation.title}`) } }
const deleteConversationById = (conversationId) => { deleteConversation(conversationId) setConversations(getAllConversations())
if (currentConversationId === conversationId) { startNewConversation() } }
// Helper functions const createAiPlaceholder = () => { const aiMessageId = Date.now() + 1 const aiMessage = { text: "", isUser: false, id: aiMessageId, isStreaming: true, } setMessages(prev => [...prev, aiMessage]) return aiMessageId }
const readStream = async (response, aiMessageId) => { const reader = response.body.getReader() const decoder = new TextDecoder()
while (true) { const { done, value } = await reader.read() if (done) break
const chunk = decoder.decode(value, { stream: true })
setMessages(prev => prev.map(msg => msg.id === aiMessageId ? { ...msg, text: msg.text + chunk } : msg ) ) } }
const sendMessage = async () => { if (!input.trim() || isStreaming) return
// π LOCAL STORAGE: Create new conversation if none exists if (!currentConversationId) { setCurrentConversationId(generateId()) }
const userMessage = { text: input.trim(), isUser: true, id: Date.now() } setMessages(prev => [...prev, userMessage])
const currentInput = input setInput('') setIsStreaming(true) const aiMessageId = createAiPlaceholder()
try { const conversationHistory = buildConversationHistory(messages)
// SUMMARY MEMORY: Smart summary timing - happens in background if (shouldCreateSummary(conversationHistory) && isGoodTimeToSummarize(conversationHistory)) { const messagesToSummarize = conversationHistory.slice(0, -recentWindowSize); createSummary(messagesToSummarize); } else if (shouldUpdateSummary(conversationHistory) && isGoodTimeToSummarize(conversationHistory)) { const messagesToSummarize = conversationHistory.slice(0, -recentWindowSize); createSummary(messagesToSummarize); }
abortControllerRef.current = new AbortController()
const response = await fetch('http://localhost:8000/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: currentInput, conversationHistory: conversationHistory, summary: summary, recentWindowSize: recentWindowSize }), signal: abortControllerRef.current.signal, })
if (!response.ok) throw new Error('Failed to get response')
await readStream(response, aiMessageId)
setMessages(prev => prev.map(msg => msg.id === aiMessageId ? { ...msg, isStreaming: false } : msg ) )
} catch (error) { if (error.name !== 'AbortError') { console.error('Streaming error:', error) setMessages(prev => prev.map(msg => msg.id === aiMessageId ? { ...msg, text: 'Sorry, something went wrong.', isStreaming: false } : msg ) ) } } finally { setIsStreaming(false) abortControllerRef.current = null } }
const stopStreaming = () => { if (abortControllerRef.current) { abortControllerRef.current.abort() } }
const handleKeyPress = (e) => { if (e.key === 'Enter' && !e.shiftKey && !isStreaming) { e.preventDefault() sendMessage() } }
return ( <div className="h-screen bg-gray-100 flex">
{/* π LOCAL STORAGE: Conversation Sidebar */} {showSidebar && ( <div className="w-80 bg-white border-r border-gray-200 flex flex-col">
{/* Sidebar Header */} <div className="p-4 border-b border-gray-200"> <div className="flex items-center justify-between mb-3"> <h2 className="text-lg font-semibold text-gray-800">Conversations</h2> <button onClick={() => setShowSidebar(false)} className="text-gray-400 hover:text-gray-600" > β </button> </div> <button onClick={startNewConversation} className="w-full bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600 flex items-center justify-center space-x-2" > <Plus className="w-4 h-4" /> <span>New Chat</span> </button> </div>
{/* Conversation List */} <div className="flex-1 overflow-y-auto"> {Object.values(conversations) .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) .map(conversation => ( <div key={conversation.id} className={`p-3 border-b border-gray-100 cursor-pointer hover:bg-gray-50 ${ currentConversationId === conversation.id ? 'bg-blue-50 border-l-4 border-l-blue-500' : '' }`} onClick={() => loadConversationById(conversation.id)} > <div className="flex items-start justify-between"> <div className="flex-1 min-w-0"> <h3 className="text-sm font-medium text-gray-800 truncate"> {conversation.title} </h3> <div className="flex items-center space-x-2 mt-1"> <span className="text-xs text-gray-500"> {conversation.messageCount} messages </span> {conversation.summary && ( <span className="text-xs bg-green-100 text-green-600 px-2 py-0.5 rounded"> π Summary </span> )} <span className="text-xs bg-blue-100 text-blue-600 px-2 py-0.5 rounded"> {conversation.conversationType} </span> </div> <p className="text-xs text-gray-400 mt-1"> {new Date(conversation.timestamp).toLocaleDateString()} </p> </div> <button onClick={(e) => { e.stopPropagation() deleteConversationById(conversation.id) }} className="text-gray-400 hover:text-red-500 ml-2" > <Trash2 className="w-4 h-4" /> </button> </div> </div> )) }
{Object.keys(conversations).length === 0 && ( <div className="p-6 text-center text-gray-500"> <MessageSquare className="w-8 h-8 mx-auto mb-2 text-gray-300" /> <p className="text-sm">No conversations yet</p> <p className="text-xs">Start a new chat to begin</p> </div> )} </div> </div> )}
{/* Main Chat Area - Fixed Layout */} <div className="flex-1 flex flex-col min-h-0">
{/* Chat Header - Fixed at top */} <div className="bg-blue-500 text-white p-4 flex-shrink-0"> <div className="flex justify-between items-start"> <div className="flex items-center space-x-3"> {!showSidebar && ( <button onClick={() => setShowSidebar(true)} className="text-blue-100 hover:text-white" > <MessageSquare className="w-5 h-5" /> </button> )} <div> <h1 className="text-xl font-bold">Summary Memory + Local Storage</h1> <p className="text-blue-100 text-sm"> Smart conversation memory with persistent storage </p> </div> </div>
<div className="text-right space-y-2"> <div> <label className="block text-xs text-blue-100">Recent: {recentWindowSize}</label> <input type="range" min="5" max="30" value={recentWindowSize} onChange={(e) => setRecentWindowSize(parseInt(e.target.value))} className="w-20" disabled={isStreaming} /> </div> <div> <label className="block text-xs text-blue-100">Summary at: {summaryThreshold}</label> <input type="range" min="15" max="50" value={summaryThreshold} onChange={(e) => setSummaryThreshold(parseInt(e.target.value))} className="w-20" disabled={isStreaming} /> </div> </div> </div> </div>
{/* Memory Status Dashboard - Fixed below header */} <div className="bg-gray-50 px-4 py-3 border-b flex-shrink-0"> {(() => { const { totalMessages, recentMessages, summarizedMessages } = getMemoryStats();
return ( <div className="space-y-2"> <div className="flex justify-between items-center text-sm"> <div className="flex space-x-4 text-gray-600"> <span>π Total: {totalMessages}</span> <span>π₯ Recent: {recentMessages}</span> {summarizedMessages > 0 && ( <span>π Summarized: {summarizedMessages}</span> )} <span className="text-blue-600">π§ {conversationType}</span> </div>
<div className="flex items-center space-x-2 text-xs"> {summary && ( <span className="text-green-600">β
Summary Active</span> )} {isCreatingSummary && ( <span className="text-blue-600">π Creating Summary...</span> )} <span className="text-purple-600"> πΎ {Object.keys(conversations).length} saved </span> </div> </div>
<div className="w-full bg-gray-200 rounded-full h-2"> <div className="bg-blue-500 h-2 rounded-full transition-all duration-300" style={{ width: `${Math.min(100, (totalMessages / 50) * 100)}%` }} /> </div> </div> ); })()} </div>
{/* Active Summary Display - Fixed when present */} {summary && ( <div className="bg-blue-50 border-l-4 border-blue-400 p-3 mx-4 mt-2 rounded flex-shrink-0"> <div className="flex items-start"> <span className="text-blue-600 mr-2">π</span> <div className="flex-1"> <p className="text-xs font-medium text-blue-800 mb-1"> Active Summary ({conversationType}) β’ Auto-saved </p> <p className="text-xs text-blue-700 leading-relaxed"> {summary} </p> </div> </div> </div> )}
{/* Messages - ONLY this area scrolls */} <div className="flex-1 overflow-y-auto p-4 space-y-4 min-h-0"> {messages.length === 0 && ( <div className="text-center text-gray-500 mt-20"> <Bot className="w-12 h-12 mx-auto mb-4 text-gray-400" /> <p>Send a message to see Summary Memory + Local Storage in action!</p> {Object.keys(conversations).length > 0 && ( <p className="text-sm mt-2 text-blue-600"> πΎ Select a conversation from the sidebar or start a new one </p> )} </div> )}
{messages.map((message) => ( <div key={message.id} className={`flex items-start space-x-3 ${ message.isUser ? 'justify-end' : 'justify-start' }`} > {!message.isUser && ( <div className="bg-blue-500 p-2 rounded-full"> <Bot className="w-4 h-4 text-white" /> </div> )}
<div className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${ message.isUser ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-800' }`} > <MessageContent message={message} /> </div>
{message.isUser && ( <div className="bg-gray-500 p-2 rounded-full"> <User className="w-4 h-4 text-white" /> </div> )} </div> ))}
{/* Breathing room at bottom */} <div className="h-4"></div> </div>
{/* Input Area - Fixed at bottom */} <div className="border-t bg-white p-4 flex-shrink-0"> <div className="flex space-x-2"> <input type="text" value={input} onChange={(e) => setInput(e.target.value)} onKeyPress={handleKeyPress} placeholder="Type your message..." className="flex-1 border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" disabled={isStreaming} /> {isStreaming ? ( <button onClick={stopStreaming} className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-lg transition-colors" > Stop </button> ) : ( <button onClick={sendMessage} disabled={!input.trim()} className="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 text-white p-2 rounded-lg transition-colors" > <Send className="w-5 h-5" /> </button> )} </div> </div> </div> </div> )}
export default App
What this complete component includes:
- β All Summary Memory features - Intelligent summarization, cost optimization, background processing
- β Complete localStorage integration - Auto-save, auto-restore, conversation management
- β Professional chat layout - Fixed input, scrollable messages, collapsible sidebar
- β Rich markdown support - Code blocks with copy buttons, formatting, links
- β Visual feedback - Memory stats, summary indicators, conversation metadata
- β User experience - Hover states, loading animations, error handling
β¨ Lesson Recap
Section titled ββ¨ Lesson RecapβIncredible work! π Youβve built a production-quality chat application that combines the best of modern AI memory management with persistent storage.
What youβve accomplished:
- πΎ Persistent conversations that survive browser restarts
- π§ Smart memory management with Summary Memory integration
- π± Professional layout with fixed input and smooth scrolling
- ποΈ Conversation management with sidebar, titles, and metadata
- β‘ Zero backend changes needed - pure frontend enhancement
You now understand:
- π State persistence using browser localStorage APIs
- π¨ Modern chat UX with fixed layouts and professional design
- π§© Complex state management coordinating multiple features seamlessly
- ποΈ Progressive enhancement building on existing functionality
Your chat app now provides the complete experience users expect from modern AI applications - intelligent memory management, persistent storage, and a polished interface that rivals professional tools!
π Next: Database Storage Implementation - Ready to scale beyond localStorage? Learn how to add user accounts and cloud storage for multi-device, multi-user support!