import { useState, useEffect, useLayoutEffect, useRef } from 'react' import Header from './components/Header' import Sidebar from './components/Sidebar' import MessageList from './components/MessageList' import ChatInput from './components/ChatInput' import { SearchModal, ImageViewer, SettingsModal } from './components/Modals' import { Menu, MoreHorizontal } from 'lucide-react' import './App.css' import GuideTour from './components/GuideTour' const pochiAsset = '/assets/pochi.jpeg' const hnamAsset = '/assets/hnam.jpeg' const defaultAvatar = hnamAsset // Set Hnam as default for Guest as requested const API_BASE = '/api' function App() { // --- State Management --- const [darkMode, setDarkMode] = useState(() => { if (typeof window !== 'undefined') { const saved = localStorage.getItem('darkMode') if (saved !== null) return saved === 'true' return window.matchMedia('(prefers-color-scheme: dark)').matches } return false }) const [conversations, setConversations] = useState([]) const [currentConversation, setCurrentConversation] = useState(() => { const lastId = localStorage.getItem('lastConversationId') return (lastId === 'null' || !lastId) ? null : lastId }) const [messages, setMessages] = useState([]) const [isLoading, setIsLoading] = useState(false) const [isInitializing, setIsInitializing] = useState(true) // Prevents flash on refresh // User Profile State const [userProfile, setUserProfile] = useState(() => { const saved = localStorage.getItem('user_profile') if (saved) { try { const profile = JSON.parse(saved) // Migrate old static paths or broken hashed paths to new stable assets if (profile.avatar && typeof profile.avatar === 'string') { if (profile.avatar.includes('pochi') && profile.avatar !== pochiAsset) { profile.avatar = pochiAsset localStorage.setItem('user_profile', JSON.stringify(profile)) } else if (profile.avatar.includes('hnam') && profile.avatar !== hnamAsset) { // Only update if it's an old asset path (not a base64 string) if (!profile.avatar.startsWith('data:')) { profile.avatar = hnamAsset localStorage.setItem('user_profile', JSON.stringify(profile)) } } } return profile } catch (e) { console.error('Failed to parse user_profile', e) } } return { name: 'Guest', email: 'guest@example.com', avatar: hnamAsset } }) const handleUpdateProfile = (newProfile) => { const updated = { ...userProfile, ...newProfile } setUserProfile(updated) localStorage.setItem('user_profile', JSON.stringify(updated)) } // UI State const [sidebarOpen, setSidebarOpen] = useState(() => { const saved = localStorage.getItem('sidebarOpen') return saved !== null ? saved === 'true' : true }) const [showSearch, setShowSearch] = useState(() => { return sessionStorage.getItem('showSearch') === 'true' }) const [tourVersion, setTourVersion] = useState(0) const [showTour, setShowTour] = useState(() => { const hasSeenTour = localStorage.getItem('hasSeenTour') const savedIndex = localStorage.getItem('tourStepIndex') // Auto-show if never seen or if interrupted (index exists and is not -1) return !hasSeenTour || (savedIndex !== null && savedIndex !== '-1') }) const [showSettings, setShowSettings] = useState(() => { return sessionStorage.getItem('showSettings') === 'true' }) const [settingsTab, setSettingsTab] = useState(() => { return localStorage.getItem('settingsTab') || 'general' }) const [viewerData, setViewerData] = useState(() => { try { const saved = sessionStorage.getItem('viewerData') return saved ? JSON.parse(saved) : null } catch (e) { console.error('Failed to parse viewerData:', e) return null } }) // { images: [], index: 0 } const [memoryStatus, setMemoryStatus] = useState(null) // For future memory blocking features const [showPinLimitToast, setShowPinLimitToast] = useState(false) const [isMobile, setIsMobile] = useState(window.innerWidth < 768) const [showRenameModal, setShowRenameModal] = useState(() => { return sessionStorage.getItem('showRenameModal') === 'true' }) const [modalTempTitle, setModalTempTitle] = useState(() => { return sessionStorage.getItem('modalTempTitle') || '' }) const appRef = useRef(null) const isCreatingSessionRef = useRef(false) const isSendingMessageRef = useRef(false) // Track if we're currently sending a message const isInitialMountRef = useRef(true) // Track if this is the first mount after refresh // --- Effects --- // Note: Removed useLayoutEffect scroll reset - it caused more issues than it solved. // Scroll persistence is now handled in MessageList. // Dark Mode - Apply synchronously before paint to prevent flickering useLayoutEffect(() => { document.documentElement.classList.remove('light', 'dark') document.documentElement.classList.add(darkMode ? 'dark' : 'light') localStorage.setItem('darkMode', darkMode) }, [darkMode]) useEffect(() => { localStorage.setItem('sidebarOpen', sidebarOpen) }, [sidebarOpen]) useEffect(() => { sessionStorage.setItem('showSearch', showSearch) }, [showSearch]) useEffect(() => { sessionStorage.setItem('showSettings', showSettings) }, [showSettings]) useEffect(() => { localStorage.setItem('settingsTab', settingsTab) }, [settingsTab]) useEffect(() => { sessionStorage.setItem('showRenameModal', showRenameModal) sessionStorage.setItem('modalTempTitle', modalTempTitle) }, [showRenameModal, modalTempTitle]) useEffect(() => { if (viewerData) { sessionStorage.setItem('viewerData', JSON.stringify(viewerData)) } else { sessionStorage.removeItem('viewerData') } }, [viewerData]) // Scroll Restoration & Event Listeners useEffect(() => { // Disable native scroll restoration to prevent jump on refresh if ('scrollRestoration' in window.history) { window.history.scrollRestoration = 'manual' } const handleResize = () => setIsMobile(window.innerWidth < 768) const handleKeyDown = (e) => { // Cmd+F or Ctrl+F for Search if ((e.metaKey || e.ctrlKey) && e.key === 'f') { e.preventDefault() setShowSearch(true) } // Cmd+E or Ctrl+E for New Chat if ((e.metaKey || e.ctrlKey) && e.key === 'e') { e.preventDefault() createConversation() } // Cmd+X or Ctrl+X for General Settings if ((e.metaKey || e.ctrlKey) && e.key === 'x') { e.preventDefault() setSettingsTab('general') setShowSettings(true) } // Cmd+I or Ctrl+I for Account Settings if ((e.metaKey || e.ctrlKey) && e.key === 'i') { e.preventDefault() setSettingsTab('account') setShowSettings(true) } // Cmd+K or Ctrl+K for Dark Mode Toggle if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault() setDarkMode(prev => !prev) } } window.addEventListener('resize', handleResize) window.addEventListener('keydown', handleKeyDown) return () => { window.removeEventListener('resize', handleResize) window.removeEventListener('keydown', handleKeyDown) } }, []) // Load Conversations on Mount useEffect(() => { const initConversations = async () => { try { const res = await fetch(`${API_BASE}/conversations`) const data = await res.json() // Merge persisted metadata (Pin/Archive) const persistedMetadata = JSON.parse(localStorage.getItem('chat_metadata') || '{}') const mergedData = data.map(conv => ({ ...conv, isPinned: persistedMetadata[conv.id]?.isPinned || false, isArchived: persistedMetadata[conv.id]?.isArchived || false })) setConversations(mergedData) const savedId = localStorage.getItem('lastConversationId') if (savedId) { const exists = data.find(c => c.id === savedId) if (exists) { // Load messages for saved conversation before showing UI await loadMessagesSync(savedId) } else { localStorage.removeItem('lastConversationId') setCurrentConversation(null) setMessages([]) } } } catch (error) { console.error('Failed to fetch conversations:', error) } finally { // Always mark initialization as complete setIsInitializing(false) } } initConversations() }, []) // Mark initial mount as complete after first render cycle of the REAL UI useEffect(() => { if (!isInitializing) { const timer = setTimeout(() => { isInitialMountRef.current = false }, 300) // Slightly longer to be safe return () => clearTimeout(timer) } }, [isInitializing]) // Load Messages when Conversation Changes useEffect(() => { if (currentConversation) { if (isCreatingSessionRef.current) { // If we JUST created this session, don't clear messages! // The current messages state already contains the optimistic user message. isCreatingSessionRef.current = false return } // Skip loading if we're currently sending a message (to prevent race condition) if (isSendingMessageRef.current) { return } loadMessages(currentConversation) localStorage.setItem('lastConversationId', currentConversation) } else { setMessages([]) } }, [currentConversation]) // Auto-refresh for pending messages (e.g., if user returns to a generating session) useEffect(() => { if (!currentConversation || isLoading || messages.length === 0) return const lastMsg = messages[messages.length - 1] const isPending = lastMsg.role === 'assistant' && !lastMsg.content if (isPending) { const pollInterval = setInterval(() => { loadMessagesSync(currentConversation) }, 3000) // Poll every 3s return () => clearInterval(pollInterval) } }, [messages, currentConversation, isLoading]) // Simulation Engine for "Resume Streaming" experience useEffect(() => { if (messages.length === 0 || isLoading) return const lastIdx = messages.length - 1 const lastMsg = messages[lastIdx] if (lastMsg.isSimulating && lastMsg._fullContent) { const simulationTimer = setTimeout(() => { setMessages(prev => { const newMessages = [...prev] const msg = newMessages[lastIdx] if (!msg || !msg.isSimulating) return prev const currentLen = msg.content.length const fullContent = msg._fullContent // Batch size for simulation (approx 24 chars for that "super fast" feel) const batchSize = 24 const nextContent = fullContent.substring(0, currentLen + batchSize) msg.content = nextContent if (nextContent.length >= fullContent.length) { msg.isStreaming = false msg.isSimulating = false delete msg._fullContent } return newMessages }) }, 5) // Very fast update for simulation return () => clearTimeout(simulationTimer) } }, [messages, isLoading]) // --- Actions --- const loadMessages = async (conversationId) => { try { const res = await fetch(`${API_BASE}/conversations/${conversationId}/messages`) const data = await res.json() setMessages(parseMessagesData(data)) } catch (error) { console.error('Failed to load messages:', error) } } // Sync version for initial load (doesn't trigger effects that cause scroll) const loadMessagesSync = async (conversationId) => { try { const res = await fetch(`${API_BASE}/conversations/${conversationId}/messages`) const data = await res.json() setMessages(parseMessagesData(data)) } catch (error) { console.error('Failed to load messages:', error) } } // Helper to parse messages data const parseMessagesData = (data) => { const TYPING_SPEED = 0.25 // chars/ms (approx 250 chars/sec) const now = new Date().getTime() return data.map((m, idx) => { let images = [] if (m.image_data) { try { const parsed = JSON.parse(m.image_data) if (Array.isArray(parsed)) { images = parsed.map(b64 => `data:image/jpeg;base64,${b64}`) } else { images = [`data:image/jpeg;base64,${m.image_data}`] } } catch (e) { images = [`data:image/jpeg;base64,${m.image_data}`] } } const createdAt = new Date(m.created_at).getTime() const elapsed = now - createdAt const fullContent = m.content || '' // Only simulate for the VERY LAST message if it's an assistant message const isLastMessage = idx === data.length - 1 const shouldSimulate = isLastMessage && m.role === 'assistant' && fullContent.length > 0 if (shouldSimulate) { const expectedVisibleLength = Math.floor(elapsed * TYPING_SPEED) if (expectedVisibleLength < fullContent.length) { return { role: m.role, content: fullContent.substring(0, Math.max(0, expectedVisibleLength)), _fullContent: fullContent, // Hidden property for simulation images: images, isStreaming: true, isSimulating: true // Flag for simulation } } } return { role: m.role, content: fullContent, images: images, // If assistant message is empty, it means it's still being generated in background isStreaming: m.role === 'assistant' && !fullContent } }) } const createConversation = () => { setCurrentConversation(null) setMessages([]) localStorage.removeItem('lastConversationId') // Clear instead of setting "null" if (window.innerWidth < 768) setSidebarOpen(false) } const deleteConversation = async (id) => { try { await fetch(`${API_BASE}/conversations/${id}`, { method: 'DELETE' }) const remaining = conversations.filter(c => c.id !== id) setConversations(remaining) if (currentConversation === id) { setCurrentConversation(null) setMessages([]) localStorage.removeItem('lastConversationId') } // Cleanup metadata const metadata = JSON.parse(localStorage.getItem('chat_metadata') || '{}') if (metadata[id]) { delete metadata[id] localStorage.setItem('chat_metadata', JSON.stringify(metadata)) } // Cleanup message expansion states Object.keys(localStorage).forEach(key => { if (key.startsWith(`expand_${id}`)) { localStorage.removeItem(key) } }) } catch (error) { console.error('Failed to delete conversation:', error) } } const renameConversation = async (id, newTitle) => { // Optimistic UI update setConversations(prev => prev.map(c => c.id === id ? { ...c, title: newTitle } : c )) try { const res = await fetch(`${API_BASE}/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: newTitle }) }) if (!res.ok) throw new Error('Failed to rename on server') } catch (error) { console.error('Failed to rename conversation:', error) // Revert on error? (Optional, maybe just notify) } } const handleSaveRename = () => { if (modalTempTitle.trim() && currentConversation) { renameConversation(currentConversation, modalTempTitle) } setShowRenameModal(false) } const togglePin = (id) => { setConversations(prev => { const isCurrentlyPinned = prev.find(c => c.id === id)?.isPinned const currentPinnedCount = prev.filter(c => c.isPinned).length // If we are pinning (not unpinning) and already at 5, stop and warn. if (!isCurrentlyPinned && currentPinnedCount >= 5) { setShowPinLimitToast(true) setTimeout(() => setShowPinLimitToast(false), 3000) return prev } const updated = prev.map(c => { if (c.id === id) { const newPinned = !c.isPinned return { ...c, isPinned: newPinned, isArchived: newPinned ? false : c.isArchived } } return c }) // Persist metadata const metadata = JSON.parse(localStorage.getItem('chat_metadata') || '{}') const conv = updated.find(c => c.id === id) metadata[id] = { ...metadata[id], isPinned: conv.isPinned, isArchived: conv.isArchived } localStorage.setItem('chat_metadata', JSON.stringify(metadata)) return updated }) } const toggleArchive = (id) => { setConversations(prev => { const updated = prev.map(c => { if (c.id === id) { const newArchived = !c.isArchived return { ...c, isArchived: newArchived, isPinned: newArchived ? false : c.isPinned } } return c }) // If the current conversation is archived, deselect it if (currentConversation === id) { const isArchiving = !prev.find(c => c.id === id)?.isArchived if (isArchiving) { setCurrentConversation(null) setMessages([]) } } // Persist metadata const metadata = JSON.parse(localStorage.getItem('chat_metadata') || '{}') const conv = updated.find(c => c.id === id) metadata[id] = { ...metadata[id], isPinned: conv.isPinned, isArchived: conv.isArchived } localStorage.setItem('chat_metadata', JSON.stringify(metadata)) return updated }) } const sendMessage = async (text, uploadedImages) => { const userMessage = text.trim() const imagePreviews = uploadedImages.map(img => img.preview) // Set flag to prevent loadMessages from overwriting optimistic updates isSendingMessageRef.current = true // Optimistic UI update setMessages(prev => [...prev, { role: 'user', content: userMessage, images: imagePreviews }]) setIsLoading(true) const formData = new FormData() formData.append('message', userMessage) if (currentConversation) formData.append('session_id', currentConversation) uploadedImages.forEach(img => { formData.append('images', img.file) }) try { const res = await fetch(`${API_BASE}/chat`, { method: 'POST', body: formData }) const sessionId = res.headers.get('X-Session-Id') // Handle New Session if (sessionId && !currentConversation) { isCreatingSessionRef.current = true setCurrentConversation(sessionId) localStorage.setItem('lastConversationId', sessionId) setConversations(prev => { if (prev.find(c => c.id === sessionId)) return prev return [{ id: sessionId, title: userMessage.slice(0, 50), created_at: new Date().toISOString() }, ...prev] }) } // Stream Response const reader = res.body.getReader() const decoder = new TextDecoder() let assistantMessage = '' setMessages(prev => [...prev, { role: 'assistant', content: '', isStreaming: true }]) while (true) { const { done, value } = await reader.read() if (done) break const chunk = decoder.decode(value, { stream: true }) const lines = chunk.split('\n').filter(l => l.trim().length > 0) let batchTokens = 0 const BATCH_SIZE = 8 // Update UI every 8 tokens if they arrive at once for (let i = 0; i < lines.length; i++) { const line = lines[i] if (line.startsWith('data: ')) { const data = line.slice(6) if (data === '[DONE]') break let parsed = null try { parsed = JSON.parse(data) } catch { parsed = data } // Legacy fallback if (typeof parsed === 'object' && parsed !== null && parsed.type) { if (parsed.type === 'token') { assistantMessage += parsed.content batchTokens++ } else if (parsed.type === 'status') { setMessages(prev => { const newMessages = [...prev] if (newMessages.length > 0) newMessages[newMessages.length - 1].status = parsed.status return newMessages }) } else if (parsed.type === 'done') { break; } } else if (typeof parsed === 'string') { assistantMessage += parsed batchTokens++ } // Update UI either on batch size or end of chunk lines if (batchTokens >= BATCH_SIZE || i === lines.length - 1) { setMessages(prev => { const newMessages = [...prev] const lastMsg = newMessages[newMessages.length - 1] if (lastMsg) lastMsg.content = assistantMessage return newMessages }) batchTokens = 0 // A very tiny delay between batches to keep the main thread breathing // and maintain the typewriter feel await new Promise(resolve => setTimeout(resolve, 2)) } } } } // Finish Streaming setMessages(prev => { const newMessages = [...prev] if (newMessages.length > 0) { newMessages[newMessages.length - 1].isStreaming = false newMessages[newMessages.length - 1].status = null // Clear any persistent "Thinking..." status } return newMessages }) } catch (error) { console.error('Failed to send message:', error) setMessages(prev => { const newMessages = [...prev] // If the last message was the streaming one, mark it as finished/error if (newMessages.length > 0 && newMessages[newMessages.length - 1].role === 'assistant') { newMessages[newMessages.length - 1].isStreaming = false newMessages[newMessages.length - 1].status = null } return [...newMessages, { role: 'assistant', content: 'Xin lỗi, đã có lỗi xảy ra.' }] }) } finally { setIsLoading(false) // Clear flag after message sending is complete isSendingMessageRef.current = false } } return (
Bạn chỉ có thể ghim tối đa 5 đoạn chat.