diff --git a/core/application/application.go b/core/application/application.go index 7a34279c9064..eea13fb0b583 100644 --- a/core/application/application.go +++ b/core/application/application.go @@ -12,6 +12,7 @@ import ( "github.com/mudler/LocalAI/core/http/auth" 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/monitoring" @@ -57,6 +58,7 @@ type Application struct { agentPoolService atomic.Pointer[agentpool.AgentPoolService] faceRegistry facerecognition.Registry voiceRegistry voicerecognition.Registry + chatHistoryStore *chathistory.Store authDB *gorm.DB metricsService *monitoring.LocalAIMetricsService statsRecorder *billing.Recorder @@ -203,6 +205,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 @@ -409,6 +418,21 @@ func (a *Application) start() error { a.agentJobService = agentJobService + // Initialize chat history store for the WebUI (issue #9432). Reuses the + // shared auth database so per-user chat history sits alongside agent + // configs and jobs in one place — mudler's review on #9902 called out + // the file-based store as inconsistent with the rest of LocalAI's + // per-user state. When auth is disabled the store stays nil and the + // React UI falls back to localStorage. + if !a.applicationConfig.DisableWebUI && a.authDB != nil { + store, err := chathistory.New(a.authDB) + if err != nil { + xlog.Warn("Chat history persistence disabled: failed to initialise store", "error", err) + } else { + a.chatHistoryStore = store + } + } + return nil } diff --git a/core/http/app.go b/core/http/app.go index 79a1067b315e..80eeb11ee3ab 100644 --- a/core/http/app.go +++ b/core/http/app.go @@ -88,6 +88,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() @@ -384,7 +386,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 9eb0095dd3bf..a07e3ef0b43d 100644 --- a/core/http/endpoints/localai/api_instructions.go +++ b/core/http/endpoints/localai/api_instructions.go @@ -116,6 +116,12 @@ var instructionDefs = []instructionDef{ Tags: []string{"router"}, Intro: "Add a `router:` block to a ModelConfig to turn it into a routing model. The block declares a classifier (today: `feature` — handcrafted rules over prompt length and code-fence presence), a list of candidates (label + downstream model + optional rule), and a fallback. When a client addresses the routing model, the RouteModel middleware invokes the classifier, picks a candidate, and rewrites input.Model — the standard model-resolution path then runs ACL, disabled-state, and per-model PII against the chosen target. Depth-1 invariant: candidates must NOT themselves carry a `router:` block; runtime check returns 500 on violation. Decisions are logged to GET /api/router/decisions and surfaced in the /app/middleware Routing tab. POST /api/router/decide is the programmatic decision-oracle: external routers (e.g. an organisation-wide router service) send `{router, input}` and receive the classifier's label set + candidate model WITHOUT LocalAI rewriting, forwarding, or recording the call. Shares the classifier cache with the in-band path so warm-up costs are paid once.", }, + { + 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 70ae717659ad..b742991ea823 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(16)) + Expect(instructions).To(HaveLen(17)) // 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 539190b99c4d..92a4e18d9d07 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 a8ffa2f04029..76f917626cda 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 96baceaf8e44..a08a18db8725 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"} @@ -430,4 +431,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..0a95c7b21e99 --- /dev/null +++ b/core/schema/chat_conversation.go @@ -0,0 +1,39 @@ +package schema + +import ( + "encoding/json" +) + +// 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"` +} 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..05ea8d9de51a --- /dev/null +++ b/core/services/chathistory/store.go @@ -0,0 +1,277 @@ +// Package chathistory implements server-side persistence of WebUI chat +// conversations (GitHub issue #9432). Conversations live in the same +// GORM-backed database as the rest of the per-user state (AgentStore, +// JobStore): the store reuses Application.authDB so chat history, +// agent configs and jobs land in one place. When auth is disabled the +// store is not initialised and the React UI transparently falls back to +// localStorage. +package chathistory + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "time" + + "gorm.io/gorm" + + "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 conversation IDs. + ErrInvalidID = errors.New("invalid conversation id") + // ErrInvalidUserID is returned for malformed user IDs. + ErrInvalidUserID = errors.New("invalid user id") +) + +// idRegex constrains conversation IDs so they fit into the conv_id column +// (size 128) and cannot smuggle whitespace or control characters into log +// lines / responses. The React UI uses crypto-random IDs +// (utils/format.generateId) which fit comfortably inside this class. +var idRegex = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`) + +// userIDMaxLen caps the user ID length to match the user_id column size. +// We don't constrain the character class because the auth subsystem chooses +// the shape (UUID, OAuth subject, etc.) and SQL parameter binding already +// prevents injection. +const userIDMaxLen = 128 + +// ConversationRecord is the GORM row representation of a chat conversation. +// +// The primary key is (UserID, ConvID): React mints conversation IDs locally +// with no global coordination so they're only unique per user. A composite +// key makes per-user partitioning fall out naturally — anonymous users +// (UserID == "") get their own slice with no special-case code path. +type ConversationRecord struct { + UserID string `gorm:"primaryKey;size:128;column:user_id"` + ConvID string `gorm:"primaryKey;size:128;column:conv_id"` + Content string `gorm:"type:text;column:content"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"index;column:updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;column:deleted_at"` +} + +// TableName returns the database table name for ConversationRecord. +func (ConversationRecord) TableName() string { return "chat_conversations" } + +// Store persists conversations to a GORM-backed database, partitioned by +// userID. The empty string is treated as the anonymous (no-auth) user. +type Store struct { + db *gorm.DB +} + +// New creates a new Store backed by db and auto-migrates the schema. The +// caller is expected to pass the shared authDB so chat history sits in +// the same database as the other per-user state in LocalAI. +func New(db *gorm.DB) (*Store, error) { + if db == nil { + return nil, errors.New("chathistory: nil *gorm.DB") + } + if err := db.AutoMigrate(&ConversationRecord{}); err != nil { + return nil, fmt.Errorf("chathistory: auto-migrate: %w", err) + } + return &Store{db: db}, nil +} + +func validateID(id string) error { + if !idRegex.MatchString(id) { + return ErrInvalidID + } + return nil +} + +func validateUserID(id string) error { + if len(id) > userIDMaxLen { + return ErrInvalidUserID + } + 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 + } + var rows []ConversationRecord + if err := s.db. + Where("user_id = ?", userID). + Order("updated_at DESC"). + Find(&rows).Error; err != nil { + return nil, fmt.Errorf("chathistory: list: %w", err) + } + out := make([]schema.Conversation, 0, len(rows)) + for _, r := range rows { + var c schema.Conversation + if err := json.Unmarshal([]byte(r.Content), &c); err != nil { + return nil, fmt.Errorf("chathistory: unmarshal %q/%q: %w", r.UserID, r.ConvID, err) + } + out = append(out, c) + } + 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 + } + var row ConversationRecord + err := s.db.Where("user_id = ? AND conv_id = ?", userID, id).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("chathistory: get: %w", err) + } + var c schema.Conversation + if err := json.Unmarshal([]byte(row.Content), &c); err != nil { + return nil, fmt.Errorf("chathistory: unmarshal: %w", err) + } + return &c, nil +} + +// Save upserts a conversation. CreatedAt is preserved across updates when +// the caller passes 0; UpdatedAt is refreshed on every save. Timestamps +// inside the Conversation struct are React-managed Unix milliseconds; the +// GORM row's own created_at/updated_at columns are DB metadata used for +// future retention queries. +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 + } + + now := time.Now().UnixMilli() + + // Look up the existing row first so we can preserve the original + // CreatedAt when the caller omits it on an update. + var existing ConversationRecord + err := s.db.Where("user_id = ? AND conv_id = ?", userID, conv.ID).First(&existing).Error + switch { + case err == nil: + if conv.CreatedAt == 0 { + var prev schema.Conversation + // Best-effort decode: if the previous row was malformed we still + // want the write to succeed and just stamp CreatedAt with now. + if uerr := json.Unmarshal([]byte(existing.Content), &prev); uerr == nil { + conv.CreatedAt = prev.CreatedAt + } else { + conv.CreatedAt = now + } + } + case errors.Is(err, gorm.ErrRecordNotFound): + if conv.CreatedAt == 0 { + conv.CreatedAt = now + } + default: + return nil, fmt.Errorf("chathistory: lookup: %w", err) + } + conv.UpdatedAt = now + + data, err := json.Marshal(conv) + if err != nil { + return nil, fmt.Errorf("chathistory: marshal: %w", err) + } + rec := ConversationRecord{ + UserID: userID, + ConvID: conv.ID, + Content: string(data), + } + // gorm.Save() issues INSERT ... ON CONFLICT DO UPDATE when all primary + // key columns are set, which matches our composite-key shape exactly + // and keeps Save() race-free under concurrent writers. + if err := s.db.Save(&rec).Error; err != nil { + return nil, fmt.Errorf("chathistory: save: %w", err) + } + return &conv, nil +} + +// ReplaceAll atomically swaps the user's entire conversation set. Used by +// the localStorage migration upload: retries are safe because the +// operation is all-or-nothing. +func (s *Store) ReplaceAll(userID string, convs []schema.Conversation) error { + if err := validateUserID(userID); err != nil { + return err + } + + now := time.Now().UnixMilli() + rows := make([]ConversationRecord, 0, len(convs)) + for _, c := range convs { + if err := validateID(c.ID); err != nil { + return err + } + if c.CreatedAt == 0 { + c.CreatedAt = now + } + if c.UpdatedAt == 0 { + c.UpdatedAt = now + } + data, err := json.Marshal(c) + if err != nil { + return fmt.Errorf("chathistory: marshal %q: %w", c.ID, err) + } + rows = append(rows, ConversationRecord{ + UserID: userID, + ConvID: c.ID, + Content: string(data), + }) + } + + return s.db.Transaction(func(tx *gorm.DB) error { + // Hard delete (Unscoped) so a future retention sweep cannot + // resurrect an old soft-deleted row that shares an ID with a + // freshly uploaded conversation. + if err := tx. + Unscoped(). + Where("user_id = ?", userID). + Delete(&ConversationRecord{}).Error; err != nil { + return fmt.Errorf("chathistory: replace clear: %w", err) + } + if len(rows) == 0 { + return nil + } + return tx.Create(&rows).Error + }) +} + +// Delete removes a conversation, returning ErrNotFound if it does not exist. +// Soft delete (GORM populates deleted_at) so a future retention or audit +// pruner can still see what was there. +func (s *Store) Delete(userID, id string) error { + if err := validateUserID(userID); err != nil { + return err + } + if err := validateID(id); err != nil { + return err + } + result := s.db. + Where("user_id = ? AND conv_id = ?", userID, id). + Delete(&ConversationRecord{}) + if result.Error != nil { + return fmt.Errorf("chathistory: delete: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + +// DeleteAll wipes every conversation for a user. Soft delete semantics so a +// retention or audit pruner can still see what was there before the wipe. +func (s *Store) DeleteAll(userID string) error { + if err := validateUserID(userID); err != nil { + return err + } + return s.db. + Where("user_id = ?", userID). + Delete(&ConversationRecord{}).Error +} diff --git a/core/services/chathistory/store_test.go b/core/services/chathistory/store_test.go new file mode 100644 index 000000000000..b7546178dad1 --- /dev/null +++ b/core/services/chathistory/store_test.go @@ -0,0 +1,179 @@ +package chathistory_test + +import ( + "encoding/json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "gorm.io/gorm" + + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/chathistory" + "github.com/mudler/LocalAI/core/services/testutil" +) + +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 ( + db *gorm.DB + store *chathistory.Store + ) + + BeforeEach(func() { + db = testutil.SetupTestDB() + var err error + store, err = chathistory.New(db) + Expect(err).NotTo(HaveOccurred()) + }) + + 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() { + // Two Stores sharing the same *gorm.DB simulate a process restart: + // no shared in-memory state, so the second must read what the first + // wrote for the round-trip to succeed. + It("loads conversations written by a previous instance", func() { + first, err := chathistory.New(db) + Expect(err).NotTo(HaveOccurred()) + _, err = first.Save("bob", newConv("x", "Hi")) + Expect(err).NotTo(HaveOccurred()) + + second, err := chathistory.New(db) + Expect(err).NotTo(HaveOccurred()) + 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("malformed IDs", func() { + // The DB-backed store no longer needs to defend against path + // traversal, but idRegex still rejects whitespace / control + // characters so IDs stay safe in logs and HTTP responses. The + // same payloads exercise the empty-string and over-length 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() { + // UserID == "" maps to the anonymous slice. We can no longer pin a + // directory layout (the previous file-based store wrote + // anonymous/conversations.json), so the test checks the round-trip + // and the per-user isolation guarantee instead. + It("stores and retrieves conversations for an empty user ID", func() { + _, err := store.Save("", newConv("solo", "anon chat")) + Expect(err).NotTo(HaveOccurred()) + + got, err := store.Get("", "solo") + Expect(err).NotTo(HaveOccurred()) + Expect(got.Name).To(Equal("anon chat")) + + // And the conversation must NOT leak to a logged-in user. + _, err = store.Get("alice", "solo") + Expect(err).To(MatchError(chathistory.ErrNotFound)) + }) + }) + + Context("DeleteAll", func() { + It("wipes the user's entire chat history without touching others", func() { + _, err := store.Save("alice", newConv("a1", "alice 1")) + Expect(err).NotTo(HaveOccurred()) + _, err = store.Save("alice", newConv("a2", "alice 2")) + Expect(err).NotTo(HaveOccurred()) + _, err = store.Save("bob", newConv("b1", "bob 1")) + Expect(err).NotTo(HaveOccurred()) + + Expect(store.DeleteAll("alice")).To(Succeed()) + + aliceList, err := store.List("alice") + Expect(err).NotTo(HaveOccurred()) + Expect(aliceList).To(BeEmpty()) + + bobList, err := store.List("bob") + Expect(err).NotTo(HaveOccurred()) + Expect(bobList).To(HaveLen(1)) + }) + }) +})