diff --git a/core/application/application.go b/core/application/application.go index 852324e74203..7416b32a5eb7 100644 --- a/core/application/application.go +++ b/core/application/application.go @@ -1,8 +1,10 @@ package application import ( + "cmp" "context" "math/rand/v2" + "path/filepath" "sync" "sync/atomic" "time" @@ -11,6 +13,7 @@ import ( "github.com/mudler/LocalAI/core/config" mcpTools "github.com/mudler/LocalAI/core/http/endpoints/mcp" "github.com/mudler/LocalAI/core/services/agentpool" + "github.com/mudler/LocalAI/core/services/chathistory" "github.com/mudler/LocalAI/core/services/facerecognition" "github.com/mudler/LocalAI/core/services/galleryop" "github.com/mudler/LocalAI/core/services/nodes" @@ -50,6 +53,7 @@ type Application struct { agentPoolService atomic.Pointer[agentpool.AgentPoolService] faceRegistry facerecognition.Registry voiceRegistry voicerecognition.Registry + chatHistoryStore *chathistory.Store authDB *gorm.DB watchdogMutex sync.Mutex watchdogStop chan bool @@ -180,6 +184,13 @@ func (a *Application) VoiceRegistry() voicerecognition.Registry { return a.voiceRegistry } +// ChatHistoryStore returns the server-side WebUI chat history store, or nil +// when the feature is disabled (LOCALAI_DISABLE_WEBUI=true or persistence +// path could not be resolved). +func (a *Application) ChatHistoryStore() *chathistory.Store { + return a.chatHistoryStore +} + // AuthDB returns the auth database connection, or nil if auth is not enabled. func (a *Application) AuthDB() *gorm.DB { return a.authDB @@ -280,6 +291,18 @@ func (a *Application) start() error { a.agentJobService = agentJobService + // Initialize chat history store for the WebUI (issue #9432). + // Uses the same directory hierarchy as the agent pool — DataPath wins, + // then DynamicConfigsDir; we never fall back to a hard-coded path so + // containers without persistent volumes simply skip the feature. + if !a.applicationConfig.DisableWebUI { + if base := cmp.Or(a.applicationConfig.DataPath, a.applicationConfig.DynamicConfigsDir); base != "" { + a.chatHistoryStore = chathistory.New(filepath.Join(base, "chat_history")) + } else { + xlog.Warn("Chat history persistence disabled: no DataPath or DynamicConfigsDir configured") + } + } + return nil } diff --git a/core/http/app.go b/core/http/app.go index 99d11bd69c5c..543c15f368e1 100644 --- a/core/http/app.go +++ b/core/http/app.go @@ -89,6 +89,8 @@ var quietPaths = []string{"/api/operations", "/api/resources", "/healthz", "/rea // @tag.description Document reranking // @tag.name instructions // @tag.description API instruction discovery — browse instruction areas and get endpoint guides +// @tag.name chat-history +// @tag.description Server-side persistence of WebUI chat conversations func API(application *application.Application) (*echo.Echo, error) { e := echo.New() @@ -366,7 +368,8 @@ func API(application *application.Application) (*echo.Echo, error) { } mcpMw := auth.RequireFeature(application.AuthDB(), auth.FeatureMCP) - routes.RegisterLocalAIRoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application.TemplatesEvaluator(), application, adminMiddleware, mcpJobsMw, mcpMw) + chatHistoryMw := auth.RequireFeature(application.AuthDB(), auth.FeatureChatHistory) + routes.RegisterLocalAIRoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application.TemplatesEvaluator(), application, adminMiddleware, mcpJobsMw, mcpMw, chatHistoryMw) routes.RegisterAgentPoolRoutes(e, application, agentsMw, skillsMw, collectionsMw) // Fine-tuning routes fineTuningMw := auth.RequireFeature(application.AuthDB(), auth.FeatureFineTuning) diff --git a/core/http/auth/features.go b/core/http/auth/features.go index 77199580a7a5..127a71075c06 100644 --- a/core/http/auth/features.go +++ b/core/http/auth/features.go @@ -123,6 +123,15 @@ var RouteFeatureRegistry = []RouteFeature{ {"GET", "/api/fine-tuning/jobs/:id/download", FeatureFineTuning}, {"POST", "/api/fine-tuning/datasets", FeatureFineTuning}, + // Chat History (server-side persistence of WebUI conversations, #9432) + {"GET", "/api/conversations", FeatureChatHistory}, + {"DELETE", "/api/conversations", FeatureChatHistory}, + {"POST", "/api/conversations", FeatureChatHistory}, + {"PUT", "/api/conversations/bulk", FeatureChatHistory}, + {"GET", "/api/conversations/:id", FeatureChatHistory}, + {"PUT", "/api/conversations/:id", FeatureChatHistory}, + {"DELETE", "/api/conversations/:id", FeatureChatHistory}, + // Quantization {"POST", "/api/quantization/jobs", FeatureQuantization}, {"GET", "/api/quantization/jobs", FeatureQuantization}, @@ -181,5 +190,6 @@ func APIFeatureMetas() []FeatureMeta { {FeatureFaceRecognition, "Face Recognition", true}, {FeatureVoiceRecognition, "Voice Recognition", true}, {FeatureAudioTransform, "Audio Transform", true}, + {FeatureChatHistory, "Chat History", true}, } } diff --git a/core/http/auth/permissions.go b/core/http/auth/permissions.go index fb8246f7c5f0..c8fc0972f471 100644 --- a/core/http/auth/permissions.go +++ b/core/http/auth/permissions.go @@ -56,6 +56,7 @@ const ( FeatureFaceRecognition = "face_recognition" FeatureVoiceRecognition = "voice_recognition" FeatureAudioTransform = "audio_transform" + FeatureChatHistory = "chat_history" ) // AgentFeatures lists agent-related features (default OFF). @@ -71,6 +72,7 @@ var APIFeatures = []string{ FeatureVAD, FeatureDetection, FeatureVideo, FeatureEmbeddings, FeatureSound, FeatureRealtime, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores, FeatureFaceRecognition, FeatureVoiceRecognition, FeatureAudioTransform, + FeatureChatHistory, } // AllFeatures lists all known features (used by UI and validation). diff --git a/core/http/endpoints/localai/api_instructions.go b/core/http/endpoints/localai/api_instructions.go index 103c87443209..34ee1c0a5384 100644 --- a/core/http/endpoints/localai/api_instructions.go +++ b/core/http/endpoints/localai/api_instructions.go @@ -92,6 +92,12 @@ var instructionDefs = []instructionDef{ Tags: []string{"branding"}, Intro: "GET /api/branding is public so the login screen can render the configured logo before authentication. Text fields are saved through POST /api/settings; binary assets (logo, horizontal logo, favicon) use multipart upload at /api/branding/asset/{kind} and are served back from /branding/asset/{kind}.", }, + { + Name: "chat-history", + Description: "Server-side persistence of WebUI chat conversations (#9432)", + Tags: []string{"chat-history"}, + Intro: "Per-user CRUD over chat conversations. POST/PUT upsert by id; PUT /bulk replaces the entire conversation set in one shot (used for localStorage migration).", + }, } // swaggerState holds parsed swagger spec data, initialised once. diff --git a/core/http/endpoints/localai/api_instructions_test.go b/core/http/endpoints/localai/api_instructions_test.go index 35bdfa2399d9..a1c7ae250f4a 100644 --- a/core/http/endpoints/localai/api_instructions_test.go +++ b/core/http/endpoints/localai/api_instructions_test.go @@ -39,7 +39,7 @@ var _ = Describe("API Instructions Endpoints", func() { instructions, ok := resp["instructions"].([]any) Expect(ok).To(BeTrue()) - Expect(instructions).To(HaveLen(12)) + Expect(instructions).To(HaveLen(13)) // Verify each instruction has required fields and correct URL format for _, s := range instructions { diff --git a/core/http/endpoints/localai/chat_history.go b/core/http/endpoints/localai/chat_history.go new file mode 100644 index 000000000000..a381c6a42d3b --- /dev/null +++ b/core/http/endpoints/localai/chat_history.go @@ -0,0 +1,161 @@ +package localai + +import ( + "errors" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/application" + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/chathistory" +) + +// ListConversationsEndpoint lists all stored conversations for the current user. +// +// @Summary List chat conversations +// @Tags chat-history +// @Success 200 {object} map[string]any +// @Router /api/conversations [get] +func ListConversationsEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + store := app.ChatHistoryStore() + if store == nil { + return c.JSON(http.StatusOK, map[string]any{"conversations": []any{}}) + } + convs, err := store.List(getUserID(c)) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]any{"conversations": convs}) + } +} + +// GetConversationEndpoint returns a single conversation by ID. +// +// @Summary Get a chat conversation +// @Tags chat-history +// @Param id path string true "Conversation ID" +// @Router /api/conversations/{id} [get] +func GetConversationEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + store := app.ChatHistoryStore() + if store == nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "chat history is not enabled"}) + } + conv, err := store.Get(getUserID(c), c.Param("id")) + if err != nil { + if errors.Is(err, chathistory.ErrNotFound) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "conversation not found"}) + } + if errors.Is(err, chathistory.ErrInvalidID) { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid conversation id"}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, conv) + } +} + +// SaveConversationEndpoint upserts a conversation. The body's id field is the +// canonical identifier; a path id is also accepted and overrides the body +// when both are present (so PUT /api/conversations/ works as expected). +// +// @Summary Save a chat conversation (upsert) +// @Tags chat-history +// @Param body body schema.Conversation true "Conversation payload" +// @Router /api/conversations [post] +func SaveConversationEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + store := app.ChatHistoryStore() + if store == nil { + return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "chat history is not enabled"}) + } + var conv schema.Conversation + if err := c.Bind(&conv); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + if pathID := c.Param("id"); pathID != "" { + conv.ID = pathID + } + saved, err := store.Save(getUserID(c), conv) + if err != nil { + if errors.Is(err, chathistory.ErrInvalidID) { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid conversation id"}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, saved) + } +} + +// BulkReplaceConversationsEndpoint replaces the entire conversation set for +// the current user — used by the React UI to migrate from localStorage on +// first connect. +// +// @Summary Replace all chat conversations +// @Tags chat-history +// @Router /api/conversations/bulk [put] +func BulkReplaceConversationsEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + store := app.ChatHistoryStore() + if store == nil { + return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "chat history is not enabled"}) + } + var payload struct { + Conversations []schema.Conversation `json:"conversations"` + } + if err := c.Bind(&payload); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + if err := store.ReplaceAll(getUserID(c), payload.Conversations); err != nil { + if errors.Is(err, chathistory.ErrInvalidID) { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid conversation id"}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]any{"status": "ok", "count": len(payload.Conversations)}) + } +} + +// DeleteConversationEndpoint removes a single conversation. +// +// @Summary Delete a chat conversation +// @Tags chat-history +// @Param id path string true "Conversation ID" +// @Router /api/conversations/{id} [delete] +func DeleteConversationEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + store := app.ChatHistoryStore() + if store == nil { + return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "chat history is not enabled"}) + } + if err := store.Delete(getUserID(c), c.Param("id")); err != nil { + if errors.Is(err, chathistory.ErrNotFound) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "conversation not found"}) + } + if errors.Is(err, chathistory.ErrInvalidID) { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid conversation id"}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + } +} + +// DeleteAllConversationsEndpoint wipes the user's entire chat history. +// +// @Summary Delete all chat conversations for the current user +// @Tags chat-history +// @Router /api/conversations [delete] +func DeleteAllConversationsEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + store := app.ChatHistoryStore() + if store == nil { + return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "chat history is not enabled"}) + } + if err := store.DeleteAll(getUserID(c)); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + } +} diff --git a/core/http/react-ui/src/hooks/useChat.js b/core/http/react-ui/src/hooks/useChat.js index 30538ed12d2f..3e9cf0c234a3 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 78f0b4f68165..3a76e02219ae 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 cf83d590fe3e..8fdd7680eb00 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}`, diff --git a/core/http/routes/localai.go b/core/http/routes/localai.go index 5c341b90c8be..5e2b6b7bf289 100644 --- a/core/http/routes/localai.go +++ b/core/http/routes/localai.go @@ -27,7 +27,8 @@ func RegisterLocalAIRoutes(router *echo.Echo, app *application.Application, adminMiddleware echo.MiddlewareFunc, mcpJobsMw echo.MiddlewareFunc, - mcpMw echo.MiddlewareFunc) { + mcpMw echo.MiddlewareFunc, + chatHistoryMw echo.MiddlewareFunc) { router.GET("/swagger/*", echoswagger.EchoWrapHandler(func(c *echoswagger.Config) { c.URLs = []string{"doc.json"} @@ -422,4 +423,17 @@ func RegisterLocalAIRoutes(router *echo.Echo, router.POST("/api/agent/tasks/:name/execute", localai.ExecuteTaskByNameEndpoint(app), mcpJobsMw) } + // Chat history persistence (#9432). Skipped entirely when the WebUI is + // disabled — the store is nil in that case, and registering routes that + // would always return 503 only adds surface area. + if app != nil && app.ChatHistoryStore() != nil { + router.GET("/api/conversations", localai.ListConversationsEndpoint(app), chatHistoryMw) + router.POST("/api/conversations", localai.SaveConversationEndpoint(app), chatHistoryMw) + router.DELETE("/api/conversations", localai.DeleteAllConversationsEndpoint(app), chatHistoryMw) + router.PUT("/api/conversations/bulk", localai.BulkReplaceConversationsEndpoint(app), chatHistoryMw) + router.GET("/api/conversations/:id", localai.GetConversationEndpoint(app), chatHistoryMw) + router.PUT("/api/conversations/:id", localai.SaveConversationEndpoint(app), chatHistoryMw) + router.DELETE("/api/conversations/:id", localai.DeleteConversationEndpoint(app), chatHistoryMw) + } + } diff --git a/core/schema/chat_conversation.go b/core/schema/chat_conversation.go new file mode 100644 index 000000000000..041843f2019f --- /dev/null +++ b/core/schema/chat_conversation.go @@ -0,0 +1,46 @@ +package schema + +import ( + "encoding/json" + "time" +) + +// Conversation represents a chat conversation persisted server-side. +// Issue #9432: enables chat history to survive browser refresh / device switch. +// +// The History field is intentionally json.RawMessage so the server stays +// agnostic to message shape — the React UI mixes user / assistant / thinking / +// tool_call / tool_result entries with text, image_url, audio_url, and file +// attachments, and storing them opaquely avoids lossy round-trips. +type Conversation struct { + ID string `json:"id"` + Name string `json:"name"` + Model string `json:"model,omitempty"` + History json.RawMessage `json:"history,omitempty"` + SystemPrompt string `json:"systemPrompt,omitempty"` + MCPMode bool `json:"mcpMode,omitempty"` + MCPServers []string `json:"mcpServers,omitempty"` + MCPResources []string `json:"mcpResources,omitempty"` + ClientMCPServers json.RawMessage `json:"clientMCPServers,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"topP,omitempty"` + TopK *float64 `json:"topK,omitempty"` + TokenUsage *ConvTokenUsage `json:"tokenUsage,omitempty"` + ContextSize *int `json:"contextSize,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +// ConvTokenUsage mirrors the React UI's tokenUsage object on each chat. +type ConvTokenUsage struct { + Prompt int `json:"prompt"` + Completion int `json:"completion"` + Total int `json:"total"` +} + +// ConversationsFile is the on-disk representation for a user's conversations. +type ConversationsFile struct { + Conversations []Conversation `json:"conversations"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} diff --git a/core/services/chathistory/chathistory_suite_test.go b/core/services/chathistory/chathistory_suite_test.go new file mode 100644 index 000000000000..b5ff2961f36d --- /dev/null +++ b/core/services/chathistory/chathistory_suite_test.go @@ -0,0 +1,13 @@ +package chathistory_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestChatHistory(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ChatHistory test suite") +} diff --git a/core/services/chathistory/store.go b/core/services/chathistory/store.go new file mode 100644 index 000000000000..98b43b3cf88e --- /dev/null +++ b/core/services/chathistory/store.go @@ -0,0 +1,296 @@ +// Package chathistory implements server-side persistence of WebUI chat +// conversations (GitHub issue #9432). Conversations are stored as a single +// JSON file per user under {baseDir}/{userID}/conversations.json (when auth is +// active) or {baseDir}/anonymous/conversations.json (single-user / no-auth +// deployments). The store is in-memory authoritative with synchronous writes +// after every mutation so a crash never loses more than one in-flight save. +package chathistory + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/mudler/LocalAI/core/schema" +) + +var ( + // ErrNotFound is returned when a conversation does not exist. + ErrNotFound = errors.New("conversation not found") + // ErrInvalidID is returned for malformed or unsafe conversation IDs. + ErrInvalidID = errors.New("invalid conversation id") + // ErrInvalidUserID is returned for malformed or unsafe user IDs. + ErrInvalidUserID = errors.New("invalid user id") +) + +// idRegex restricts conversation IDs to safe filesystem-printable runes. +// The React UI uses crypto-random IDs (see utils/format.generateId), which +// fit comfortably inside this character class. +var idRegex = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`) + +// anonymousDir is the directory used when auth is disabled (empty userID). +const anonymousDir = "anonymous" + +// Store persists conversations to disk, partitioned by userID. +type Store struct { + baseDir string + + mu sync.Mutex + cache map[string]map[string]schema.Conversation // userID -> id -> conv +} + +// New creates a new Store rooted at baseDir. The directory is created on the +// first write — empty installs do not pollute the filesystem. +func New(baseDir string) *Store { + return &Store{ + baseDir: baseDir, + cache: make(map[string]map[string]schema.Conversation), + } +} + +// validateID checks the conversation ID against idRegex. +func validateID(id string) error { + if !idRegex.MatchString(id) { + return ErrInvalidID + } + return nil +} + +// validateUserID rejects path-traversal attempts in the user ID. +// Empty string is allowed (maps to anonymousDir). +func validateUserID(id string) error { + if id == "" { + return nil + } + if strings.ContainsAny(id, "/\\\x00") || strings.Contains(id, "..") { + return ErrInvalidUserID + } + return nil +} + +// userDir returns the directory where userID's conversation file lives. +func (s *Store) userDir(userID string) string { + if userID == "" { + return filepath.Join(s.baseDir, anonymousDir) + } + return filepath.Join(s.baseDir, userID) +} + +// userFile returns the on-disk path for a user's conversations file. +func (s *Store) userFile(userID string) string { + return filepath.Join(s.userDir(userID), "conversations.json") +} + +// load reads userID's conversations from disk (cached after first read). +// Caller must hold s.mu. +func (s *Store) load(userID string) (map[string]schema.Conversation, error) { + if cached, ok := s.cache[userID]; ok { + return cached, nil + } + convs := map[string]schema.Conversation{} + path := s.userFile(userID) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + s.cache[userID] = convs + return convs, nil + } + return nil, fmt.Errorf("read conversations file: %w", err) + } + var cf schema.ConversationsFile + if err := json.Unmarshal(data, &cf); err != nil { + return nil, fmt.Errorf("parse conversations file: %w", err) + } + for _, c := range cf.Conversations { + convs[c.ID] = c + } + s.cache[userID] = convs + return convs, nil +} + +// save writes userID's conversations back to disk. Caller must hold s.mu. +func (s *Store) save(userID string, convs map[string]schema.Conversation) error { + dir := s.userDir(userID) + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("create user dir: %w", err) + } + + list := make([]schema.Conversation, 0, len(convs)) + for _, c := range convs { + list = append(list, c) + } + sort.Slice(list, func(i, j int) bool { + return list[i].UpdatedAt > list[j].UpdatedAt + }) + + cf := schema.ConversationsFile{ + Conversations: list, + UpdatedAt: time.Now(), + } + data, err := json.MarshalIndent(cf, "", " ") + if err != nil { + return fmt.Errorf("marshal conversations: %w", err) + } + + tmp := s.userFile(userID) + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("write conversations file: %w", err) + } + if err := os.Rename(tmp, s.userFile(userID)); err != nil { + return fmt.Errorf("rename conversations file: %w", err) + } + return nil +} + +// List returns all conversations for userID, sorted newest-updated first. +func (s *Store) List(userID string) ([]schema.Conversation, error) { + if err := validateUserID(userID); err != nil { + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + + convs, err := s.load(userID) + if err != nil { + return nil, err + } + out := make([]schema.Conversation, 0, len(convs)) + for _, c := range convs { + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { + return out[i].UpdatedAt > out[j].UpdatedAt + }) + return out, nil +} + +// Get returns a single conversation, or ErrNotFound if absent. +func (s *Store) Get(userID, id string) (*schema.Conversation, error) { + if err := validateUserID(userID); err != nil { + return nil, err + } + if err := validateID(id); err != nil { + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + + convs, err := s.load(userID) + if err != nil { + return nil, err + } + c, ok := convs[id] + if !ok { + return nil, ErrNotFound + } + return &c, nil +} + +// Save upserts a conversation. CreatedAt is preserved across updates; +// UpdatedAt is refreshed on every save. +func (s *Store) Save(userID string, conv schema.Conversation) (*schema.Conversation, error) { + if err := validateUserID(userID); err != nil { + return nil, err + } + if err := validateID(conv.ID); err != nil { + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + + convs, err := s.load(userID) + if err != nil { + return nil, err + } + + now := time.Now().UnixMilli() + if existing, ok := convs[conv.ID]; ok { + if conv.CreatedAt == 0 { + conv.CreatedAt = existing.CreatedAt + } + } else if conv.CreatedAt == 0 { + conv.CreatedAt = now + } + conv.UpdatedAt = now + + convs[conv.ID] = conv + if err := s.save(userID, convs); err != nil { + return nil, err + } + return &conv, nil +} + +// ReplaceAll overwrites the entire conversation set for a user. The React UI +// uses this for bulk sync after a multi-tab merge or after importing from +// localStorage on first connect. +func (s *Store) ReplaceAll(userID string, convs []schema.Conversation) error { + if err := validateUserID(userID); err != nil { + return err + } + for _, c := range convs { + if err := validateID(c.ID); err != nil { + return err + } + } + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now().UnixMilli() + out := make(map[string]schema.Conversation, len(convs)) + for _, c := range convs { + if c.CreatedAt == 0 { + c.CreatedAt = now + } + if c.UpdatedAt == 0 { + c.UpdatedAt = now + } + out[c.ID] = c + } + s.cache[userID] = out + return s.save(userID, out) +} + +// Delete removes a conversation, returning ErrNotFound if it does not exist. +func (s *Store) Delete(userID, id string) error { + if err := validateUserID(userID); err != nil { + return err + } + if err := validateID(id); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + + convs, err := s.load(userID) + if err != nil { + return err + } + if _, ok := convs[id]; !ok { + return ErrNotFound + } + delete(convs, id) + return s.save(userID, convs) +} + +// DeleteAll wipes all conversations for a user. +func (s *Store) DeleteAll(userID string) error { + if err := validateUserID(userID); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + + s.cache[userID] = map[string]schema.Conversation{} + path := s.userFile(userID) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove conversations file: %w", err) + } + return nil +} diff --git a/core/services/chathistory/store_test.go b/core/services/chathistory/store_test.go new file mode 100644 index 000000000000..5b3aa3f5f9be --- /dev/null +++ b/core/services/chathistory/store_test.go @@ -0,0 +1,152 @@ +package chathistory_test + +import ( + "encoding/json" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/chathistory" +) + +func newConv(id, name string) schema.Conversation { + history, _ := json.Marshal([]map[string]any{ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + }) + return schema.Conversation{ + ID: id, + Name: name, + Model: "test-model", + History: history, + } +} + +var _ = Describe("Store", func() { + var ( + dir string + store *chathistory.Store + ) + + BeforeEach(func() { + dir = GinkgoT().TempDir() + store = chathistory.New(dir) + }) + + Context("basic CRUD", func() { + const userID = "alice" + + It("saves, lists, gets, and deletes a conversation", func() { + _, err := store.Save(userID, newConv("c1", "First")) + Expect(err).NotTo(HaveOccurred()) + _, err = store.Save(userID, newConv("c2", "Second")) + Expect(err).NotTo(HaveOccurred()) + + list, err := store.List(userID) + Expect(err).NotTo(HaveOccurred()) + Expect(list).To(HaveLen(2)) + + got, err := store.Get(userID, "c1") + Expect(err).NotTo(HaveOccurred()) + Expect(got.Name).To(Equal("First")) + Expect(got.CreatedAt).NotTo(BeZero(), "Save should populate CreatedAt") + Expect(got.UpdatedAt).NotTo(BeZero(), "Save should populate UpdatedAt") + + Expect(store.Delete(userID, "c1")).To(Succeed()) + _, err = store.Get(userID, "c1") + Expect(err).To(MatchError(chathistory.ErrNotFound)) + }) + }) + + Context("persistence across Store instances", func() { + // Second Store instance simulates a process restart: no shared + // in-memory cache, so it must read what the first instance wrote + // for the round-trip to succeed. + It("loads conversations written by a previous instance", func() { + first := chathistory.New(dir) + _, err := first.Save("bob", newConv("x", "Hi")) + Expect(err).NotTo(HaveOccurred()) + + second := chathistory.New(dir) + got, err := second.Get("bob", "x") + Expect(err).NotTo(HaveOccurred()) + Expect(got.Name).To(Equal("Hi")) + }) + }) + + Context("user isolation", func() { + It("never leaks one user's data to another", func() { + _, err := store.Save("alice", newConv("a1", "alice's chat")) + Expect(err).NotTo(HaveOccurred()) + _, err = store.Save("bob", newConv("b1", "bob's chat")) + Expect(err).NotTo(HaveOccurred()) + + bobList, err := store.List("bob") + Expect(err).NotTo(HaveOccurred()) + Expect(bobList).To(HaveLen(1)) + Expect(bobList[0].ID).To(Equal("b1")) + + _, err = store.Get("bob", "a1") + Expect(err).To(MatchError(chathistory.ErrNotFound)) + }) + }) + + Context("unsafe IDs", func() { + // idRegex must reject anything that could escape the user's + // directory or be misread by os.WriteFile. These are the + // classic path-traversal payloads plus a few edge cases. + DescribeTable("rejects", + func(badID string) { + _, err := store.Save("alice", schema.Conversation{ID: badID, Name: "x"}) + Expect(err).To(HaveOccurred()) + }, + Entry("path traversal", "../etc/passwd"), + Entry("forward slash", "a/b"), + Entry("back slash", "a\\b"), + Entry("empty id", ""), + Entry("contains spaces", "id with spaces"), + ) + }) + + Context("ReplaceAll", func() { + // Bulk migration scenario: client uploads its entire + // conversation set in one shot, the store should overwrite + // anything previously there instead of merging. + It("overwrites the entire conversation set", func() { + const userID = "alice" + for _, id := range []string{"a", "b", "c"} { + _, err := store.Save(userID, newConv(id, id)) + Expect(err).NotTo(HaveOccurred()) + } + + Expect(store.ReplaceAll(userID, []schema.Conversation{newConv("z", "z")})).To(Succeed()) + + list, err := store.List(userID) + Expect(err).NotTo(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].ID).To(Equal("z")) + }) + }) + + Context("anonymous user", func() { + // Drift from the anonymous/ layout would silently strand + // anonymous users' history once they later log in, so the + // test pins the exact path. + It("stores conversations under the anonymous/ subdirectory", func() { + _, err := store.Save("", newConv("solo", "anon chat")) + Expect(err).NotTo(HaveOccurred()) + + expected := filepath.Join(dir, "anonymous", "conversations.json") + _, err = os.Stat(expected) + Expect(err).NotTo(HaveOccurred(), "expected anonymous conversations file at %s", expected) + + second := chathistory.New(dir) + got, err := second.Get("", "solo") + Expect(err).NotTo(HaveOccurred()) + Expect(got.Name).To(Equal("anon chat")) + }) + }) +})