Spaces:
Build error
Build error
| "use client"; | |
| import { useState, useEffect, useRef, useCallback } from 'react'; | |
| import { Message } from 'ai'; | |
| export interface ChatSession { | |
| id: string; | |
| title: string; | |
| messages: Message[]; | |
| createdAt: number; | |
| isTemp?: boolean; | |
| type?: 'chat' | 'quiz' | 'file'; | |
| fileInfo?: { | |
| name: string; | |
| }; | |
| } | |
| // Simple debounce implementation if lodash is not available or to avoid dependency | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| function simpleDebounce<T extends (...args: any[]) => void>(func: T, wait: number): (...args: Parameters<T>) => void { | |
| let timeout: ReturnType<typeof setTimeout>; | |
| return (...args: Parameters<T>) => { | |
| clearTimeout(timeout); | |
| timeout = setTimeout(() => func(...args), wait); | |
| }; | |
| } | |
| const STORAGE_KEY = 'rag_kb_chat_history'; | |
| export function useChatHistory() { | |
| const [sessions, setSessions] = useState<ChatSession[]>([]); | |
| const [currentSessionId, setCurrentSessionId] = useState<string | null>(null); | |
| const [isLoaded, setIsLoaded] = useState(false); | |
| // Ref to track sessions state for async callbacks without dependency loops | |
| const sessionsRef = useRef(sessions); | |
| useEffect(() => { | |
| sessionsRef.current = sessions; | |
| }, [sessions]); | |
| // Ref to track which sessions are currently being created to prevent duplicate calls | |
| const creatingSessionsRef = useRef<Set<string>>(new Set()); | |
| // Save current session ID to local storage | |
| useEffect(() => { | |
| if (currentSessionId) { | |
| localStorage.setItem('rag_kb_current_session_id', currentSessionId); | |
| } | |
| }, [currentSessionId]); | |
| // Fetch messages when current session changes | |
| useEffect(() => { | |
| if (!currentSessionId) return; | |
| const session = sessions.find(s => s.id === currentSessionId); | |
| // If messages are not loaded (we only fetched session metadata initially), load them | |
| // Actually, let's optimize: only fetch if we don't have messages or want to refresh? | |
| // For simplicity, let's fetch. | |
| // But wait, if we just created it locally, we have messages (empty). | |
| // Let's check if messages array exists. | |
| async function fetchMessages() { | |
| if (!currentSessionId) return; | |
| try { | |
| const res = await fetch(`/api/history/sessions/${currentSessionId}`); | |
| if (res.ok) { | |
| const { messages } = await res.json(); | |
| setSessions(prev => prev.map(s => | |
| s.id === currentSessionId ? { ...s, messages } : s | |
| )); | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| } | |
| } | |
| // Only fetch if messages are undefined (if we change the initial fetch to only get metadata) | |
| // Currently our GET /sessions returns * (including undefined messages? No, table has no messages column). | |
| // Wait, sessions table only has id, title, created_at. | |
| // So sessions initially have no messages property or it's undefined. | |
| // We should check if messages are missing. | |
| if (session && !session.messages) { | |
| fetchMessages(); | |
| } | |
| }, [currentSessionId, sessions]); | |
| const createNewSession = useCallback(async (type: 'chat' | 'quiz' | 'file' = 'chat', title?: string, fileInfo?: { name: string }) => { | |
| // Check if we already have an empty temp session to reuse (only if type matches) | |
| const existingTemp = sessionsRef.current.find(s => s.isTemp && s.messages.length === 0 && (s.type === type || (!s.type && type === 'chat'))); | |
| // For file type, we must also match the file name, or just create new one to be safe | |
| if (existingTemp && type !== 'file') { | |
| setCurrentSessionId(existingTemp.id); | |
| return existingTemp.id; | |
| } | |
| const newSession: ChatSession = { | |
| id: crypto.randomUUID(), | |
| title: title || (type === 'quiz' ? 'Quiz Generation' : (type === 'file' && fileInfo ? `Chat with ${fileInfo.name}` : 'New Chat')), | |
| messages: [], | |
| createdAt: Date.now(), | |
| isTemp: true, // Mark as temporary, don't persist yet | |
| type, | |
| fileInfo | |
| }; | |
| // Optimistic update | |
| setSessions(prev => [newSession, ...prev]); | |
| setCurrentSessionId(newSession.id); | |
| // We do NOT persist here anymore. | |
| // Session will be persisted when the first message is sent. | |
| return newSession.id; | |
| }, []); | |
| // Load sessions from API on mount | |
| useEffect(() => { | |
| async function fetchSessions() { | |
| try { | |
| const isDemo = process.env.NEXT_PUBLIC_DEMO_MODE === 'true'; | |
| const res = await fetch(`/api/history/sessions${isDemo ? '?mode=demo' : ''}`); | |
| if (!res.ok) throw new Error('Failed to fetch sessions'); | |
| const data = await res.json(); | |
| setSessions(data); | |
| const savedId = localStorage.getItem('rag_kb_current_session_id'); | |
| const foundSession = savedId ? data.find((s: ChatSession) => s.id === savedId) : null; | |
| if (foundSession) { | |
| setCurrentSessionId(savedId); | |
| } else { | |
| // Default to new chat if no saved session or saved session not found | |
| createNewSession(); | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| // Fallback to new session if DB fails or empty | |
| createNewSession(); | |
| } finally { | |
| setIsLoaded(true); | |
| } | |
| } | |
| fetchSessions(); | |
| }, [createNewSession]); | |
| // Persist to DB with debounce | |
| const debouncedSaveRef = useRef<((id: string, msgs: Message[]) => void) | null>(null); | |
| useEffect(() => { | |
| debouncedSaveRef.current = simpleDebounce(async (id: string, msgs: Message[]) => { | |
| try { | |
| await fetch(`/api/history/sessions/${id}/messages`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ messages: msgs }) | |
| }); | |
| } catch (e) { | |
| console.error('Failed to save messages', e); | |
| } | |
| }, 1000); // Wait 1 second after last update | |
| }, []); | |
| const updateSessionMessages = useCallback(async (id: string, messages: Message[]) => { | |
| // Check if we need to create the session on the server first (lazy creation) | |
| const session = sessionsRef.current.find(s => s.id === id); | |
| let shouldCreate = false; | |
| if (session && session.isTemp && messages.length > 0 && !creatingSessionsRef.current.has(id)) { | |
| shouldCreate = true; | |
| creatingSessionsRef.current.add(id); | |
| } | |
| // Optimistic update | |
| setSessions(prev => prev.map(session => { | |
| if (session.id === id) { | |
| let title = session.title; | |
| // Update title if it's the first message AND it's a regular chat | |
| if ((session.isTemp || title === 'New Chat') && messages.length > 0 && (!session.type || session.type === 'chat')) { | |
| const firstUserMsg = messages.find(m => m.role === 'user'); | |
| if (firstUserMsg) { | |
| title = firstUserMsg.content.slice(0, 30) + (firstUserMsg.content.length > 30 ? '...' : ''); | |
| } | |
| } | |
| const updatedSession = { ...session, messages, title }; | |
| if (shouldCreate) { | |
| delete updatedSession.isTemp; | |
| } | |
| return updatedSession; | |
| } | |
| return session; | |
| })); | |
| if (shouldCreate) { | |
| try { | |
| // Get the title we just generated? | |
| // We can recalculate it here to be safe or grab from state later? | |
| // Recalculating is safer for the async call. | |
| let title = session?.title || 'New Chat'; | |
| // Only update title from message content if it is a regular chat | |
| if (!session?.type || session.type === 'chat') { | |
| const firstUserMsg = messages.find(m => m.role === 'user'); | |
| if (firstUserMsg) { | |
| title = firstUserMsg.content.slice(0, 30) + (firstUserMsg.content.length > 30 ? '...' : ''); | |
| } | |
| } | |
| await fetch('/api/history/sessions', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| id: id, | |
| title: title, | |
| createdAt: session?.createdAt || Date.now(), | |
| type: session?.type || 'chat' | |
| }) | |
| }); | |
| } catch (e) { | |
| console.error('Failed to create session lazily', e); | |
| // If failed, we might want to keep isTemp? | |
| // For now, let's assume it works or user will retry. | |
| } finally { | |
| creatingSessionsRef.current.delete(id); | |
| } | |
| } | |
| // Trigger debounced save | |
| if (debouncedSaveRef.current) { | |
| debouncedSaveRef.current(id, messages); | |
| } | |
| }, []); | |
| const deleteSession = useCallback(async (id: string) => { | |
| // Optimistic update | |
| setSessions(prev => { | |
| const newSessions = prev.filter(s => s.id !== id); | |
| // Note: We need to handle currentSessionId update carefully inside the setter or outside | |
| // But here we need access to currentSessionId state. | |
| // Instead of using closure state which changes, let's do the check outside or use functional update fully? | |
| // Functional update for setSessions doesn't allow setting currentSessionId easily. | |
| // Let's just depend on currentSessionId in useCallback deps. | |
| return newSessions; | |
| }); | |
| // We need to check if we deleted the current session | |
| // This logic was slightly flawed in previous version because it was inside setSessions but tried to set another state | |
| // React batching might handle it, but better to do it cleanly. | |
| if (currentSessionId === id) { | |
| // We can't see the *new* sessions here easily without duplicating logic. | |
| // Let's assume we remove it. | |
| // We need to find the next session. | |
| setSessions(prev => { | |
| const remaining = prev.filter(s => s.id !== id); | |
| if (remaining.length > 0) { | |
| setCurrentSessionId(remaining[0].id); | |
| } else { | |
| setCurrentSessionId(null); | |
| } | |
| return remaining; | |
| }); | |
| } else { | |
| setSessions(prev => prev.filter(s => s.id !== id)); | |
| } | |
| // Persist | |
| try { | |
| await fetch(`/api/history/sessions/${id}`, { | |
| method: 'DELETE' | |
| }); | |
| } catch (e) { | |
| console.error('Failed to delete session', e); | |
| } | |
| }, [currentSessionId]); | |
| const clearHistory = useCallback(() => { | |
| // Not implemented in API yet, but user didn't ask for "Clear All" specifically. | |
| // Just reset local state for now. | |
| setSessions([]); | |
| createNewSession(); | |
| }, [createNewSession]); | |
| const currentSession = sessions.find(s => s.id === currentSessionId); | |
| return { | |
| sessions, | |
| currentSessionId, | |
| currentSession, | |
| setCurrentSessionId, | |
| createNewSession, | |
| updateSessionMessages, | |
| deleteSession, | |
| clearHistory, | |
| isLoaded | |
| }; | |
| } | |