rag-kb-system / src /hooks /useChatHistory.ts
duqing2026's picture
同步 hf
9ed89c8
"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
};
}