mirror of
https://github.com/mudler/LocalAI
synced 2026-05-24 09:28:23 +00:00
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 <tloemizuchizu@gmail.com>
This commit is contained in:
parent
4634b87c53
commit
b8a480df45
3 changed files with 124 additions and 19 deletions
119
core/http/react-ui/src/hooks/useChat.js
vendored
119
core/http/react-ui/src/hooks/useChat.js
vendored
|
|
@ -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 = /<thinking>([\s\S]*?)<\/thinking>|<think>([\s\S]*?)<\/think>|<\|channel>thought([\s\S]*?)<channel\|>/g
|
||||
const openThinkTagRegex = /<thinking>|<think>|<\|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) => {
|
||||
|
|
|
|||
19
core/http/react-ui/src/utils/api.js
vendored
19
core/http/react-ui/src/utils/api.js
vendored
|
|
@ -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)),
|
||||
|
|
|
|||
5
core/http/react-ui/src/utils/config.js
vendored
5
core/http/react-ui/src/utils/config.js
vendored
|
|
@ -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}`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue