From b8a480df45fe7d8a20b1198dcd06084cbb0c8cca Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Mon, 18 May 2026 18:06:20 -0700 Subject: [PATCH] feat(react-ui): sync chat history to server with localStorage fallback (#9432) useChat now probes /api/conversations on mount. When the endpoint responds 200, the server becomes the authoritative source: the hook merges remote conversations into local state and pushes per-chat PUT updates on each debounced save. When the endpoint 404s - older LocalAI deploys or the feature disabled - the hook silently keeps the old localStorage-only behaviour, so the change is backward-compatible. Migration: when the server is reachable but empty and the browser has local conversations with history, the hook fires a single PUT /api/conversations/bulk to seed the server. The atomic bulk endpoint avoids partial-upload states where a retry would skip already-uploaded entries. Delete operations propagate to the server when it's available; failures are silently swallowed so the local UI stays responsive on transient network errors. Assisted-by: Claude:claude-opus-4-7 Signed-off-by: TLoE419 --- core/http/react-ui/src/hooks/useChat.js | 119 ++++++++++++++++++++---- core/http/react-ui/src/utils/api.js | 19 ++++ core/http/react-ui/src/utils/config.js | 5 + 3 files changed, 124 insertions(+), 19 deletions(-) diff --git a/core/http/react-ui/src/hooks/useChat.js b/core/http/react-ui/src/hooks/useChat.js index 30538ed12..3e9cf0c23 100644 --- a/core/http/react-ui/src/hooks/useChat.js +++ b/core/http/react-ui/src/hooks/useChat.js @@ -1,7 +1,8 @@ -import { useState, useCallback, useRef } from 'react' +import { useState, useCallback, useRef, useEffect } from 'react' import { API_CONFIG } from '../utils/config' import { apiUrl } from '../utils/basePath' import { useDebouncedEffect } from './useDebounce' +import { chatHistoryApi } from '../utils/api' const thinkingTagRegex = /([\s\S]*?)<\/thinking>|([\s\S]*?)<\/think>|<\|channel>thought([\s\S]*?)/g const openThinkTagRegex = /||<\|channel>thought/ @@ -50,26 +51,33 @@ function loadChats() { return null } +// serializeChat strips React-only state (streaming flags, transient UI bits) +// before persistence. Used by both localStorage and the server. +function serializeChat(chat) { + return { + id: chat.id, + name: chat.name, + model: chat.model, + history: chat.history, + systemPrompt: chat.systemPrompt, + mcpMode: chat.mcpMode, + mcpServers: chat.mcpServers, + mcpResources: chat.mcpResources, + clientMCPServers: chat.clientMCPServers, + temperature: chat.temperature, + topP: chat.topP, + topK: chat.topK, + tokenUsage: chat.tokenUsage, + contextSize: chat.contextSize, + createdAt: chat.createdAt, + updatedAt: chat.updatedAt, + } +} + function saveChats(chats, activeChatId) { try { const data = { - chats: chats.map(chat => ({ - id: chat.id, - name: chat.name, - model: chat.model, - history: chat.history, - systemPrompt: chat.systemPrompt, - mcpMode: chat.mcpMode, - mcpServers: chat.mcpServers, - clientMCPServers: chat.clientMCPServers, - temperature: chat.temperature, - topP: chat.topP, - topK: chat.topK, - tokenUsage: chat.tokenUsage, - contextSize: chat.contextSize, - createdAt: chat.createdAt, - updatedAt: chat.updatedAt, - })), + chats: chats.map(serializeChat), activeChatId, lastSaved: Date.now(), } @@ -81,6 +89,20 @@ function saveChats(chats, activeChatId) { } } +// mergeRemoteAndLocal reconciles server conversations with the in-memory list. +// Server wins for any conversation that exists on both sides — the React +// state may have been hydrated from a stale localStorage cache on this tab. +// Conversations that exist only locally are preserved so unsaved drafts +// survive the first server roundtrip; they'll be pushed up on the next debounce. +function mergeRemoteAndLocal(remote, local) { + const byId = new Map() + for (const c of remote) byId.set(c.id, c) + for (const c of local) { + if (!byId.has(c.id)) byId.set(c.id, c) + } + return Array.from(byId.values()).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)) +} + function createNewChat(model = '', systemPrompt = '', mcpMode = false) { return { id: generateId(), @@ -132,7 +154,58 @@ export function useChat(initialModel = '') { const activeChat = chats.find(c => c.id === activeChatId) || chats[0] - useDebouncedEffect(() => saveChats(chats, activeChatId), [chats, activeChatId]) + // Server-side persistence (#9432). serverEnabledRef is null while we are + // still probing, true once a successful list arrives, false on any error + // (feature disabled, auth denied, network down). serializedSentRef caches + // the JSON last pushed per chat so we skip no-op writes on every render. + const serverEnabledRef = useRef(null) + const serializedSentRef = useRef(new Map()) + const bootstrappedRef = useRef(false) + + useEffect(() => { + let cancelled = false + chatHistoryApi.list() + .then(resp => { + if (cancelled) return + serverEnabledRef.current = true + const remote = Array.isArray(resp?.conversations) ? resp.conversations : [] + if (remote.length > 0) { + setChats(prev => mergeRemoteAndLocal(remote, prev)) + } else { + // Empty server, populated local cache: migrate so the user keeps + // their previous history after enabling persistence. + const localOnly = chats.filter(c => c.history && c.history.length > 0) + if (localOnly.length > 0) { + chatHistoryApi.bulkReplace(localOnly.map(serializeChat)).catch(() => {}) + } + } + }) + .catch(() => { + if (!cancelled) serverEnabledRef.current = false + }) + .finally(() => { + if (!cancelled) bootstrappedRef.current = true + }) + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useDebouncedEffect(() => { + saveChats(chats, activeChatId) + if (serverEnabledRef.current === true) { + for (const chat of chats) { + const serialized = serializeChat(chat) + const json = JSON.stringify(serialized) + if (serializedSentRef.current.get(chat.id) === json) continue + serializedSentRef.current.set(chat.id, json) + chatHistoryApi.save(serialized).catch(() => { + // Keep localStorage as the authoritative copy on transient + // server failures; we'll retry on the next change. + serializedSentRef.current.delete(chat.id) + }) + } + } + }, [chats, activeChatId]) const addChat = useCallback((model = '', systemPrompt = '', mcpMode = false) => { const chat = createNewChat(model, systemPrompt, mcpMode) @@ -159,6 +232,10 @@ export function useChat(initialModel = '') { } return filtered }) + serializedSentRef.current.delete(chatId) + if (serverEnabledRef.current === true) { + chatHistoryApi.delete(chatId).catch(() => {}) + } }, [activeChatId]) const deleteAllChats = useCallback(() => { @@ -170,6 +247,10 @@ export function useChat(initialModel = '') { setStreamingToolCalls([]) setTokensPerSecond(null) setMaxTokensPerSecond(null) + serializedSentRef.current.clear() + if (serverEnabledRef.current === true) { + chatHistoryApi.deleteAll().catch(() => {}) + } }, [activeChat?.model]) const renameChat = useCallback((chatId, name) => { diff --git a/core/http/react-ui/src/utils/api.js b/core/http/react-ui/src/utils/api.js index 78f0b4f68..3a76e0221 100644 --- a/core/http/react-ui/src/utils/api.js +++ b/core/http/react-ui/src/utils/api.js @@ -144,6 +144,25 @@ export const chatApi = { mcpComplete: (body) => postJSON(API_CONFIG.endpoints.mcpChatCompletions, body), } +// Chat History API — server-side conversation persistence (#9432). +// Endpoints return 404 when the WebUI's chat history feature is disabled, so +// every call here is best-effort: callers should fall back to localStorage on +// failure rather than surfacing a user-visible error. +export const chatHistoryApi = { + list: () => fetchJSON(API_CONFIG.endpoints.conversations), + get: (id) => fetchJSON(API_CONFIG.endpoints.conversation(id)), + save: (conv) => fetchJSON(API_CONFIG.endpoints.conversation(conv.id), { + method: 'PUT', + body: JSON.stringify(conv), + }), + bulkReplace: (conversations) => fetchJSON(API_CONFIG.endpoints.conversationsBulk, { + method: 'PUT', + body: JSON.stringify({ conversations }), + }), + delete: (id) => fetchJSON(API_CONFIG.endpoints.conversation(id), { method: 'DELETE' }), + deleteAll: () => fetchJSON(API_CONFIG.endpoints.conversations, { method: 'DELETE' }), +} + // MCP API export const mcpApi = { listServers: (model) => fetchJSON(API_CONFIG.endpoints.mcpServers(model)), diff --git a/core/http/react-ui/src/utils/config.js b/core/http/react-ui/src/utils/config.js index cf83d590f..8fdd7680e 100644 --- a/core/http/react-ui/src/utils/config.js +++ b/core/http/react-ui/src/utils/config.js @@ -51,6 +51,11 @@ export const API_CONFIG = { p2pStats: '/api/p2p/stats', p2pToken: '/api/p2p/token', + // Chat history (server-side persistence, #9432) + conversations: '/api/conversations', + conversation: (id) => `/api/conversations/${encodeURIComponent(id)}`, + conversationsBulk: '/api/conversations/bulk', + // Agent jobs agentTasks: '/api/agent/tasks', agentTask: (id) => `/api/agent/tasks/${id}`,