From 06f5de87ce49711e270ca9db38f96c6db89d4731 Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Mon, 18 May 2026 18:05:41 -0700 Subject: [PATCH 1/9] feat(schema): add Conversation schema for chat history (#9432) Persist WebUI chat conversations server-side so browser refresh, private windows, or device changes preserve user history (issue #9432). This commit adds the on-disk representation: - Conversation holds id, name, model, opaque history, MCP / sampling settings, and timestamps. - ConversationsFile is the per-user list serialised to JSON. History is stored as json.RawMessage so the server stays agnostic to React message shapes (user / assistant / thinking / tool_call / tool_result mixed with text / image_url / audio_url / file attachments). Assisted-by: Claude:claude-opus-4-7 Signed-off-by: TLoE419 --- core/schema/chat_conversation.go | 46 ++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 core/schema/chat_conversation.go 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"` +} From f29ba20b43a221b1ff0cded756ac89c93bf4abc5 Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Mon, 18 May 2026 18:05:53 -0700 Subject: [PATCH 2/9] feat(chathistory): add file-based per-user conversation store (#9432) File-backed persister for chat history. Each user's conversations live under {baseDir}/{userID}/conversations.json (anonymous/ when auth is disabled). Design choices: - In-memory cache backed by sync.Mutex so concurrent saves don't interleave writes. - Atomic write via tmp file + os.Rename so a crash mid-save never leaves a corrupted history. - ID validation regex blocks path-traversal payloads at the store boundary, in addition to the auth context constraint. Tests cover round-trip persistence across instances, user isolation, unsafe-ID rejection, bulk replace migration, and the anonymous fallback path. Assisted-by: Claude:claude-opus-4-7 Signed-off-by: TLoE419 --- core/services/chathistory/store.go | 296 ++++++++++++++++++++++++ core/services/chathistory/store_test.go | 174 ++++++++++++++ 2 files changed, 470 insertions(+) create mode 100644 core/services/chathistory/store.go create mode 100644 core/services/chathistory/store_test.go 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..02ec66d9871a --- /dev/null +++ b/core/services/chathistory/store_test.go @@ -0,0 +1,174 @@ +package chathistory_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "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, + } +} + +func TestStore_SaveListGetDelete(t *testing.T) { + dir := t.TempDir() + s := chathistory.New(dir) + + userID := "alice" + if _, err := s.Save(userID, newConv("c1", "First")); err != nil { + t.Fatalf("save c1: %v", err) + } + if _, err := s.Save(userID, newConv("c2", "Second")); err != nil { + t.Fatalf("save c2: %v", err) + } + + list, err := s.List(userID) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(list) != 2 { + t.Fatalf("expected 2 conversations, got %d", len(list)) + } + + got, err := s.Get(userID, "c1") + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Name != "First" { + t.Fatalf("expected Name=First, got %q", got.Name) + } + if got.CreatedAt == 0 || got.UpdatedAt == 0 { + t.Fatalf("expected timestamps to be set, got CreatedAt=%d UpdatedAt=%d", got.CreatedAt, got.UpdatedAt) + } + + if err := s.Delete(userID, "c1"); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := s.Get(userID, "c1"); err != chathistory.ErrNotFound { + t.Fatalf("expected ErrNotFound after delete, got %v", err) + } +} + +func TestStore_RoundTripsAcrossInstances(t *testing.T) { + dir := t.TempDir() + first := chathistory.New(dir) + if _, err := first.Save("bob", newConv("x", "Hi")); err != nil { + t.Fatalf("save: %v", err) + } + + // Second store instance simulates a process restart: no in-memory cache, + // must read what the first instance wrote. + second := chathistory.New(dir) + got, err := second.Get("bob", "x") + if err != nil { + t.Fatalf("get after restart: %v", err) + } + if got.Name != "Hi" { + t.Fatalf("expected Name=Hi after reload, got %q", got.Name) + } +} + +func TestStore_UserIsolation(t *testing.T) { + dir := t.TempDir() + s := chathistory.New(dir) + + if _, err := s.Save("alice", newConv("a1", "alice's chat")); err != nil { + t.Fatalf("save alice: %v", err) + } + if _, err := s.Save("bob", newConv("b1", "bob's chat")); err != nil { + t.Fatalf("save bob: %v", err) + } + + bobList, err := s.List("bob") + if err != nil { + t.Fatalf("list bob: %v", err) + } + if len(bobList) != 1 || bobList[0].ID != "b1" { + t.Fatalf("bob should see only b1, got %+v", bobList) + } + + if _, err := s.Get("bob", "a1"); err != chathistory.ErrNotFound { + t.Fatalf("bob shouldn't be able to see alice's a1, got %v", err) + } +} + +func TestStore_RejectsUnsafeIDs(t *testing.T) { + s := chathistory.New(t.TempDir()) + + cases := []string{ + "../etc/passwd", + "a/b", + "a\\b", + "", + "id with spaces", + } + for _, id := range cases { + _, err := s.Save("alice", schema.Conversation{ID: id, Name: "x"}) + if err == nil { + t.Errorf("expected error for unsafe id %q, got nil", id) + } + } +} + +func TestStore_ReplaceAllOverwrites(t *testing.T) { + dir := t.TempDir() + s := chathistory.New(dir) + userID := "alice" + + for _, id := range []string{"a", "b", "c"} { + if _, err := s.Save(userID, newConv(id, id)); err != nil { + t.Fatalf("save %s: %v", id, err) + } + } + + if err := s.ReplaceAll(userID, []schema.Conversation{newConv("z", "z")}); err != nil { + t.Fatalf("replace: %v", err) + } + + list, err := s.List(userID) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(list) != 1 || list[0].ID != "z" { + t.Fatalf("expected only [z] after ReplaceAll, got %+v", list) + } +} + +func TestStore_AnonymousUsesAnonymousDir(t *testing.T) { + dir := t.TempDir() + s := chathistory.New(dir) + + if _, err := s.Save("", newConv("solo", "anon chat")); err != nil { + t.Fatalf("save anon: %v", err) + } + + // Verify the file landed under the anonymous/ subdir, not at the root — + // any drift from this layout would silently strand anonymous users' + // history when they later log in. + expected := filepath.Join(dir, "anonymous", "conversations.json") + if _, err := os.Stat(expected); err != nil { + t.Fatalf("expected anonymous conversations file at %s: %v", expected, err) + } + + second := chathistory.New(dir) + got, err := second.Get("", "solo") + if err != nil { + t.Fatalf("get anon: %v", err) + } + if got.Name != "anon chat" { + t.Fatalf("unexpected name: %q", got.Name) + } +} From c44e5261e80e8fe0be9d8b5d8c31ff5b696da0f1 Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Mon, 18 May 2026 18:06:06 -0700 Subject: [PATCH 3/9] feat(api): expose chat conversation CRUD endpoints (#9432) Wire the chathistory store into the HTTP layer. New endpoints under /api/conversations: - GET /api/conversations list - POST /api/conversations upsert (id in body) - DELETE /api/conversations delete all - PUT /api/conversations/bulk replace entire set (localStorage migration) - GET /api/conversations/:id fetch one - PUT /api/conversations/:id upsert (id in path; path id wins over body) - DELETE /api/conversations/:id delete one All endpoints scope queries to the authenticated user via getUserID(c) - callers cannot impersonate other users by passing a user_id in the body. The endpoints are gated behind the new chat_history feature permission (default ON in APIFeatures), registered through the existing RouteFeatureRegistry so the unified feature middleware picks them up automatically. Application.ChatHistoryStore() returns nil when DisableWebUI is set or no persistence path is configured, in which case route registration is skipped entirely rather than registering handlers that always 503. Also adds the chat-history instruction entry and Swagger tag so the endpoint surfaces in /api/instructions and /swagger. Assisted-by: Claude:claude-opus-4-7 Signed-off-by: TLoE419 --- core/application/application.go | 23 +++ core/http/app.go | 5 +- core/http/auth/features.go | 10 ++ core/http/auth/permissions.go | 2 + .../endpoints/localai/api_instructions.go | 6 + core/http/endpoints/localai/chat_history.go | 161 ++++++++++++++++++ core/http/routes/localai.go | 16 +- 7 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 core/http/endpoints/localai/chat_history.go diff --git a/core/application/application.go b/core/application/application.go index 7a34279c9064..503fec7b0dff 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" @@ -12,6 +14,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 +60,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 +207,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 +420,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 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/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/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) + } + } From 2a84d81ad62f2929d4687f491dfbea702f3fe90b Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Mon, 18 May 2026 18:06:20 -0700 Subject: [PATCH 4/9] 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 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}`, From d26f9c20f4f4b0cef5f325baa2001ec54261e4c0 Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Tue, 19 May 2026 00:08:29 -0700 Subject: [PATCH 5/9] test(chathistory): rewrite tests in Ginkgo/Gomega MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial test file used the stdlib testing package (t.Fatalf / t.Errorf), which is forbidden by the forbidigo linter rule in .golangci.yml — LocalAI mandates Ginkgo/Gomega for all Go tests (see .agents/coding-style.md). Restructured the same six scenarios into Describe / Context / It blocks, with a chathistory_suite_test.go bootstrap that registers the Ginkgo fail handler. Test coverage is unchanged: basic CRUD round-trip, cross- instance persistence, user isolation, unsafe ID rejection (now via DescribeTable), bulk replace overwrite, and the anonymous fallback path. Assisted-by: Claude:claude-opus-4-7 Signed-off-by: TLoE419 --- .../chathistory/chathistory_suite_test.go | 13 + core/services/chathistory/store_test.go | 264 ++++++++---------- 2 files changed, 134 insertions(+), 143 deletions(-) create mode 100644 core/services/chathistory/chathistory_suite_test.go 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_test.go b/core/services/chathistory/store_test.go index 02ec66d9871a..5b3aa3f5f9be 100644 --- a/core/services/chathistory/store_test.go +++ b/core/services/chathistory/store_test.go @@ -4,7 +4,9 @@ import ( "encoding/json" "os" "path/filepath" - "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" "github.com/mudler/LocalAI/core/schema" "github.com/mudler/LocalAI/core/services/chathistory" @@ -23,152 +25,128 @@ func newConv(id, name string) schema.Conversation { } } -func TestStore_SaveListGetDelete(t *testing.T) { - dir := t.TempDir() - s := chathistory.New(dir) - - userID := "alice" - if _, err := s.Save(userID, newConv("c1", "First")); err != nil { - t.Fatalf("save c1: %v", err) - } - if _, err := s.Save(userID, newConv("c2", "Second")); err != nil { - t.Fatalf("save c2: %v", err) - } - - list, err := s.List(userID) - if err != nil { - t.Fatalf("list: %v", err) - } - if len(list) != 2 { - t.Fatalf("expected 2 conversations, got %d", len(list)) - } - - got, err := s.Get(userID, "c1") - if err != nil { - t.Fatalf("get: %v", err) - } - if got.Name != "First" { - t.Fatalf("expected Name=First, got %q", got.Name) - } - if got.CreatedAt == 0 || got.UpdatedAt == 0 { - t.Fatalf("expected timestamps to be set, got CreatedAt=%d UpdatedAt=%d", got.CreatedAt, got.UpdatedAt) - } - - if err := s.Delete(userID, "c1"); err != nil { - t.Fatalf("delete: %v", err) - } - if _, err := s.Get(userID, "c1"); err != chathistory.ErrNotFound { - t.Fatalf("expected ErrNotFound after delete, got %v", err) - } -} - -func TestStore_RoundTripsAcrossInstances(t *testing.T) { - dir := t.TempDir() - first := chathistory.New(dir) - if _, err := first.Save("bob", newConv("x", "Hi")); err != nil { - t.Fatalf("save: %v", err) - } - - // Second store instance simulates a process restart: no in-memory cache, - // must read what the first instance wrote. - second := chathistory.New(dir) - got, err := second.Get("bob", "x") - if err != nil { - t.Fatalf("get after restart: %v", err) - } - if got.Name != "Hi" { - t.Fatalf("expected Name=Hi after reload, got %q", got.Name) - } -} - -func TestStore_UserIsolation(t *testing.T) { - dir := t.TempDir() - s := chathistory.New(dir) - - if _, err := s.Save("alice", newConv("a1", "alice's chat")); err != nil { - t.Fatalf("save alice: %v", err) - } - if _, err := s.Save("bob", newConv("b1", "bob's chat")); err != nil { - t.Fatalf("save bob: %v", err) - } - - bobList, err := s.List("bob") - if err != nil { - t.Fatalf("list bob: %v", err) - } - if len(bobList) != 1 || bobList[0].ID != "b1" { - t.Fatalf("bob should see only b1, got %+v", bobList) - } - - if _, err := s.Get("bob", "a1"); err != chathistory.ErrNotFound { - t.Fatalf("bob shouldn't be able to see alice's a1, got %v", err) - } -} - -func TestStore_RejectsUnsafeIDs(t *testing.T) { - s := chathistory.New(t.TempDir()) - - cases := []string{ - "../etc/passwd", - "a/b", - "a\\b", - "", - "id with spaces", - } - for _, id := range cases { - _, err := s.Save("alice", schema.Conversation{ID: id, Name: "x"}) - if err == nil { - t.Errorf("expected error for unsafe id %q, got nil", id) - } - } -} - -func TestStore_ReplaceAllOverwrites(t *testing.T) { - dir := t.TempDir() - s := chathistory.New(dir) - userID := "alice" +var _ = Describe("Store", func() { + var ( + dir string + store *chathistory.Store + ) - for _, id := range []string{"a", "b", "c"} { - if _, err := s.Save(userID, newConv(id, id)); err != nil { - t.Fatalf("save %s: %v", id, err) - } - } + BeforeEach(func() { + dir = GinkgoT().TempDir() + store = chathistory.New(dir) + }) - if err := s.ReplaceAll(userID, []schema.Conversation{newConv("z", "z")}); err != nil { - t.Fatalf("replace: %v", err) - } + 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)) + }) + }) - list, err := s.List(userID) - if err != nil { - t.Fatalf("list: %v", err) - } - if len(list) != 1 || list[0].ID != "z" { - t.Fatalf("expected only [z] after ReplaceAll, got %+v", list) - } -} + 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")) + }) + }) -func TestStore_AnonymousUsesAnonymousDir(t *testing.T) { - dir := t.TempDir() - s := chathistory.New(dir) + 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)) + }) + }) - if _, err := s.Save("", newConv("solo", "anon chat")); err != nil { - t.Fatalf("save anon: %v", err) - } + 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"), + ) + }) - // Verify the file landed under the anonymous/ subdir, not at the root — - // any drift from this layout would silently strand anonymous users' - // history when they later log in. - expected := filepath.Join(dir, "anonymous", "conversations.json") - if _, err := os.Stat(expected); err != nil { - t.Fatalf("expected anonymous conversations file at %s: %v", expected, err) - } + 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")) + }) + }) - second := chathistory.New(dir) - got, err := second.Get("", "solo") - if err != nil { - t.Fatalf("get anon: %v", err) - } - if got.Name != "anon chat" { - t.Fatalf("unexpected name: %q", got.Name) - } -} + 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")) + }) + }) +}) From 127e8486f19297e021fe5f23202d2255346b8152 Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Tue, 19 May 2026 10:22:56 -0700 Subject: [PATCH 6/9] test(api-instructions): bump instruction count to 13 for chat-history The new chat-history instruction definition pushes the total instructionDefs entries from 12 to 13. Update the hard-coded length assertion in api_instructions_test.go to match. The presence-level assertion in the sibling \"should include known instruction names\" test already uses ContainElements rather than ConsistOf, so no further edits are needed there. Assisted-by: Claude:claude-opus-4-7 Signed-off-by: TLoE419 --- core/http/endpoints/localai/api_instructions_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 { From 0369dcce4e91ee4f4730cf568cc74f30dd9e8388 Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Sun, 24 May 2026 21:29:44 -0700 Subject: [PATCH 7/9] refactor(chathistory): switch store to GORM-backed authDB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses mudler's review on #9902. Chat history was the only per-user state in LocalAI sitting in a separate filesystem store instead of the shared GORM database — AgentStore and JobStore both live there. Move it onto Application.authDB so per-user state stays in one place. * ConversationRecord (GORM model) with composite primary key (user_id, conv_id) — React mints IDs locally with no global coordination so per-user uniqueness is the natural shape. * Store.New(db *gorm.DB) replaces Store.New(baseDir string). Save / Get / List / Delete / DeleteAll / ReplaceAll keep their signatures so HTTP handlers and the React UI are untouched. * ReplaceAll runs in a transaction with an Unscoped() pre-clear so a future retention sweep cannot resurrect a stale ID on re-upload. * gorm.DeletedAt in the schema means a future retention pruner is a one-line query — addresses the second half of the review (unbounded in-memory map). The map is gone: reads go straight to the DB. Trade-off worth flagging in the PR reply: chat history persistence now requires auth.Enabled = true (that's what initialises Application.authDB). With auth disabled the React UI transparently falls back to localStorage, the same path the original PR took on 404. Consistent with AgentStore / JobStore; if a "chat history always on" mode is preferred we can pull DB init out of auth.InitDB into a shared helper. Assisted-by: Claude:claude-opus-4-7 [Read Edit Bash] Signed-off-by: TLoE419 --- core/application/application.go | 21 +- core/services/chathistory/store.go | 335 ++++++++++++++--------------- 2 files changed, 169 insertions(+), 187 deletions(-) diff --git a/core/application/application.go b/core/application/application.go index 503fec7b0dff..eea13fb0b583 100644 --- a/core/application/application.go +++ b/core/application/application.go @@ -1,10 +1,8 @@ package application import ( - "cmp" "context" "math/rand/v2" - "path/filepath" "sync" "sync/atomic" "time" @@ -420,15 +418,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")) + // 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 { - xlog.Warn("Chat history persistence disabled: no DataPath or DynamicConfigsDir configured") + a.chatHistoryStore = store } } diff --git a/core/services/chathistory/store.go b/core/services/chathistory/store.go index 98b43b3cf88e..05ea8d9de51a 100644 --- a/core/services/chathistory/store.go +++ b/core/services/chathistory/store.go @@ -1,61 +1,82 @@ // 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. +// 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" - "os" - "path/filepath" "regexp" - "sort" - "strings" - "sync" "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 or unsafe conversation IDs. + // ErrInvalidID is returned for malformed conversation IDs. ErrInvalidID = errors.New("invalid conversation id") - // ErrInvalidUserID is returned for malformed or unsafe user IDs. + // ErrInvalidUserID is returned for malformed 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. +// 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}$`) -// anonymousDir is the directory used when auth is disabled (empty userID). -const anonymousDir = "anonymous" +// 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 -// Store persists conversations to disk, partitioned by userID. -type Store struct { - baseDir string +// 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"` +} - mu sync.Mutex - cache map[string]map[string]schema.Conversation // userID -> id -> conv +// 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 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), +// 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 } -// validateID checks the conversation ID against idRegex. func validateID(id string) error { if !idRegex.MatchString(id) { return ErrInvalidID @@ -63,111 +84,33 @@ func validateID(id string) error { 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, "..") { + if len(id) > userIDMaxLen { 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 { + 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) } - sort.Slice(out, func(i, j int) bool { - return out[i].UpdatedAt > out[j].UpdatedAt - }) return out, nil } @@ -179,22 +122,26 @@ func (s *Store) Get(userID, id string) (*schema.Conversation, error) { if err := validateID(id); err != nil { return nil, err } - s.mu.Lock() - defer s.mu.Unlock() - - convs, err := s.load(userID) + 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, err + return nil, fmt.Errorf("chathistory: get: %w", err) } - c, ok := convs[id] - if !ok { - return nil, ErrNotFound + 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; -// UpdatedAt is refreshed on every save. +// 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 @@ -202,62 +149,103 @@ func (s *Store) Save(userID string, conv schema.Conversation) (*schema.Conversat 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 { + + // 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 { - conv.CreatedAt = existing.CreatedAt + 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 + } } - } else if conv.CreatedAt == 0 { - 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 - convs[conv.ID] = conv - if err := s.save(userID, convs); err != nil { - return nil, err + 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 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. +// 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 } - } - 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 + 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), + }) } - s.cache[userID] = out - return s.save(userID, out) + + 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 @@ -265,32 +253,25 @@ func (s *Store) Delete(userID, id string) error { 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 + 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 _, ok := convs[id]; !ok { + if result.RowsAffected == 0 { return ErrNotFound } - delete(convs, id) - return s.save(userID, convs) + return nil } -// DeleteAll wipes all conversations for a user. +// 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 } - 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 + return s.db. + Where("user_id = ?", userID). + Delete(&ConversationRecord{}).Error } From 01a80bee6531ce4218d217d835984152e4384cab Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Sun, 24 May 2026 21:29:57 -0700 Subject: [PATCH 8/9] refactor(schema): drop now-unused ConversationsFile envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConversationsFile was the JSON wrapper for the old file-based chat history store — a {Conversations: [...], UpdatedAt: ...} pair serialised to disk under {baseDir}/{userID}/conversations.json. The previous commit replaced that store with a GORM-backed one that writes rows directly as ConversationRecord, leaving the envelope without a consumer. Assisted-by: Claude:claude-opus-4-7 [Read Edit Bash] Signed-off-by: TLoE419 --- core/schema/chat_conversation.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/core/schema/chat_conversation.go b/core/schema/chat_conversation.go index 041843f2019f..0a95c7b21e99 100644 --- a/core/schema/chat_conversation.go +++ b/core/schema/chat_conversation.go @@ -2,7 +2,6 @@ package schema import ( "encoding/json" - "time" ) // Conversation represents a chat conversation persisted server-side. @@ -38,9 +37,3 @@ type ConvTokenUsage struct { 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"` -} From e6109ec8e27623c0e137d85dfdc0f06260cc5a4d Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Sun, 24 May 2026 21:31:48 -0700 Subject: [PATCH 9/9] test(chathistory): switch tests to testutil.SetupTestDB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the AgentStore test setup: spin up a PostgreSQL 16 container via testutil.SetupTestDB(), construct a Store on top of it, exercise CRUD round-trips. The previous file-based fixtures (TempDir, filepath.Join checks) are no longer meaningful now that the store talks GORM. Tests are skipped on macOS (testcontainers needs Docker) and run on CI, same as the other GORM-backed suites in the repo. Also reframed two cases: * "unsafe IDs" → "malformed IDs" — the DB-backed store doesn't care about path traversal, but idRegex still rejects whitespace / control characters so IDs stay safe in logs and HTTP responses. * "anonymous user" no longer pins a directory path (no directory exists anymore); instead it checks the round-trip plus the per-user isolation guarantee. Added a DeleteAll round-trip to cover the bulk-clear endpoint. Assisted-by: Claude:claude-opus-4-7 [Read Edit Bash] Signed-off-by: TLoE419 --- core/services/chathistory/store_test.go | 83 ++++++++++++++++--------- 1 file changed, 55 insertions(+), 28 deletions(-) diff --git a/core/services/chathistory/store_test.go b/core/services/chathistory/store_test.go index 5b3aa3f5f9be..b7546178dad1 100644 --- a/core/services/chathistory/store_test.go +++ b/core/services/chathistory/store_test.go @@ -2,14 +2,15 @@ package chathistory_test import ( "encoding/json" - "os" - "path/filepath" . "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 { @@ -27,13 +28,15 @@ func newConv(id, name string) schema.Conversation { var _ = Describe("Store", func() { var ( - dir string + db *gorm.DB store *chathistory.Store ) BeforeEach(func() { - dir = GinkgoT().TempDir() - store = chathistory.New(dir) + db = testutil.SetupTestDB() + var err error + store, err = chathistory.New(db) + Expect(err).NotTo(HaveOccurred()) }) Context("basic CRUD", func() { @@ -62,15 +65,17 @@ var _ = Describe("Store", func() { }) 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. + // 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 := chathistory.New(dir) - _, err := first.Save("bob", newConv("x", "Hi")) + first, err := chathistory.New(db) + Expect(err).NotTo(HaveOccurred()) + _, err = first.Save("bob", newConv("x", "Hi")) Expect(err).NotTo(HaveOccurred()) - second := chathistory.New(dir) + 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")) @@ -94,10 +99,11 @@ var _ = Describe("Store", func() { }) }) - 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. + 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"}) @@ -112,9 +118,9 @@ var _ = Describe("Store", func() { }) 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. + // 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"} { @@ -132,21 +138,42 @@ var _ = Describe("Store", func() { }) 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() { + // 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()) - 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") + 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)) }) }) })