From e582a02e68dd74687a55c1dc99f51f9604f615e1 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Thu, 5 Mar 2026 16:56:26 -0800 Subject: [PATCH] feat(ui): add useChatHistory hook for localStorage-backed conversations --- .../src/components/chat/useChatHistory.ts | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/chat/useChatHistory.ts diff --git a/ui/litellm-dashboard/src/components/chat/useChatHistory.ts b/ui/litellm-dashboard/src/components/chat/useChatHistory.ts new file mode 100644 index 00000000000..df32f8221dd --- /dev/null +++ b/ui/litellm-dashboard/src/components/chat/useChatHistory.ts @@ -0,0 +1,272 @@ +import { useCallback, useEffect, useState } from "react"; +import { ChatMessage, Conversation } from "./types"; + +const STORAGE_KEY = "litellm_chat_history_v1"; +const MAX_CONVERSATIONS = 100; +const TITLE_MAX_LENGTH = 40; + +function generateTitle(firstUserMessage: string): string { + const trimmed = firstUserMessage.trim(); + if (trimmed.length <= TITLE_MAX_LENGTH) { + return trimmed; + } + return trimmed.slice(0, TITLE_MAX_LENGTH); +} + +function loadFromStorage(): { conversations: Conversation[]; storageUnavailable: boolean } { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return { conversations: [], storageUnavailable: false }; + } + const parsed = JSON.parse(raw) as Conversation[]; + return { conversations: parsed, storageUnavailable: false }; + } catch { + return { conversations: [], storageUnavailable: true }; + } +} + +function saveToStorage(conversations: Conversation[]): boolean { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations)); + return true; + } catch { + return false; + } +} + +function trimConversations(conversations: Conversation[]): Conversation[] { + if (conversations.length <= MAX_CONVERSATIONS) { + return conversations; + } + return [...conversations] + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, MAX_CONVERSATIONS); +} + +export function useChatHistory(activeConversationId: string | null): { + conversations: Conversation[]; + activeConversation: Conversation | null; + storageUnavailable: boolean; + staleId: boolean; + createConversation: (model: string) => string; + appendMessage: (conversationId: string, message: Omit) => void; + updateLastAssistantMessage: (conversationId: string, updates: Partial>) => void; + truncateAfterMessage: (conversationId: string, messageId: string) => void; + deleteConversation: (id: string) => void; + renameConversation: (id: string, newTitle: string) => void; + setActiveConversationId: (id: string | null) => void; +} { + const [conversations, setConversations] = useState([]); + const [storageUnavailable, setStorageUnavailable] = useState(false); + const [staleId, setStaleId] = useState(false); + const [currentActiveId, setCurrentActiveId] = useState(activeConversationId); + + // Sync internal active id whenever the URL-derived prop changes (e.g. "New chat" → null) + useEffect(() => { + setCurrentActiveId(activeConversationId); + setStaleId(false); + }, [activeConversationId]); + + useEffect(() => { + const { conversations: loaded, storageUnavailable: unavailable } = loadFromStorage(); + setConversations(loaded); + setStorageUnavailable(unavailable); + + if (activeConversationId !== null) { + const found = loaded.some((c) => c.id === activeConversationId); + if (!found) { + setStaleId(true); + } + } + }, []); + + const persistConversations = useCallback( + (updated: Conversation[]) => { + const trimmed = trimConversations(updated); + setConversations(trimmed); + if (!storageUnavailable) { + const success = saveToStorage(trimmed); + if (!success) { + setStorageUnavailable(true); + } + } + }, + [storageUnavailable], + ); + + const createConversation = useCallback( + (model: string): string => { + const id = crypto.randomUUID(); + const now = Date.now(); + const newConversation: Conversation = { + id, + title: "New conversation", + model, + messages: [], + mcpServerNames: [], + createdAt: now, + updatedAt: now, + }; + persistConversations([newConversation, ...conversations]); + setCurrentActiveId(id); + return id; + }, + [conversations, persistConversations], + ); + + const appendMessage = useCallback( + (conversationId: string, message: Omit) => { + const newMessage: ChatMessage = { + ...message, + id: crypto.randomUUID(), + timestamp: Date.now(), + }; + + setConversations((prev) => { + const updated = prev.map((conv) => { + if (conv.id !== conversationId) { + return conv; + } + + const updatedMessages = [...conv.messages, newMessage]; + + let title = conv.title; + if ( + title === "New conversation" && + newMessage.role === "user" && + conv.messages.filter((m) => m.role === "user").length === 0 + ) { + title = generateTitle(newMessage.content); + } + + return { + ...conv, + title, + messages: updatedMessages, + updatedAt: Date.now(), + }; + }); + + const trimmed = trimConversations(updated); + if (!storageUnavailable) { + const success = saveToStorage(trimmed); + if (!success) { + setStorageUnavailable(true); + } + } + return trimmed; + }); + }, + [storageUnavailable], + ); + + const updateLastAssistantMessage = useCallback( + ( + conversationId: string, + updates: Partial>, + ) => { + setConversations((prev) => { + const updated = prev.map((conv) => { + if (conv.id !== conversationId) { + return conv; + } + + const messages = [...conv.messages]; + const lastAssistantIndex = messages.reduceRight((found, msg, idx) => { + if (found !== -1) return found; + return msg.role === "assistant" ? idx : -1; + }, -1); + + if (lastAssistantIndex === -1) { + return conv; + } + + messages[lastAssistantIndex] = { + ...messages[lastAssistantIndex], + ...updates, + }; + + return { + ...conv, + messages, + updatedAt: Date.now(), + }; + }); + + const trimmed = trimConversations(updated); + if (!storageUnavailable) { + const success = saveToStorage(trimmed); + if (!success) { + setStorageUnavailable(true); + } + } + return trimmed; + }); + }, + [storageUnavailable], + ); + + const truncateAfterMessage = useCallback( + (conversationId: string, messageId: string) => { + setConversations((prev) => { + const updated = prev.map((conv) => { + if (conv.id !== conversationId) return conv; + const idx = conv.messages.findIndex((m) => m.id === messageId); + if (idx === -1) return conv; + const messages = conv.messages.slice(0, idx); + return { ...conv, messages, updatedAt: Date.now() }; + }); + const trimmed = trimConversations(updated); + if (!storageUnavailable) saveToStorage(trimmed); + return trimmed; + }); + }, + [storageUnavailable], + ); + + const deleteConversation = useCallback( + (id: string) => { + const updated = conversations.filter((c) => c.id !== id); + persistConversations(updated); + if (currentActiveId === id) { + setCurrentActiveId(null); + } + }, + [conversations, currentActiveId, persistConversations], + ); + + const renameConversation = useCallback( + (id: string, newTitle: string) => { + const updated = conversations.map((conv) => + conv.id === id ? { ...conv, title: newTitle, updatedAt: Date.now() } : conv, + ); + persistConversations(updated); + }, + [conversations, persistConversations], + ); + + const setActiveConversationId = useCallback((id: string | null) => { + setCurrentActiveId(id); + setStaleId(false); + }, []); + + const activeConversation = + currentActiveId !== null + ? (conversations.find((c) => c.id === currentActiveId) ?? null) + : null; + + return { + conversations, + activeConversation, + storageUnavailable, + staleId, + createConversation, + appendMessage, + updateLastAssistantMessage, + truncateAfterMessage, + deleteConversation, + renameConversation, + setActiveConversationId, + }; +}