From 285c58061ab57d68b33d664d7a69c79ecb598c93 Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Mon, 18 May 2026 18:05:41 -0700 Subject: [PATCH 01/17] 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 e8ab2e194df45c9d89b5e38cadd927e4a6daba34 Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Mon, 18 May 2026 18:05:53 -0700 Subject: [PATCH 02/17] 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 7f5aece01e7847dbed3c9c896104486c5818dec9 Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Mon, 18 May 2026 18:06:06 -0700 Subject: [PATCH 03/17] 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 | 21 +++ core/http/app.go | 5 +- core/http/auth/features.go | 10 ++ core/http/auth/permissions.go | 4 +- .../endpoints/localai/api_instructions.go | 6 + core/http/endpoints/localai/chat_history.go | 161 ++++++++++++++++++ core/http/routes/localai.go | 16 +- 7 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 core/http/endpoints/localai/chat_history.go diff --git a/core/application/application.go b/core/application/application.go index a4ec4fc94629..6c8e23664021 100644 --- a/core/application/application.go +++ b/core/application/application.go @@ -15,6 +15,7 @@ import ( mcpTools "github.com/mudler/LocalAI/core/http/endpoints/mcp" "github.com/mudler/LocalAI/core/services/advisorylock" "github.com/mudler/LocalAI/core/services/agentpool" + "github.com/mudler/LocalAI/core/services/chathistory" "github.com/mudler/LocalAI/core/services/cloudproxy/mitm" "github.com/mudler/LocalAI/core/services/facerecognition" "github.com/mudler/LocalAI/core/services/failover" @@ -67,6 +68,7 @@ type Application struct { faceRegistry facerecognition.Registry voiceRegistry voicerecognition.Registry voiceProfileStore *voiceprofile.Store + chatHistoryStore *chathistory.Store authDB *gorm.DB metricsService *monitoring.LocalAIMetricsService statsRecorder *billing.Recorder @@ -283,6 +285,13 @@ func (a *Application) VoiceProfileStore() *voiceprofile.Store { return a.voiceProfileStore } +// 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 @@ -656,6 +665,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 b6211686baf4..81e98248195d 100644 --- a/core/http/app.go +++ b/core/http/app.go @@ -193,6 +193,8 @@ func setModelLoadingRetryAfter(loadErr *nodes.ModelLoadingError, c echo.Context) // @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() @@ -511,7 +513,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 ac7cf410632b..d78b80534c91 100644 --- a/core/http/auth/features.go +++ b/core/http/auth/features.go @@ -153,6 +153,15 @@ var RouteFeatureRegistry = []RouteFeature{ {"POST", "/api/pii/analyze", FeaturePIIFilter}, {"POST", "/api/pii/redact", FeaturePIIFilter}, + // 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}, @@ -216,5 +225,6 @@ func APIFeatureMetas() []FeatureMeta { {FeatureAudioTransform, "Audio Transform", true}, {FeaturePIIFilter, "PII Analyze / Redact", true}, {FeatureDecisions, "Decisions", true}, + {FeatureChatHistory, "Chat History", true}, } } diff --git a/core/http/auth/permissions.go b/core/http/auth/permissions.go index 3f8b6deff7ed..94ca8f4c912d 100644 --- a/core/http/auth/permissions.go +++ b/core/http/auth/permissions.go @@ -63,7 +63,8 @@ const ( // FeaturePIIFilter gates the synchronous PII analyze/redact service // (POST /api/pii/{analyze,redact}). Default ON like the other API // features; the admin-only events log is gated separately in-handler. - FeaturePIIFilter = "pii_filter" + FeaturePIIFilter = "pii_filter" + FeatureChatHistory = "chat_history" ) // AgentFeatures lists agent-related features (default OFF). @@ -80,6 +81,7 @@ var APIFeatures = []string{ FeatureRealtime, FeatureModeration, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores, FeatureFaceRecognition, FeatureVoiceRecognition, FeatureAudioTransform, FeaturePIIFilter, FeatureDecisions, + 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 2e849bf6af63..3cd343d73291 100644 --- a/core/http/endpoints/localai/api_instructions.go +++ b/core/http/endpoints/localai/api_instructions.go @@ -148,6 +148,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 (`score` — a small model ranks each policy label, Arch-Router-style; `colbert` — a reranker scores policy descriptions against the prompt; `knn` — similarity-weighted vote over a curated corpus of labelled example prompts), `policies` (the label vocabulary), `candidates` (downstream model + labels it serves; first candidate whose labels cover the active set wins, so order small → large), and a `fallback`. The knn classifier needs a `knn: { embedding_model }` block instead of a classifier_model, and reads a persisted corpus seeded via POST /api/router/{name}/corpus with `{entries: [{text, labels}]}` (admin-only; texts are embedded server-side, persisted under the state dir, and NEVER returned by any endpoint — GET /api/router/{name}/corpus/stats reports label counts only, DELETE /api/router/{name}/corpus wipes it). knn routes to the fallback whenever the prompt is less similar than knn.similarity_threshold to every corpus entry — out-of-corpus prompts are treated as undecidable rather than guessed. 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 4a78e30e4769..12f524b3cd32 100644 --- a/core/http/routes/localai.go +++ b/core/http/routes/localai.go @@ -33,7 +33,8 @@ func RegisterLocalAIRoutes(router *echo.Echo, app *application.Application, adminMiddleware echo.MiddlewareFunc, mcpJobsMw echo.MiddlewareFunc, - mcpMw echo.MiddlewareFunc) { + mcpMw echo.MiddlewareFunc, + chatHistoryMw echo.MiddlewareFunc) { // Themed index first, then the library's wildcard for its own assets. RegisterSwaggerTheme(router) @@ -550,4 +551,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 1bbe0a3ec9ea80f5318e23ad3e4c7db8d1e24d6c Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Mon, 18 May 2026 18:06:20 -0700 Subject: [PATCH 04/17] 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 16f99f64932b..e523d52a24b0 100644 --- a/core/http/react-ui/src/hooks/useChat.js +++ b/core/http/react-ui/src/hooks/useChat.js @@ -1,8 +1,9 @@ -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 { effectiveSystemPrompt } from '../utils/systemPrompt' 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/ @@ -113,26 +114,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(), } @@ -144,6 +152,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(), @@ -199,7 +221,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) @@ -244,6 +317,10 @@ export function useChat(initialModel = '') { } return filtered }) + serializedSentRef.current.delete(chatId) + if (serverEnabledRef.current === true) { + chatHistoryApi.delete(chatId).catch(() => {}) + } }, [activeChatId]) const deleteAllChats = useCallback(() => { @@ -255,6 +332,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 49297933a115..739ad5aa801a 100644 --- a/core/http/react-ui/src/utils/api.js +++ b/core/http/react-ui/src/utils/api.js @@ -157,6 +157,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 c2702ed30a4c..5442e23fadb3 100644 --- a/core/http/react-ui/src/utils/config.js +++ b/core/http/react-ui/src/utils/config.js @@ -57,6 +57,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 bb680907678ad34da8d587782a6a582004811e9f Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Tue, 19 May 2026 00:08:29 -0700 Subject: [PATCH 05/17] 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 --- .../localai/api_instructions_test.go | 2 +- .../chathistory/chathistory_suite_test.go | 13 + core/services/chathistory/store_test.go | 264 ++++++++---------- 3 files changed, 135 insertions(+), 144 deletions(-) create mode 100644 core/services/chathistory/chathistory_suite_test.go diff --git a/core/http/endpoints/localai/api_instructions_test.go b/core/http/endpoints/localai/api_instructions_test.go index a3b504304b4f..79fae8f52264 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(21)) + Expect(instructions).To(HaveLen(22)) // Verify each instruction has required fields and correct URL format for _, s := range instructions { 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 c6fdd2fe4383838c66dcf63785fe065fca576a5c Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Sun, 24 May 2026 21:29:44 -0700 Subject: [PATCH 06/17] 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 | 19 +- core/services/chathistory/store.go | 335 ++++++++++++++--------------- 2 files changed, 169 insertions(+), 185 deletions(-) diff --git a/core/application/application.go b/core/application/application.go index 6c8e23664021..9c5468ad7454 100644 --- a/core/application/application.go +++ b/core/application/application.go @@ -665,15 +665,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 620b57b2b1a07e8435bbc8ed088f71f1f6d2a232 Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Sun, 24 May 2026 21:29:57 -0700 Subject: [PATCH 07/17] 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 b881566df0cdd848d07388dab433e68052ede260 Mon Sep 17 00:00:00 2001 From: TLoE419 Date: Sun, 24 May 2026 21:31:48 -0700 Subject: [PATCH 08/17] 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)) }) }) }) From 8972605bd85782cecdbbabc638ae7225b9b48e84 Mon Sep 17 00:00:00 2001 From: Stefan Walcz Date: Tue, 13 Oct 2026 10:17:35 +0200 Subject: [PATCH 09/17] test(chathistory): cover the conversation HTTP handlers The seven /api/conversations handlers had no tests. Inject the *chathistory.Store into the handlers instead of the whole Application and move the route table into RegisterChatHistoryRoutes, so a spec can drive the real registration and middleware chain (auth.Middleware plus the chat_history feature gate) against an in-memory SQLite auth database. The new specs cover 401 without credentials on every route, 403 when an admin revoked chat_history, the CRUD round trip, path id over body id, 400 on malformed ids and bodies, per-user isolation, bulk replace and delete-all, and that a nil store registers nothing (the UI then falls back to localStorage). The handlers' nil-store branches are gone: the routes are only registered with a store, so those branches could never run. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Stefan Walcz --- core/http/endpoints/localai/chat_history.go | 37 +--- core/http/routes/chat_history_auth_test.go | 204 ++++++++++++++++++++ core/http/routes/localai.go | 31 +-- 3 files changed, 230 insertions(+), 42 deletions(-) create mode 100644 core/http/routes/chat_history_auth_test.go diff --git a/core/http/endpoints/localai/chat_history.go b/core/http/endpoints/localai/chat_history.go index a381c6a42d3b..446f7ad46ff3 100644 --- a/core/http/endpoints/localai/chat_history.go +++ b/core/http/endpoints/localai/chat_history.go @@ -5,7 +5,6 @@ import ( "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" ) @@ -16,12 +15,8 @@ import ( // @Tags chat-history // @Success 200 {object} map[string]any // @Router /api/conversations [get] -func ListConversationsEndpoint(app *application.Application) echo.HandlerFunc { +func ListConversationsEndpoint(store *chathistory.Store) 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()}) @@ -36,12 +31,8 @@ func ListConversationsEndpoint(app *application.Application) echo.HandlerFunc { // @Tags chat-history // @Param id path string true "Conversation ID" // @Router /api/conversations/{id} [get] -func GetConversationEndpoint(app *application.Application) echo.HandlerFunc { +func GetConversationEndpoint(store *chathistory.Store) 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) { @@ -64,12 +55,8 @@ func GetConversationEndpoint(app *application.Application) echo.HandlerFunc { // @Tags chat-history // @Param body body schema.Conversation true "Conversation payload" // @Router /api/conversations [post] -func SaveConversationEndpoint(app *application.Application) echo.HandlerFunc { +func SaveConversationEndpoint(store *chathistory.Store) 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()}) @@ -95,12 +82,8 @@ func SaveConversationEndpoint(app *application.Application) echo.HandlerFunc { // @Summary Replace all chat conversations // @Tags chat-history // @Router /api/conversations/bulk [put] -func BulkReplaceConversationsEndpoint(app *application.Application) echo.HandlerFunc { +func BulkReplaceConversationsEndpoint(store *chathistory.Store) 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"` } @@ -123,12 +106,8 @@ func BulkReplaceConversationsEndpoint(app *application.Application) echo.Handler // @Tags chat-history // @Param id path string true "Conversation ID" // @Router /api/conversations/{id} [delete] -func DeleteConversationEndpoint(app *application.Application) echo.HandlerFunc { +func DeleteConversationEndpoint(store *chathistory.Store) 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"}) @@ -147,12 +126,8 @@ func DeleteConversationEndpoint(app *application.Application) echo.HandlerFunc { // @Summary Delete all chat conversations for the current user // @Tags chat-history // @Router /api/conversations [delete] -func DeleteAllConversationsEndpoint(app *application.Application) echo.HandlerFunc { +func DeleteAllConversationsEndpoint(store *chathistory.Store) 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()}) } diff --git a/core/http/routes/chat_history_auth_test.go b/core/http/routes/chat_history_auth_test.go new file mode 100644 index 000000000000..998008ffe3ea --- /dev/null +++ b/core/http/routes/chat_history_auth_test.go @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MIT +//go:build auth + +package routes_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/core/http/auth" + "github.com/mudler/LocalAI/core/http/routes" + "github.com/mudler/LocalAI/core/services/chathistory" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gorm.io/gorm" +) + +// chatHistoryServer drives the conversation endpoints through the same +// registration function and middleware chain the real server uses, against +// an in-memory SQLite auth database. +type chatHistoryServer struct { + e *echo.Echo + db *gorm.DB + sessions map[string]string +} + +func newChatHistoryServer() *chatHistoryServer { + db, err := auth.InitDB(":memory:") + Expect(err).NotTo(HaveOccurred()) + sqlDB, err := db.DB() + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(sqlDB.Close) + + store, err := chathistory.New(db) + Expect(err).NotTo(HaveOccurred()) + + s := &chatHistoryServer{e: echo.New(), db: db, sessions: map[string]string{}} + for _, id := range []string{"alice", "bob", "carol"} { + user := &auth.User{ID: id, Email: id + "@example.test", Name: id, Role: auth.RoleUser, Status: auth.StatusActive, Provider: auth.ProviderLocal} + Expect(db.Create(user).Error).To(Succeed()) + session, err := auth.CreateSession(db, id, "") + Expect(err).NotTo(HaveOccurred()) + s.sessions[id] = session + } + // carol had the default-on feature revoked by an admin. + Expect(auth.UpdateUserPermissions(db, "carol", auth.PermissionMap{auth.FeatureChatHistory: false})).To(Succeed()) + + s.e.Use(auth.Middleware(db, config.NewApplicationConfig())) + routes.RegisterChatHistoryRoutes(s.e, store, auth.RequireFeature(db, auth.FeatureChatHistory)) + return s +} + +// do sends a request as user (empty = unauthenticated) and returns the +// recorder; body may be nil. +func (s *chatHistoryServer) do(user, method, path string, body any) *httptest.ResponseRecorder { + var payload *strings.Reader + if body == nil { + payload = strings.NewReader("") + } else if raw, ok := body.(string); ok { + payload = strings.NewReader(raw) + } else { + data, err := json.Marshal(body) + Expect(err).NotTo(HaveOccurred()) + payload = strings.NewReader(string(data)) + } + req := httptest.NewRequest(method, path, payload) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + if user != "" { + req.AddCookie(&http.Cookie{Name: "session", Value: s.sessions[user]}) + } + rec := httptest.NewRecorder() + s.e.ServeHTTP(rec, req) + return rec +} + +func decodeJSON(rec *httptest.ResponseRecorder) map[string]any { + var out map[string]any + Expect(json.Unmarshal(rec.Body.Bytes(), &out)).To(Succeed(), rec.Body.String()) + return out +} + +func listedIDs(rec *httptest.ResponseRecorder) []string { + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + ids := []string{} + for _, c := range decodeJSON(rec)["conversations"].([]any) { + ids = append(ids, c.(map[string]any)["id"].(string)) + } + return ids +} + +func conversationBody(id, name string) map[string]any { + return map[string]any{ + "id": id, + "name": name, + "model": "test-model", + "history": []map[string]any{{"role": "user", "content": "hi"}}, + } +} + +var _ = Describe("Chat history endpoints", func() { + var s *chatHistoryServer + + BeforeEach(func() { + s = newChatHistoryServer() + }) + + DescribeTable("require authentication on every route", + func(method, path string) { + Expect(s.do("", method, path, nil).Code).To(Equal(http.StatusUnauthorized)) + }, + Entry("list", http.MethodGet, "/api/conversations"), + Entry("create", http.MethodPost, "/api/conversations"), + Entry("delete all", http.MethodDelete, "/api/conversations"), + Entry("bulk", http.MethodPut, "/api/conversations/bulk"), + Entry("get", http.MethodGet, "/api/conversations/c1"), + Entry("save", http.MethodPut, "/api/conversations/c1"), + Entry("delete", http.MethodDelete, "/api/conversations/c1"), + ) + + DescribeTable("honour a revoked chat_history permission", + func(method, path string) { + Expect(s.do("carol", method, path, nil).Code).To(Equal(http.StatusForbidden)) + }, + Entry("list", http.MethodGet, "/api/conversations"), + Entry("save", http.MethodPut, "/api/conversations/c1"), + Entry("bulk", http.MethodPut, "/api/conversations/bulk"), + ) + + It("registers nothing without a store, so the UI falls back to localStorage", func() { + e := echo.New() + routes.RegisterChatHistoryRoutes(e, nil, auth.NoopMiddleware()) + req := httptest.NewRequest(http.MethodGet, "/api/conversations", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + Expect(rec.Code).To(Equal(http.StatusNotFound)) + }) + + It("saves, lists, gets and deletes a conversation", func() { + rec := s.do("alice", http.MethodPut, "/api/conversations/c1", conversationBody("c1", "First")) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + saved := decodeJSON(rec) + Expect(saved["createdAt"]).NotTo(BeZero()) + Expect(saved["updatedAt"]).NotTo(BeZero()) + + rec = s.do("alice", http.MethodPost, "/api/conversations", conversationBody("c2", "Second")) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + + Expect(listedIDs(s.do("alice", http.MethodGet, "/api/conversations", nil))).To(ConsistOf("c1", "c2")) + + rec = s.do("alice", http.MethodGet, "/api/conversations/c1", nil) + Expect(rec.Code).To(Equal(http.StatusOK)) + got := decodeJSON(rec) + Expect(got["name"]).To(Equal("First")) + Expect(got["history"]).To(HaveLen(1)) + + Expect(s.do("alice", http.MethodDelete, "/api/conversations/c1", nil).Code).To(Equal(http.StatusOK)) + Expect(listedIDs(s.do("alice", http.MethodGet, "/api/conversations", nil))).To(ConsistOf("c2")) + }) + + It("takes the id from the path over the body", func() { + rec := s.do("alice", http.MethodPut, "/api/conversations/from-path", conversationBody("from-body", "x")) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + Expect(listedIDs(s.do("alice", http.MethodGet, "/api/conversations", nil))).To(ConsistOf("from-path")) + }) + + It("returns 404 for an unknown conversation", func() { + Expect(s.do("alice", http.MethodGet, "/api/conversations/nope", nil).Code).To(Equal(http.StatusNotFound)) + }) + + It("rejects malformed ids and bodies with 400", func() { + Expect(s.do("alice", http.MethodPut, "/api/conversations/bad!id", conversationBody("x", "x")).Code).To(Equal(http.StatusBadRequest)) + Expect(s.do("alice", http.MethodGet, "/api/conversations/bad!id", nil).Code).To(Equal(http.StatusBadRequest)) + Expect(s.do("alice", http.MethodDelete, "/api/conversations/bad!id", nil).Code).To(Equal(http.StatusBadRequest)) + Expect(s.do("alice", http.MethodPost, "/api/conversations", `{"id":`).Code).To(Equal(http.StatusBadRequest)) + Expect(s.do("alice", http.MethodPut, "/api/conversations/bulk", `{"conversations":`).Code).To(Equal(http.StatusBadRequest)) + Expect(s.do("alice", http.MethodPut, "/api/conversations/bulk", map[string]any{ + "conversations": []any{conversationBody("ok", "ok"), conversationBody("bad id", "x")}, + }).Code).To(Equal(http.StatusBadRequest)) + }) + + It("keeps users apart", func() { + Expect(s.do("alice", http.MethodPut, "/api/conversations/a1", conversationBody("a1", "alice")).Code).To(Equal(http.StatusOK)) + Expect(listedIDs(s.do("bob", http.MethodGet, "/api/conversations", nil))).To(BeEmpty()) + Expect(s.do("bob", http.MethodGet, "/api/conversations/a1", nil).Code).To(Equal(http.StatusNotFound)) + }) + + It("replaces the whole set in bulk and wipes it on delete-all", func() { + Expect(s.do("alice", http.MethodPut, "/api/conversations/old", conversationBody("old", "old")).Code).To(Equal(http.StatusOK)) + + rec := s.do("alice", http.MethodPut, "/api/conversations/bulk", map[string]any{ + "conversations": []any{conversationBody("n1", "n1"), conversationBody("n2", "n2")}, + }) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + Expect(decodeJSON(rec)["count"]).To(BeNumerically("==", 2)) + Expect(listedIDs(s.do("alice", http.MethodGet, "/api/conversations", nil))).To(ConsistOf("n1", "n2")) + + Expect(s.do("alice", http.MethodDelete, "/api/conversations", nil).Code).To(Equal(http.StatusOK)) + Expect(listedIDs(s.do("alice", http.MethodGet, "/api/conversations", nil))).To(BeEmpty()) + }) +}) diff --git a/core/http/routes/localai.go b/core/http/routes/localai.go index 12f524b3cd32..fc87f6981d89 100644 --- a/core/http/routes/localai.go +++ b/core/http/routes/localai.go @@ -9,6 +9,7 @@ import ( mcpTools "github.com/mudler/LocalAI/core/http/endpoints/mcp" "github.com/mudler/LocalAI/core/http/middleware" "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/chathistory" compressionservice "github.com/mudler/LocalAI/core/services/compression" "github.com/mudler/LocalAI/core/services/galleryop" "github.com/mudler/LocalAI/core/services/monitoring" @@ -551,17 +552,25 @@ 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) + // Chat history persistence (#9432). Skipped entirely when the store is + // nil (WebUI disabled or no auth database) — registering routes that + // could only ever fail adds surface area for nothing. + if app != nil { + RegisterChatHistoryRoutes(router, app.ChatHistoryStore(), chatHistoryMw) } +} +// RegisterChatHistoryRoutes wires the per-user conversation endpoints. A nil +// store registers nothing, so the React UI sees 404 and stays on localStorage. +func RegisterChatHistoryRoutes(router *echo.Echo, store *chathistory.Store, chatHistoryMw echo.MiddlewareFunc) { + if store == nil { + return + } + router.GET("/api/conversations", localai.ListConversationsEndpoint(store), chatHistoryMw) + router.POST("/api/conversations", localai.SaveConversationEndpoint(store), chatHistoryMw) + router.DELETE("/api/conversations", localai.DeleteAllConversationsEndpoint(store), chatHistoryMw) + router.PUT("/api/conversations/bulk", localai.BulkReplaceConversationsEndpoint(store), chatHistoryMw) + router.GET("/api/conversations/:id", localai.GetConversationEndpoint(store), chatHistoryMw) + router.PUT("/api/conversations/:id", localai.SaveConversationEndpoint(store), chatHistoryMw) + router.DELETE("/api/conversations/:id", localai.DeleteConversationEndpoint(store), chatHistoryMw) } From 78da518ffa512a80dce89b112bf5430c00b32cdd Mon Sep 17 00:00:00 2001 From: Stefan Walcz Date: Tue, 13 Oct 2026 10:23:38 +0200 Subject: [PATCH 10/17] fix(chathistory): make deletions final with tombstones and versions A conversation deleted on one device came back as soon as a client with a stale copy wrote it again: Delete soft-deleted the row, and the next Save went through GORM's upsert, which cleared deleted_at and resurrected it for every device. The lookup that recovered CreatedAt also ran outside any transaction, so concurrent writers raced on it. The server is now authoritative over deletions: * Delete turns the row into a tombstone: content dropped, deleted_at set. It is idempotent, and deleting an id the server has not seen yet still records a tombstone, so a delete that overtakes the UI's in-flight debounced save of the same chat still wins. * Save runs in a transaction, refuses a tombstoned id with ErrDeleted (HTTP 410 Gone), and bumps the version with a single UPDATE ... SET version = version + 1 guarded by deleted_at IS NULL, so concurrent writers each count and a delete committed between the lookup and the write still wins. Two concurrent first writes of the same id retry once (ON CONFLICT DO NOTHING, then update). * Every row carries a server-assigned version (1 on the first save, +1 per write including the delete), returned to the client. A non-zero version tells the UI the server has seen a conversation, which is how it can tell a never-synced draft from a server-side deletion. * GET /api/conversations returns the tombstones under "deleted". * DeleteAll and ReplaceAll leave tombstones too. ReplaceAll no longer hard-deletes everything first; it upserts the payload, skips ids that were deleted (reported in "skipped"), and tombstones the rest. * createdAt/updatedAt are stored as the client sends them (only zero values are stamped), so clients compare updatedAt from their own clocks rather than against the server's. The store specs now run against PostgreSQL (testcontainers) and, with the auth tag, SQLite through auth.InitDB, since both back the auth database. The API specs cover a stale client reconnecting after a remote deletion. Swagger is regenerated with swag v1.16.6 (json.RawMessage fields get swaggertype), and the feature is documented, including that it needs user auth. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Stefan Walcz --- .../endpoints/localai/api_instructions.go | 4 +- .../localai/api_instructions_test.go | 1 + core/http/endpoints/localai/chat_history.go | 157 ++++--- core/http/routes/chat_history_auth_test.go | 60 +++ core/http/routes/localai.go | 2 +- core/schema/chat_conversation.go | 37 +- core/services/chathistory/store.go | 363 ++++++++++----- .../services/chathistory/store_sqlite_test.go | 27 ++ core/services/chathistory/store_test.go | 196 +++++++- docs/content/features/api-discovery.md | 1 + docs/content/features/chat-history.md | 72 +++ docs/content/features/chat.md | 2 +- swagger/docs.go | 423 ++++++++++++++++++ swagger/swagger.json | 423 ++++++++++++++++++ swagger/swagger.yaml | 289 ++++++++++++ 15 files changed, 1890 insertions(+), 167 deletions(-) create mode 100644 core/services/chathistory/store_sqlite_test.go create mode 100644 docs/content/features/chat-history.md diff --git a/core/http/endpoints/localai/api_instructions.go b/core/http/endpoints/localai/api_instructions.go index 3cd343d73291..189c01b14021 100644 --- a/core/http/endpoints/localai/api_instructions.go +++ b/core/http/endpoints/localai/api_instructions.go @@ -150,9 +150,9 @@ var instructionDefs = []instructionDef{ }, { Name: "chat-history", - Description: "Server-side persistence of WebUI chat conversations (#9432)", + Description: "Server-side persistence of WebUI chat conversations", 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).", + Intro: "Per-user CRUD over chat conversations; only registered when user auth is enabled, and gated by the `chat_history` feature. POST /api/conversations and PUT /api/conversations/{id} upsert and return the conversation with a server-assigned `version` (1 on the first save, +1 per write). createdAt/updatedAt are client Unix milliseconds stored as sent, so clients compare updatedAt to pick the newer copy. Deletes are final: DELETE leaves a tombstone (idempotent, also for ids never saved), any later write of that id returns 410 Gone, and GET /api/conversations returns tombstones under `deleted` so a client drops stale local copies instead of re-uploading them. PUT /api/conversations/bulk makes the payload the live set (others are tombstoned) and reports refused deleted ids in `skipped`.", }, } diff --git a/core/http/endpoints/localai/api_instructions_test.go b/core/http/endpoints/localai/api_instructions_test.go index 79fae8f52264..7106c10e34f7 100644 --- a/core/http/endpoints/localai/api_instructions_test.go +++ b/core/http/endpoints/localai/api_instructions_test.go @@ -83,6 +83,7 @@ var _ = Describe("API Instructions Endpoints", func() { "3d", "failover", "decisions", + "chat-history", )) }) }) diff --git a/core/http/endpoints/localai/chat_history.go b/core/http/endpoints/localai/chat_history.go index 446f7ad46ff3..f407f5e2db76 100644 --- a/core/http/endpoints/localai/chat_history.go +++ b/core/http/endpoints/localai/chat_history.go @@ -9,19 +9,45 @@ import ( "github.com/mudler/LocalAI/core/services/chathistory" ) -// ListConversationsEndpoint lists all stored conversations for the current user. +// chatHistoryError maps store errors onto HTTP status codes. A deleted +// conversation is 410 Gone, so a client holding a stale copy can tell it +// apart from a transient failure and drop the copy instead of retrying. +func chatHistoryError(c echo.Context, err error) error { + switch { + case errors.Is(err, chathistory.ErrInvalidID), errors.Is(err, chathistory.ErrInvalidUserID): + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + case errors.Is(err, chathistory.ErrNotFound): + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + case errors.Is(err, chathistory.ErrDeleted): + return c.JSON(http.StatusGone, map[string]string{"error": err.Error()}) + case errors.Is(err, chathistory.ErrConflict): + return c.JSON(http.StatusConflict, map[string]string{"error": err.Error()}) + default: + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } +} + +// ListConversationsEndpoint lists the current user's conversations, plus the +// ids of conversations the user deleted (tombstones). // -// @Summary List chat conversations -// @Tags chat-history -// @Success 200 {object} map[string]any -// @Router /api/conversations [get] +// @Summary List chat conversations +// @Description Returns the live conversations, newest first, and in `deleted` the conversations the user deleted. A client must drop any local copy whose id appears in `deleted` instead of uploading it again. +// @Tags chat-history +// @Produce json +// @Success 200 {object} schema.ConversationList +// @Router /api/conversations [get] func ListConversationsEndpoint(store *chathistory.Store) echo.HandlerFunc { return func(c echo.Context) error { - convs, err := store.List(getUserID(c)) + userID := getUserID(c) + convs, err := store.List(userID) if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return chatHistoryError(c, err) } - return c.JSON(http.StatusOK, map[string]any{"conversations": convs}) + deleted, err := store.Tombstones(userID) + if err != nil { + return chatHistoryError(c, err) + } + return c.JSON(http.StatusOK, schema.ConversationList{Conversations: convs, Deleted: deleted}) } } @@ -29,19 +55,17 @@ func ListConversationsEndpoint(store *chathistory.Store) echo.HandlerFunc { // // @Summary Get a chat conversation // @Tags chat-history -// @Param id path string true "Conversation ID" +// @Produce json +// @Param id path string true "Conversation ID" +// @Success 200 {object} schema.Conversation +// @Failure 404 {object} map[string]string "Never existed" +// @Failure 410 {object} map[string]string "Deleted" // @Router /api/conversations/{id} [get] func GetConversationEndpoint(store *chathistory.Store) echo.HandlerFunc { return func(c echo.Context) error { 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 chatHistoryError(c, err) } return c.JSON(http.StatusOK, conv) } @@ -51,10 +75,16 @@ func GetConversationEndpoint(store *chathistory.Store) echo.HandlerFunc { // 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] +// @Summary Save a chat conversation (upsert) +// @Description Stores the conversation and returns it with its new server-assigned `version`. `createdAt`/`updatedAt` are kept as sent (Unix ms). Writing a conversation the user deleted returns 410 and does not bring it back. +// @Tags chat-history +// @Accept json +// @Produce json +// @Param body body schema.Conversation true "Conversation payload" +// @Success 200 {object} schema.Conversation +// @Failure 400 {object} map[string]string "Invalid id or body" +// @Failure 410 {object} map[string]string "Deleted" +// @Router /api/conversations [post] func SaveConversationEndpoint(store *chathistory.Store) echo.HandlerFunc { return func(c echo.Context) error { var conv schema.Conversation @@ -66,70 +96,93 @@ func SaveConversationEndpoint(store *chathistory.Store) echo.HandlerFunc { } 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 chatHistoryError(c, err) } 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. +// SaveConversationByIDEndpoint is SaveConversationEndpoint under its own +// name so the PUT route gets its own swagger entry. // -// @Summary Replace all chat conversations +// @Summary Save a chat conversation by id (upsert) // @Tags chat-history -// @Router /api/conversations/bulk [put] +// @Accept json +// @Produce json +// @Param id path string true "Conversation ID (overrides the body id)" +// @Param body body schema.Conversation true "Conversation payload" +// @Success 200 {object} schema.Conversation +// @Failure 400 {object} map[string]string "Invalid id or body" +// @Failure 410 {object} map[string]string "Deleted" +// @Router /api/conversations/{id} [put] +func SaveConversationByIDEndpoint(store *chathistory.Store) echo.HandlerFunc { + return SaveConversationEndpoint(store) +} + +// BulkReplaceConversationsEndpoint replaces the user's live conversation set +// in one transaction. +// +// @Summary Replace all chat conversations +// @Description Upserts every conversation in the payload and deletes the user's other live conversations. Ids the user deleted earlier are not written and are listed in `skipped`. +// @Tags chat-history +// @Accept json +// @Produce json +// @Param body body schema.ConversationBulkRequest true "Conversations" +// @Success 200 {object} schema.ConversationBulkResponse +// @Failure 400 {object} map[string]string "Invalid id or body" +// @Router /api/conversations/bulk [put] func BulkReplaceConversationsEndpoint(store *chathistory.Store) echo.HandlerFunc { return func(c echo.Context) error { - var payload struct { - Conversations []schema.Conversation `json:"conversations"` - } + var payload schema.ConversationBulkRequest 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()}) + skipped, err := store.ReplaceAll(getUserID(c), payload.Conversations) + if err != nil { + return chatHistoryError(c, err) + } + if skipped == nil { + skipped = []string{} } - return c.JSON(http.StatusOK, map[string]any{"status": "ok", "count": len(payload.Conversations)}) + return c.JSON(http.StatusOK, schema.ConversationBulkResponse{ + Status: "ok", + Count: len(payload.Conversations) - len(skipped), + Skipped: skipped, + }) } } -// DeleteConversationEndpoint removes a single conversation. +// DeleteConversationEndpoint deletes a single conversation, leaving a +// tombstone. Idempotent. // -// @Summary Delete a chat conversation -// @Tags chat-history -// @Param id path string true "Conversation ID" -// @Router /api/conversations/{id} [delete] +// @Summary Delete a chat conversation +// @Description Deletes the conversation and keeps a tombstone so no client can write it back. Deleting an unknown or already deleted id succeeds. +// @Tags chat-history +// @Produce json +// @Param id path string true "Conversation ID" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string "Invalid id" +// @Router /api/conversations/{id} [delete] func DeleteConversationEndpoint(store *chathistory.Store) echo.HandlerFunc { return func(c echo.Context) error { 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 chatHistoryError(c, err) } return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) } } -// DeleteAllConversationsEndpoint wipes the user's entire chat history. +// DeleteAllConversationsEndpoint deletes the user's entire chat history. // // @Summary Delete all chat conversations for the current user // @Tags chat-history +// @Produce json +// @Success 200 {object} map[string]string // @Router /api/conversations [delete] func DeleteAllConversationsEndpoint(store *chathistory.Store) echo.HandlerFunc { return func(c echo.Context) error { if err := store.DeleteAll(getUserID(c)); err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return chatHistoryError(c, err) } return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) } diff --git a/core/http/routes/chat_history_auth_test.go b/core/http/routes/chat_history_auth_test.go index 998008ffe3ea..0bc71562be3a 100644 --- a/core/http/routes/chat_history_auth_test.go +++ b/core/http/routes/chat_history_auth_test.go @@ -201,4 +201,64 @@ var _ = Describe("Chat history endpoints", func() { Expect(s.do("alice", http.MethodDelete, "/api/conversations", nil).Code).To(Equal(http.StatusOK)) Expect(listedIDs(s.do("alice", http.MethodGet, "/api/conversations", nil))).To(BeEmpty()) }) + + // Review on #9902: a conversation deleted on device A must not come back + // when device B, holding a stale copy, reconnects and writes it again. + Context("a stale client reconnecting after a remote deletion", func() { + It("gets 410 for the write, and the deletion stays visible to everyone", func() { + // Device A and device B both have c1. + Expect(s.do("alice", http.MethodPut, "/api/conversations/c1", conversationBody("c1", "shared")).Code).To(Equal(http.StatusOK)) + // Device A deletes it. + Expect(s.do("alice", http.MethodDelete, "/api/conversations/c1", nil).Code).To(Equal(http.StatusOK)) + + // Device B comes back with its stale copy and pushes it. + rec := s.do("alice", http.MethodPut, "/api/conversations/c1", conversationBody("c1", "stale copy")) + Expect(rec.Code).To(Equal(http.StatusGone), rec.Body.String()) + rec = s.do("alice", http.MethodPost, "/api/conversations", conversationBody("c1", "stale copy")) + Expect(rec.Code).To(Equal(http.StatusGone), rec.Body.String()) + + // The list keeps it out and tells B why, so B can drop its copy + // instead of treating it as an unsynced draft. + rec = s.do("alice", http.MethodGet, "/api/conversations", nil) + Expect(listedIDs(rec)).To(BeEmpty()) + deleted := decodeJSON(rec)["deleted"].([]any) + Expect(deleted).To(HaveLen(1)) + tomb := deleted[0].(map[string]any) + Expect(tomb["id"]).To(Equal("c1")) + Expect(tomb["deletedAt"]).To(BeNumerically(">", 0)) + Expect(tomb["version"]).To(BeNumerically("==", 2)) + + Expect(s.do("alice", http.MethodGet, "/api/conversations/c1", nil).Code).To(Equal(http.StatusGone)) + }) + + It("is not undone by a bulk upload either", func() { + Expect(s.do("alice", http.MethodPut, "/api/conversations/c1", conversationBody("c1", "shared")).Code).To(Equal(http.StatusOK)) + Expect(s.do("alice", http.MethodDelete, "/api/conversations", nil).Code).To(Equal(http.StatusOK)) + + rec := s.do("alice", http.MethodPut, "/api/conversations/bulk", map[string]any{ + "conversations": []any{conversationBody("c1", "stale"), conversationBody("fresh", "new")}, + }) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + body := decodeJSON(rec) + Expect(body["count"]).To(BeNumerically("==", 1)) + Expect(body["skipped"]).To(ConsistOf("c1")) + Expect(listedIDs(s.do("alice", http.MethodGet, "/api/conversations", nil))).To(ConsistOf("fresh")) + }) + + It("holds even when the delete reached the server before the first save", func() { + Expect(s.do("alice", http.MethodDelete, "/api/conversations/never-saved", nil).Code).To(Equal(http.StatusOK)) + Expect(s.do("alice", http.MethodPut, "/api/conversations/never-saved", conversationBody("never-saved", "late")).Code).To(Equal(http.StatusGone)) + }) + + It("leaves a genuinely new conversation alone", func() { + Expect(s.do("alice", http.MethodPut, "/api/conversations/gone", conversationBody("gone", "x")).Code).To(Equal(http.StatusOK)) + Expect(s.do("alice", http.MethodDelete, "/api/conversations/gone", nil).Code).To(Equal(http.StatusOK)) + + rec := s.do("alice", http.MethodPut, "/api/conversations/draft", conversationBody("draft", "new draft")) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + Expect(decodeJSON(rec)["version"]).To(BeNumerically("==", 1)) + // A tombstone is per user: bob may use the same id. + Expect(s.do("bob", http.MethodPut, "/api/conversations/gone", conversationBody("gone", "bob")).Code).To(Equal(http.StatusOK)) + }) + }) }) diff --git a/core/http/routes/localai.go b/core/http/routes/localai.go index fc87f6981d89..3911fbd763dd 100644 --- a/core/http/routes/localai.go +++ b/core/http/routes/localai.go @@ -571,6 +571,6 @@ func RegisterChatHistoryRoutes(router *echo.Echo, store *chathistory.Store, chat router.DELETE("/api/conversations", localai.DeleteAllConversationsEndpoint(store), chatHistoryMw) router.PUT("/api/conversations/bulk", localai.BulkReplaceConversationsEndpoint(store), chatHistoryMw) router.GET("/api/conversations/:id", localai.GetConversationEndpoint(store), chatHistoryMw) - router.PUT("/api/conversations/:id", localai.SaveConversationEndpoint(store), chatHistoryMw) + router.PUT("/api/conversations/:id", localai.SaveConversationByIDEndpoint(store), chatHistoryMw) router.DELETE("/api/conversations/:id", localai.DeleteConversationEndpoint(store), chatHistoryMw) } diff --git a/core/schema/chat_conversation.go b/core/schema/chat_conversation.go index 0a95c7b21e99..e10b57124b3d 100644 --- a/core/schema/chat_conversation.go +++ b/core/schema/chat_conversation.go @@ -15,12 +15,12 @@ type Conversation struct { ID string `json:"id"` Name string `json:"name"` Model string `json:"model,omitempty"` - History json.RawMessage `json:"history,omitempty"` + History json.RawMessage `json:"history,omitempty" swaggertype:"array,object"` 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"` + ClientMCPServers json.RawMessage `json:"clientMCPServers,omitempty" swaggertype:"array,object"` Temperature *float64 `json:"temperature,omitempty"` TopP *float64 `json:"topP,omitempty"` TopK *float64 `json:"topK,omitempty"` @@ -29,6 +29,39 @@ type Conversation struct { Metadata map[string]string `json:"metadata,omitempty"` CreatedAt int64 `json:"createdAt"` UpdatedAt int64 `json:"updatedAt"` + // Version is assigned by the server: 1 on the first save, +1 on every + // later write including the delete. Clients use a non-zero version to + // tell a conversation the server has seen from a never-synced draft. + // Ignored on input. + Version int64 `json:"version,omitempty"` +} + +// ConversationTombstone marks a conversation the user deleted. The server +// keeps it (without content) so a client holding a stale copy can learn the +// conversation is gone instead of uploading it again. +type ConversationTombstone struct { + ID string `json:"id"` + Version int64 `json:"version"` + DeletedAt int64 `json:"deletedAt"` // Unix milliseconds +} + +// ConversationList is the GET /api/conversations response. +type ConversationList struct { + Conversations []Conversation `json:"conversations"` + Deleted []ConversationTombstone `json:"deleted"` +} + +// ConversationBulkRequest is the PUT /api/conversations/bulk payload. +type ConversationBulkRequest struct { + Conversations []Conversation `json:"conversations"` +} + +// ConversationBulkResponse reports how many conversations a bulk replace +// stored and which ids it refused because they had been deleted. +type ConversationBulkResponse struct { + Status string `json:"status"` + Count int `json:"count"` + Skipped []string `json:"skipped"` } // ConvTokenUsage mirrors the React UI's tokenUsage object on each chat. diff --git a/core/services/chathistory/store.go b/core/services/chathistory/store.go index 05ea8d9de51a..cd6f71702421 100644 --- a/core/services/chathistory/store.go +++ b/core/services/chathistory/store.go @@ -15,6 +15,7 @@ import ( "time" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/mudler/LocalAI/core/schema" ) @@ -26,8 +27,22 @@ var ( ErrInvalidID = errors.New("invalid conversation id") // ErrInvalidUserID is returned for malformed user IDs. ErrInvalidUserID = errors.New("invalid user id") + // ErrDeleted is returned when a conversation was deleted. Its tombstone + // stays so a client with a stale copy cannot write it back. + ErrDeleted = errors.New("conversation was deleted") + // ErrConflict is returned when concurrent writers kept racing on the + // same conversation and the write could not be settled. + ErrConflict = errors.New("conversation changed concurrently, retry") + + // errRaced makes a transaction start over: another writer created the + // row between our lookup and our insert. + errRaced = errors.New("chathistory: raced with a concurrent create") ) +// maxTxAttempts bounds the retry loop for errRaced. One retry settles the +// only race we expect (two first writes of the same id); the rest is margin. +const maxTxAttempts = 3 + // 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 @@ -46,10 +61,15 @@ const userIDMaxLen = 128 // 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. +// +// A row with DeletedAt set is a tombstone: the user deleted the conversation, +// Content is emptied, and the row only stays so that no later write can +// bring the conversation back. Version counts every write to the row. 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"` + Version int64 `gorm:"not null;default:0;column:version"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"index;column:updated_at"` DeletedAt gorm.DeletedAt `gorm:"index;column:deleted_at"` @@ -91,7 +111,8 @@ func validateUserID(id string) error { return nil } -// List returns all conversations for userID, sorted newest-updated first. +// List returns the live conversations for userID, sorted newest-updated first. +// Deleted conversations are reported by Tombstones instead. func (s *Store) List(userID string) ([]schema.Conversation, error) { if err := validateUserID(userID); err != nil { return nil, err @@ -105,16 +126,41 @@ func (s *Store) List(userID string) ([]schema.Conversation, error) { } 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) + c, err := decodeRecord(r) + if err != nil { + return nil, err } - out = append(out, c) + out = append(out, *c) + } + return out, nil +} + +// Tombstones returns the conversations userID deleted, newest deletion first. +func (s *Store) Tombstones(userID string) ([]schema.ConversationTombstone, error) { + if err := validateUserID(userID); err != nil { + return nil, err + } + var rows []ConversationRecord + if err := s.db.Unscoped(). + Select("conv_id", "version", "deleted_at"). + Where("user_id = ? AND deleted_at IS NOT NULL", userID). + Order("deleted_at DESC"). + Find(&rows).Error; err != nil { + return nil, fmt.Errorf("chathistory: tombstones: %w", err) + } + out := make([]schema.ConversationTombstone, 0, len(rows)) + for _, r := range rows { + out = append(out, schema.ConversationTombstone{ + ID: r.ConvID, + Version: r.Version, + DeletedAt: r.DeletedAt.Time.UnixMilli(), + }) } return out, nil } -// Get returns a single conversation, or ErrNotFound if absent. +// Get returns a single conversation: ErrNotFound if it never existed, +// ErrDeleted if the user deleted it. func (s *Store) Get(userID, id string) (*schema.Conversation, error) { if err := validateUserID(userID); err != nil { return nil, err @@ -122,26 +168,27 @@ func (s *Store) Get(userID, id string) (*schema.Conversation, error) { 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 - } + row, err := lookup(s.db, userID, id) if err != nil { - return nil, fmt.Errorf("chathistory: get: %w", err) + return nil, err } - var c schema.Conversation - if err := json.Unmarshal([]byte(row.Content), &c); err != nil { - return nil, fmt.Errorf("chathistory: unmarshal: %w", err) + if row == nil { + return nil, ErrNotFound } - return &c, nil + if row.DeletedAt.Valid { + return nil, ErrDeleted + } + return decodeRecord(*row) } -// 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. +// Save upserts a conversation and returns it with its new Version. A +// conversation the user deleted is never written again: Save returns +// ErrDeleted, so a client holding a stale copy cannot resurrect it. +// +// CreatedAt and UpdatedAt are client-managed Unix milliseconds and are +// stored as sent; the UI compares UpdatedAt across devices, so the server +// clock must not replace it. Zero values are stamped with the current time, +// and a zero CreatedAt on an update keeps the stored one. func (s *Store) Save(userID string, conv schema.Conversation) (*schema.Conversation, error) { if err := validateUserID(userID); err != nil { return nil, err @@ -149,103 +196,147 @@ func (s *Store) Save(userID string, conv schema.Conversation) (*schema.Conversat if err := validateID(conv.ID); err != nil { return nil, err } + var saved *schema.Conversation + err := s.transaction(func(tx *gorm.DB) error { + var err error + saved, err = saveInTx(tx, userID, conv) + return err + }) + if err != nil { + return nil, err + } + return saved, nil +} - now := time.Now().UnixMilli() +// saveInTx does the lookup and the write in the caller's transaction. The +// version bump is a single UPDATE ... SET version = version + 1 guarded by +// deleted_at IS NULL, so concurrent writers each count, and a delete that +// commits between the lookup and the write still wins. +func saveInTx(tx *gorm.DB, userID string, conv schema.Conversation) (*schema.Conversation, error) { + existing, err := lookup(tx, userID, conv.ID) + if err != nil { + return nil, err + } + if existing != nil && existing.DeletedAt.Valid { + return nil, ErrDeleted + } - // 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 { + now := time.Now().UnixMilli() + if conv.CreatedAt == 0 { + conv.CreatedAt = now + if existing != nil { + // Best-effort: a malformed previous row just gets a fresh stamp. + if prev, derr := decodeRecord(*existing); derr == nil && prev.CreatedAt != 0 { 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 - + if conv.UpdatedAt == 0 { + conv.UpdatedAt = now + } + // The version lives in its own column; keep it out of the stored JSON. + conv.Version = 0 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), + + if existing == nil { + res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ConversationRecord{ + UserID: userID, + ConvID: conv.ID, + Content: string(data), + Version: 1, + }) + if res.Error != nil { + return nil, fmt.Errorf("chathistory: insert: %w", res.Error) + } + if res.RowsAffected == 0 { + return nil, errRaced + } + conv.Version = 1 + return &conv, nil } - // 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) + + // The default scope adds deleted_at IS NULL. + res := tx.Model(&ConversationRecord{}). + Where("user_id = ? AND conv_id = ?", userID, conv.ID). + Updates(map[string]any{ + "content": string(data), + "version": gorm.Expr("version + 1"), + }) + if res.Error != nil { + return nil, fmt.Errorf("chathistory: update: %w", res.Error) + } + if res.RowsAffected == 0 { + return nil, ErrDeleted + } + var version int64 + if err := tx.Model(&ConversationRecord{}). + Select("version"). + Where("user_id = ? AND conv_id = ?", userID, conv.ID). + Scan(&version).Error; err != nil { + return nil, fmt.Errorf("chathistory: read version: %w", err) } + conv.Version = version 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 { +// ReplaceAll makes convs the user's live conversation set in one +// transaction: each one is upserted, and every other live conversation is +// deleted (tombstoned, so the deletion reaches other devices too). Ids the +// user already deleted are not written and are returned in skipped. +func (s *Store) ReplaceAll(userID string, convs []schema.Conversation) (skipped []string, err error) { if err := validateUserID(userID); err != nil { - return err + return nil, 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 + return nil, err } - 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(). + err = s.transaction(func(tx *gorm.DB) error { + skipped = skipped[:0] + keep := make(map[string]struct{}, len(convs)) + for _, c := range convs { + keep[c.ID] = struct{}{} + if _, err := saveInTx(tx, userID, c); err != nil { + if errors.Is(err, ErrDeleted) { + skipped = append(skipped, c.ID) + continue + } + return err + } + } + + var live []string + if err := tx.Model(&ConversationRecord{}). Where("user_id = ?", userID). - Delete(&ConversationRecord{}).Error; err != nil { - return fmt.Errorf("chathistory: replace clear: %w", err) + Pluck("conv_id", &live).Error; err != nil { + return fmt.Errorf("chathistory: replace list: %w", err) + } + var drop []string + for _, id := range live { + if _, ok := keep[id]; !ok { + drop = append(drop, id) + } } - if len(rows) == 0 { + if len(drop) == 0 { return nil } - return tx.Create(&rows).Error + return tombstone(tx.Where("conv_id IN ?", drop), userID) }) + if err != nil { + return nil, err + } + return skipped, nil } -// 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. +// Delete deletes a conversation by turning it into a tombstone. Deleting is +// idempotent, and deleting an id the server has not seen yet still records +// a tombstone: the UI deletes immediately while a debounced save of the same +// conversation may still be in flight, and that save must not win. func (s *Store) Delete(userID, id string) error { if err := validateUserID(userID); err != nil { return err @@ -253,25 +344,89 @@ func (s *Store) Delete(userID, id string) error { 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 + return s.transaction(func(tx *gorm.DB) error { + existing, err := lookup(tx, userID, id) + if err != nil { + return err + } + if existing == nil { + res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ConversationRecord{ + UserID: userID, + ConvID: id, + Version: 1, + DeletedAt: gorm.DeletedAt{Time: time.Now(), Valid: true}, + }) + if res.Error != nil { + return fmt.Errorf("chathistory: delete: %w", res.Error) + } + if res.RowsAffected == 0 { + return errRaced + } + return nil + } + if existing.DeletedAt.Valid { + return nil + } + return tombstone(tx.Where("conv_id = ?", id), userID) + }) } -// 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. +// DeleteAll deletes every conversation of a user, leaving tombstones so the +// deletion reaches the user's other devices. 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 + return tombstone(s.db, userID) +} + +// tombstone turns the user's live rows matched by scope into tombstones: +// content dropped, version bumped, deleted_at set. +func tombstone(scope *gorm.DB, userID string) error { + err := scope.Unscoped(). + Model(&ConversationRecord{}). + Where("user_id = ? AND deleted_at IS NULL", userID). + Updates(map[string]any{ + "content": "", + "version": gorm.Expr("version + 1"), + "deleted_at": time.Now(), + }).Error + if err != nil { + return fmt.Errorf("chathistory: delete: %w", err) + } + return nil +} + +// transaction runs fn in a transaction and starts over when it lost a race +// with a concurrent first write of the same id. +func (s *Store) transaction(fn func(tx *gorm.DB) error) error { + for range maxTxAttempts { + err := s.db.Transaction(fn) + if !errors.Is(err, errRaced) { + return err + } + } + return ErrConflict +} + +// lookup returns the row for (userID, id) including tombstones, or nil. +func lookup(db *gorm.DB, userID, id string) (*ConversationRecord, error) { + var row ConversationRecord + err := db.Unscoped().Where("user_id = ? AND conv_id = ?", userID, id).Take(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("chathistory: lookup: %w", err) + } + return &row, nil +} + +func decodeRecord(r ConversationRecord) (*schema.Conversation, error) { + 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) + } + c.Version = r.Version + return &c, nil } diff --git a/core/services/chathistory/store_sqlite_test.go b/core/services/chathistory/store_sqlite_test.go new file mode 100644 index 000000000000..bf77a8f7fce9 --- /dev/null +++ b/core/services/chathistory/store_sqlite_test.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +//go:build auth + +package chathistory_test + +import ( + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gorm.io/gorm" + + "github.com/mudler/LocalAI/core/http/auth" +) + +// SQLite is the default auth database. Opening it through auth.InitDB keeps +// the production DSN (busy timeout, immediate transactions). +var _ = Describe("Store on SQLite", func() { + describeStore(func() *gorm.DB { + db, err := auth.InitDB(filepath.Join(GinkgoT().TempDir(), "auth.db")) + Expect(err).NotTo(HaveOccurred()) + sqlDB, err := db.DB() + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(sqlDB.Close) + return db + }) +}) diff --git a/core/services/chathistory/store_test.go b/core/services/chathistory/store_test.go index b7546178dad1..42da7fa1681c 100644 --- a/core/services/chathistory/store_test.go +++ b/core/services/chathistory/store_test.go @@ -26,14 +26,21 @@ func newConv(id, name string) schema.Conversation { } } -var _ = Describe("Store", func() { +// The store runs on whichever database backs auth: SQLite by default, +// PostgreSQL in distributed setups. Every spec runs against both so the +// tombstone and version SQL cannot drift into one dialect. +var _ = Describe("Store on PostgreSQL", func() { + describeStore(testutil.SetupTestDB) +}) + +func describeStore(openDB func() *gorm.DB) { var ( db *gorm.DB store *chathistory.Store ) BeforeEach(func() { - db = testutil.SetupTestDB() + db = openDB() var err error store, err = chathistory.New(db) Expect(err).NotTo(HaveOccurred()) @@ -60,7 +67,7 @@ var _ = Describe("Store", func() { Expect(store.Delete(userID, "c1")).To(Succeed()) _, err = store.Get(userID, "c1") - Expect(err).To(MatchError(chathistory.ErrNotFound)) + Expect(err).To(MatchError(chathistory.ErrDeleted)) }) }) @@ -128,7 +135,8 @@ var _ = Describe("Store", func() { Expect(err).NotTo(HaveOccurred()) } - Expect(store.ReplaceAll(userID, []schema.Conversation{newConv("z", "z")})).To(Succeed()) + // Second return value (error) must be nil; the skipped list empty. + Expect(store.ReplaceAll(userID, []schema.Conversation{newConv("z", "z")})).To(BeEmpty()) list, err := store.List(userID) Expect(err).NotTo(HaveOccurred()) @@ -176,4 +184,182 @@ var _ = Describe("Store", func() { Expect(bobList).To(HaveLen(1)) }) }) -}) + // A conversation deleted on one device must stay deleted when a client + // with a stale copy writes it again (review on #9902). Deletes leave a + // tombstone the store refuses to overwrite. + Context("tombstones", func() { + const userID = "alice" + + It("rejects a save after delete instead of resurrecting the row", func() { + _, err := store.Save(userID, newConv("c1", "First")) + Expect(err).NotTo(HaveOccurred()) + Expect(store.Delete(userID, "c1")).To(Succeed()) + + _, err = store.Save(userID, newConv("c1", "stale copy")) + Expect(err).To(MatchError(chathistory.ErrDeleted)) + + list, err := store.List(userID) + Expect(err).NotTo(HaveOccurred()) + Expect(list).To(BeEmpty()) + Expect(tombstoneIDs(store, userID)).To(ConsistOf("c1")) + _, err = store.Get(userID, "c1") + Expect(err).To(MatchError(chathistory.ErrDeleted)) + }) + + It("drops the content of a deleted conversation", func() { + _, err := store.Save(userID, newConv("c1", "secret")) + Expect(err).NotTo(HaveOccurred()) + Expect(store.Delete(userID, "c1")).To(Succeed()) + + var row chathistory.ConversationRecord + Expect(db.Unscoped().Where("user_id = ? AND conv_id = ?", userID, "c1").Take(&row).Error).To(Succeed()) + Expect(row.DeletedAt.Valid).To(BeTrue()) + Expect(row.Content).To(BeEmpty()) + }) + + It("records a delete that arrives before the conversation's first save", func() { + // The UI deletes right away while a debounced PUT may still be in + // flight; whichever lands second must not bring the chat back. + Expect(store.Delete(userID, "early")).To(Succeed()) + _, err := store.Save(userID, newConv("early", "late write")) + Expect(err).To(MatchError(chathistory.ErrDeleted)) + }) + + It("treats a repeated delete as success", func() { + _, err := store.Save(userID, newConv("c1", "First")) + Expect(err).NotTo(HaveOccurred()) + Expect(store.Delete(userID, "c1")).To(Succeed()) + Expect(store.Delete(userID, "c1")).To(Succeed()) + Expect(tombstoneIDs(store, userID)).To(ConsistOf("c1")) + }) + + It("tombstones every conversation on DeleteAll", func() { + for _, id := range []string{"a", "b"} { + _, err := store.Save(userID, newConv(id, id)) + Expect(err).NotTo(HaveOccurred()) + } + _, err := store.Save("bob", newConv("a", "bob's a")) + Expect(err).NotTo(HaveOccurred()) + + Expect(store.DeleteAll(userID)).To(Succeed()) + Expect(tombstoneIDs(store, userID)).To(ConsistOf("a", "b")) + _, err = store.Save(userID, newConv("a", "stale")) + Expect(err).To(MatchError(chathistory.ErrDeleted)) + + // bob shares the id, not the tombstone. + _, err = store.Save("bob", newConv("a", "bob again")) + Expect(err).NotTo(HaveOccurred()) + }) + + It("does not let ReplaceAll resurrect deleted ids and tombstones what it replaces", func() { + for _, id := range []string{"a", "b"} { + _, err := store.Save(userID, newConv(id, id)) + Expect(err).NotTo(HaveOccurred()) + } + Expect(store.Delete(userID, "b")).To(Succeed()) + + skipped, err := store.ReplaceAll(userID, []schema.Conversation{newConv("b", "stale b"), newConv("c", "c")}) + Expect(err).NotTo(HaveOccurred()) + Expect(skipped).To(ConsistOf("b")) + + list, err := store.List(userID) + Expect(err).NotTo(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].ID).To(Equal("c")) + // "a" was replaced away: other devices must see it as deleted too. + Expect(tombstoneIDs(store, userID)).To(ConsistOf("a", "b")) + }) + }) + + Context("versions", func() { + const userID = "alice" + + It("starts at 1 and bumps on every write, including the delete", func() { + Expect(savedVersion(store, userID, newConv("c1", "v1"))).To(Equal(int64(1))) + Expect(savedVersion(store, userID, newConv("c1", "v2"))).To(Equal(int64(2))) + + list, err := store.List(userID) + Expect(err).NotTo(HaveOccurred()) + Expect(list[0].Version).To(Equal(int64(2))) + + Expect(store.Delete(userID, "c1")).To(Succeed()) + tombs, err := store.Tombstones(userID) + Expect(err).NotTo(HaveOccurred()) + Expect(tombs).To(HaveLen(1)) + Expect(tombs[0].Version).To(Equal(int64(3))) + Expect(tombs[0].DeletedAt).NotTo(BeZero()) + }) + + It("ignores a client-supplied version", func() { + conv := newConv("c1", "x") + conv.Version = 41 + Expect(savedVersion(store, userID, conv)).To(Equal(int64(1))) + }) + + It("counts every one of many concurrent writes", func() { + Expect(savedVersion(store, userID, newConv("hot", "seed"))).To(Equal(int64(1))) + const writers = 8 + errs := make(chan error, writers) + for i := 0; i < writers; i++ { + go func() { + defer GinkgoRecover() + _, err := store.Save(userID, newConv("hot", "concurrent")) + errs <- err + }() + } + for i := 0; i < writers; i++ { + Expect(<-errs).NotTo(HaveOccurred()) + } + got, err := store.Get(userID, "hot") + Expect(err).NotTo(HaveOccurred()) + Expect(got.Version).To(Equal(int64(1 + writers))) + }) + }) + + Context("timestamps", func() { + const userID = "alice" + + // The UI decides which copy is newer by comparing updatedAt across + // devices, so the server must store the client's value rather than + // its own clock. + It("keeps the client's updatedAt and createdAt", func() { + conv := newConv("c1", "x") + conv.CreatedAt = 1000 + conv.UpdatedAt = 2000 + _, err := store.Save(userID, conv) + Expect(err).NotTo(HaveOccurred()) + got, err := store.Get(userID, "c1") + Expect(err).NotTo(HaveOccurred()) + Expect(got.CreatedAt).To(Equal(int64(1000))) + Expect(got.UpdatedAt).To(Equal(int64(2000))) + }) + + It("keeps the original createdAt when an update omits it", func() { + conv := newConv("c1", "x") + conv.CreatedAt = 1000 + _, err := store.Save(userID, conv) + Expect(err).NotTo(HaveOccurred()) + _, err = store.Save(userID, newConv("c1", "y")) + Expect(err).NotTo(HaveOccurred()) + got, err := store.Get(userID, "c1") + Expect(err).NotTo(HaveOccurred()) + Expect(got.CreatedAt).To(Equal(int64(1000))) + }) + }) +} + +func savedVersion(store *chathistory.Store, userID string, conv schema.Conversation) int64 { + saved, err := store.Save(userID, conv) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + return saved.Version +} + +func tombstoneIDs(store *chathistory.Store, userID string) []string { + tombs, err := store.Tombstones(userID) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + ids := []string{} + for _, t := range tombs { + ids = append(ids, t.ID) + } + return ids +} diff --git a/docs/content/features/api-discovery.md b/docs/content/features/api-discovery.md index 1aaf0783194e..555cd1ec49d2 100644 --- a/docs/content/features/api-discovery.md +++ b/docs/content/features/api-discovery.md @@ -119,6 +119,7 @@ Returns a compact list of instruction areas: | `mcp` | Model Context Protocol - tool-augmented chat with MCP servers | | `agents` | Agent task and job management | | `video` | Video generation from text prompts | +| `chat-history` | Server-side persistence of web UI chat conversations, with tombstones for deletions (auth only) | ### Get an instruction guide diff --git a/docs/content/features/chat-history.md b/docs/content/features/chat-history.md new file mode 100644 index 000000000000..eba12271709b --- /dev/null +++ b/docs/content/features/chat-history.md @@ -0,0 +1,72 @@ ++++ +disableToc = false +title = "Chat History" +weight = 49 +url = "/features/chat-history/" ++++ + +Chat history keeps the conversations of the web UI [Chat]({{%relref "features/chat" %}}) page on the server, per user, so they survive a browser reset and follow you to another device. It is built on the [user authentication system]({{%relref "features/authentication" %}}): conversations live in the same database as users, sessions and API keys (SQLite by default, PostgreSQL when `LOCALAI_AUTH_DATABASE_URL` points at one). + +{{% notice note %}} +Chat history is only active when user authentication is enabled (`LOCALAI_AUTH=true`). Without it, or with only legacy API keys, the endpoints below are not registered and the web UI keeps conversations in the browser (`localai_chats_data` in `localStorage`), as before. Disabling the web UI (`LOCALAI_DISABLE_WEBUI=true`) also disables chat history. +{{% /notice %}} + +## Permission + +Access is controlled by the `chat_history` feature permission. Like the other API features it is **on by default** for new users; an admin can turn it off per user under **Settings → Users and keys**. A user without it gets `403` from every endpoint, and their web UI falls back to browser storage. + +## Deletions are final + +A deleted conversation must not come back because another device still has a copy. The server therefore keeps a **tombstone** for every deleted conversation: the row stays, its content is dropped, and it is marked deleted. + +- Writing a conversation that has a tombstone returns `410 Gone`. Nothing is written. +- `GET /api/conversations` lists tombstones under `deleted`, so a client can drop its local copies of those ids instead of uploading them again. +- `DELETE` is idempotent, and deleting an id the server has never seen still records a tombstone. A delete that overtakes an in-flight save of the same conversation therefore still wins. +- Tombstones are per user. Another user can use the same id. + +Every conversation also carries a server-assigned `version`: `1` after the first save, then one more for every write, including the delete. A client that has a non-zero version for a conversation knows the server has seen it; a conversation without one is a draft that was never uploaded. + +Within a live conversation the last write wins: there is no merge of two devices editing the same conversation at the same time. `createdAt` and `updatedAt` (Unix milliseconds) are stored as the client sends them, so clients can compare `updatedAt` to pick the newer copy; the server only fills them in when they are `0`. + +## API + +All endpoints act on the conversations of the calling user and need a session or API key. + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/conversations` | Live conversations (`conversations`, newest first) and tombstones (`deleted`) | +| `POST` | `/api/conversations` | Save (upsert) the conversation in the body | +| `PUT` | `/api/conversations/{id}` | Save (upsert); the path id overrides the body id | +| `GET` | `/api/conversations/{id}` | One conversation; `404` if it never existed, `410` if it was deleted | +| `DELETE` | `/api/conversations/{id}` | Delete, leaving a tombstone. Idempotent | +| `DELETE` | `/api/conversations` | Delete all of the user's conversations, leaving tombstones | +| `PUT` | `/api/conversations/bulk` | Make the payload the user's live set: upsert each conversation, delete (tombstone) the others. Ids that were deleted earlier are not written and come back in `skipped` | + +Status codes: `400` for a malformed id or body, `401` without credentials, `403` without the `chat_history` permission, `409` when concurrent writers kept racing on the same id (retry), `410` for a deleted conversation. + +Conversation ids are 1 to 128 characters from `A-Z a-z 0-9 . _ -`. The message list (`history`) is stored as sent, so every message shape the UI uses (text, images, audio, files, tool calls, thinking) survives the round trip. + +### Example + +```bash +# Save a conversation +curl -X PUT http://localhost:8080/api/conversations/3f9c2a \ + -H "Authorization: Bearer $LOCALAI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"id":"3f9c2a","name":"Release notes","model":"qwen3-8b", + "history":[{"role":"user","content":"Summarise the changes"}], + "updatedAt":1760342400000}' +# -> {"id":"3f9c2a", ..., "createdAt":1760342400000, "updatedAt":1760342400000, "version":1} + +# Delete it, then try to write it again from a stale client +curl -X DELETE http://localhost:8080/api/conversations/3f9c2a -H "Authorization: Bearer $LOCALAI_API_KEY" +curl -X PUT http://localhost:8080/api/conversations/3f9c2a \ + -H "Authorization: Bearer $LOCALAI_API_KEY" -H "Content-Type: application/json" \ + -d '{"id":"3f9c2a","name":"Release notes"}' +# -> 410 {"error":"conversation was deleted"} + +curl http://localhost:8080/api/conversations -H "Authorization: Bearer $LOCALAI_API_KEY" +# -> {"conversations":[],"deleted":[{"id":"3f9c2a","version":2,"deletedAt":1760342460000}]} +``` + +The machine-readable description is under the `chat-history` tag in the [API discovery]({{%relref "features/api-discovery" %}}) instructions (`GET /api/instructions/chat-history`) and in `/swagger`. diff --git a/docs/content/features/chat.md b/docs/content/features/chat.md index 4b2c9651595b..385f73d01965 100644 --- a/docs/content/features/chat.md +++ b/docs/content/features/chat.md @@ -50,7 +50,7 @@ Enter sends, Shift+Enter adds a line. Paste an image to attach it. Under the box Press `Ctrl+K` (`Cmd+K`) or **Chats** to open the list of conversations, grouped by day like **Jump back in** on Home. Type to search names and message text. Arrow keys move, `Enter` opens, `F2` renames and `Delete` removes. Each row also offers rename, duplicate, copy and export. Removing a chat hides it and shows an **Undo** toast; the chat is deleted for good when the toast goes away. The name in the header can be renamed with a click. -The history is stored in this browser (`localai_chats_data`), not on the server. +The history is stored in this browser (`localai_chats_data`). When [user authentication]({{%relref "features/authentication" %}}) is enabled it is also kept on the server for each user and follows you to other devices; see [Chat History]({{%relref "features/chat-history" %}}). ## Settings diff --git a/swagger/docs.go b/swagger/docs.go index 94861edbfebe..bfb9327804c9 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -881,6 +881,280 @@ const docTemplate = `{ } } }, + "/api/conversations": { + "get": { + "description": "Returns the live conversations, newest first, and in ` + "`" + `deleted` + "`" + ` the conversations the user deleted. A client must drop any local copy whose id appears in ` + "`" + `deleted` + "`" + ` instead of uploading it again.", + "produces": [ + "application/json" + ], + "tags": [ + "chat-history" + ], + "summary": "List chat conversations", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/schema.ConversationList" + } + } + } + }, + "post": { + "description": "Stores the conversation and returns it with its new server-assigned ` + "`" + `version` + "`" + `. ` + "`" + `createdAt` + "`" + `/` + "`" + `updatedAt` + "`" + ` are kept as sent (Unix ms). Writing a conversation the user deleted returns 410 and does not bring it back.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat-history" + ], + "summary": "Save a chat conversation (upsert)", + "parameters": [ + { + "description": "Conversation payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.Conversation" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/schema.Conversation" + } + }, + "400": { + "description": "Invalid id or body", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "410": { + "description": "Deleted", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "chat-history" + ], + "summary": "Delete all chat conversations for the current user", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/conversations/bulk": { + "put": { + "description": "Upserts every conversation in the payload and deletes the user's other live conversations. Ids the user deleted earlier are not written and are listed in ` + "`" + `skipped` + "`" + `.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat-history" + ], + "summary": "Replace all chat conversations", + "parameters": [ + { + "description": "Conversations", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.ConversationBulkRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/schema.ConversationBulkResponse" + } + }, + "400": { + "description": "Invalid id or body", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/conversations/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "chat-history" + ], + "summary": "Get a chat conversation", + "parameters": [ + { + "type": "string", + "description": "Conversation ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/schema.Conversation" + } + }, + "404": { + "description": "Never existed", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "410": { + "description": "Deleted", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat-history" + ], + "summary": "Save a chat conversation by id (upsert)", + "parameters": [ + { + "type": "string", + "description": "Conversation ID (overrides the body id)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Conversation payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.Conversation" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/schema.Conversation" + } + }, + "400": { + "description": "Invalid id or body", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "410": { + "description": "Deleted", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "description": "Deletes the conversation and keeps a tombstone so no client can write it back. Deleting an unknown or already deleted id succeeds.", + "produces": [ + "application/json" + ], + "tags": [ + "chat-history" + ], + "summary": "Delete a chat conversation", + "parameters": [ + { + "type": "string", + "description": "Conversation ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Invalid id", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/api/failover": { "get": { "produces": [ @@ -5846,6 +6120,155 @@ const docTemplate = `{ } } }, + "schema.ConvTokenUsage": { + "type": "object", + "properties": { + "completion": { + "type": "integer" + }, + "prompt": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "schema.Conversation": { + "type": "object", + "properties": { + "clientMCPServers": { + "type": "array", + "items": { + "type": "object" + } + }, + "contextSize": { + "type": "integer" + }, + "createdAt": { + "type": "integer" + }, + "history": { + "type": "array", + "items": { + "type": "object" + } + }, + "id": { + "type": "string" + }, + "mcpMode": { + "type": "boolean" + }, + "mcpResources": { + "type": "array", + "items": { + "type": "string" + } + }, + "mcpServers": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "model": { + "type": "string" + }, + "name": { + "type": "string" + }, + "systemPrompt": { + "type": "string" + }, + "temperature": { + "type": "number" + }, + "tokenUsage": { + "$ref": "#/definitions/schema.ConvTokenUsage" + }, + "topK": { + "type": "number" + }, + "topP": { + "type": "number" + }, + "updatedAt": { + "type": "integer" + }, + "version": { + "description": "Version is assigned by the server: 1 on the first save, +1 on every\nlater write including the delete. Clients use a non-zero version to\ntell a conversation the server has seen from a never-synced draft.\nIgnored on input.", + "type": "integer" + } + } + }, + "schema.ConversationBulkRequest": { + "type": "object", + "properties": { + "conversations": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.Conversation" + } + } + } + }, + "schema.ConversationBulkResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "skipped": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + } + } + }, + "schema.ConversationList": { + "type": "object", + "properties": { + "conversations": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.Conversation" + } + }, + "deleted": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ConversationTombstone" + } + } + } + }, + "schema.ConversationTombstone": { + "type": "object", + "properties": { + "deletedAt": { + "description": "Unix milliseconds", + "type": "integer" + }, + "id": { + "type": "string" + }, + "version": { + "type": "integer" + } + } + }, "schema.DecisionAnswer": { "type": "object", "properties": { diff --git a/swagger/swagger.json b/swagger/swagger.json index 95a87d2c3ea9..65489c11fdbe 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -878,6 +878,280 @@ } } }, + "/api/conversations": { + "get": { + "description": "Returns the live conversations, newest first, and in `deleted` the conversations the user deleted. A client must drop any local copy whose id appears in `deleted` instead of uploading it again.", + "produces": [ + "application/json" + ], + "tags": [ + "chat-history" + ], + "summary": "List chat conversations", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/schema.ConversationList" + } + } + } + }, + "post": { + "description": "Stores the conversation and returns it with its new server-assigned `version`. `createdAt`/`updatedAt` are kept as sent (Unix ms). Writing a conversation the user deleted returns 410 and does not bring it back.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat-history" + ], + "summary": "Save a chat conversation (upsert)", + "parameters": [ + { + "description": "Conversation payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.Conversation" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/schema.Conversation" + } + }, + "400": { + "description": "Invalid id or body", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "410": { + "description": "Deleted", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "chat-history" + ], + "summary": "Delete all chat conversations for the current user", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/conversations/bulk": { + "put": { + "description": "Upserts every conversation in the payload and deletes the user's other live conversations. Ids the user deleted earlier are not written and are listed in `skipped`.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat-history" + ], + "summary": "Replace all chat conversations", + "parameters": [ + { + "description": "Conversations", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.ConversationBulkRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/schema.ConversationBulkResponse" + } + }, + "400": { + "description": "Invalid id or body", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/conversations/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "chat-history" + ], + "summary": "Get a chat conversation", + "parameters": [ + { + "type": "string", + "description": "Conversation ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/schema.Conversation" + } + }, + "404": { + "description": "Never existed", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "410": { + "description": "Deleted", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat-history" + ], + "summary": "Save a chat conversation by id (upsert)", + "parameters": [ + { + "type": "string", + "description": "Conversation ID (overrides the body id)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Conversation payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.Conversation" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/schema.Conversation" + } + }, + "400": { + "description": "Invalid id or body", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "410": { + "description": "Deleted", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "description": "Deletes the conversation and keeps a tombstone so no client can write it back. Deleting an unknown or already deleted id succeeds.", + "produces": [ + "application/json" + ], + "tags": [ + "chat-history" + ], + "summary": "Delete a chat conversation", + "parameters": [ + { + "type": "string", + "description": "Conversation ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Invalid id", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/api/failover": { "get": { "produces": [ @@ -5843,6 +6117,155 @@ } } }, + "schema.ConvTokenUsage": { + "type": "object", + "properties": { + "completion": { + "type": "integer" + }, + "prompt": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "schema.Conversation": { + "type": "object", + "properties": { + "clientMCPServers": { + "type": "array", + "items": { + "type": "object" + } + }, + "contextSize": { + "type": "integer" + }, + "createdAt": { + "type": "integer" + }, + "history": { + "type": "array", + "items": { + "type": "object" + } + }, + "id": { + "type": "string" + }, + "mcpMode": { + "type": "boolean" + }, + "mcpResources": { + "type": "array", + "items": { + "type": "string" + } + }, + "mcpServers": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "model": { + "type": "string" + }, + "name": { + "type": "string" + }, + "systemPrompt": { + "type": "string" + }, + "temperature": { + "type": "number" + }, + "tokenUsage": { + "$ref": "#/definitions/schema.ConvTokenUsage" + }, + "topK": { + "type": "number" + }, + "topP": { + "type": "number" + }, + "updatedAt": { + "type": "integer" + }, + "version": { + "description": "Version is assigned by the server: 1 on the first save, +1 on every\nlater write including the delete. Clients use a non-zero version to\ntell a conversation the server has seen from a never-synced draft.\nIgnored on input.", + "type": "integer" + } + } + }, + "schema.ConversationBulkRequest": { + "type": "object", + "properties": { + "conversations": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.Conversation" + } + } + } + }, + "schema.ConversationBulkResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "skipped": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + } + } + }, + "schema.ConversationList": { + "type": "object", + "properties": { + "conversations": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.Conversation" + } + }, + "deleted": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ConversationTombstone" + } + } + } + }, + "schema.ConversationTombstone": { + "type": "object", + "properties": { + "deletedAt": { + "description": "Unix milliseconds", + "type": "integer" + }, + "id": { + "type": "string" + }, + "version": { + "type": "integer" + } + } + }, "schema.DecisionAnswer": { "type": "object", "properties": { diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 2d1703773980..a15bbe30b3f3 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -967,6 +967,108 @@ definitions: summary_tokens: type: integer type: object + schema.ConvTokenUsage: + properties: + completion: + type: integer + prompt: + type: integer + total: + type: integer + type: object + schema.Conversation: + properties: + clientMCPServers: + items: + type: object + type: array + contextSize: + type: integer + createdAt: + type: integer + history: + items: + type: object + type: array + id: + type: string + mcpMode: + type: boolean + mcpResources: + items: + type: string + type: array + mcpServers: + items: + type: string + type: array + metadata: + additionalProperties: + type: string + type: object + model: + type: string + name: + type: string + systemPrompt: + type: string + temperature: + type: number + tokenUsage: + $ref: '#/definitions/schema.ConvTokenUsage' + topK: + type: number + topP: + type: number + updatedAt: + type: integer + version: + description: |- + Version is assigned by the server: 1 on the first save, +1 on every + later write including the delete. Clients use a non-zero version to + tell a conversation the server has seen from a never-synced draft. + Ignored on input. + type: integer + type: object + schema.ConversationBulkRequest: + properties: + conversations: + items: + $ref: '#/definitions/schema.Conversation' + type: array + type: object + schema.ConversationBulkResponse: + properties: + count: + type: integer + skipped: + items: + type: string + type: array + status: + type: string + type: object + schema.ConversationList: + properties: + conversations: + items: + $ref: '#/definitions/schema.Conversation' + type: array + deleted: + items: + $ref: '#/definitions/schema.ConversationTombstone' + type: array + type: object + schema.ConversationTombstone: + properties: + deletedAt: + description: Unix milliseconds + type: integer + id: + type: string + version: + type: integer + type: object schema.DecisionAnswer: properties: choice: @@ -4258,6 +4360,193 @@ paths: summary: Upload a branding asset tags: - branding + /api/conversations: + delete: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + summary: Delete all chat conversations for the current user + tags: + - chat-history + get: + description: Returns the live conversations, newest first, and in `deleted` + the conversations the user deleted. A client must drop any local copy whose + id appears in `deleted` instead of uploading it again. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/schema.ConversationList' + summary: List chat conversations + tags: + - chat-history + post: + consumes: + - application/json + description: Stores the conversation and returns it with its new server-assigned + `version`. `createdAt`/`updatedAt` are kept as sent (Unix ms). Writing a conversation + the user deleted returns 410 and does not bring it back. + parameters: + - description: Conversation payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/schema.Conversation' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/schema.Conversation' + "400": + description: Invalid id or body + schema: + additionalProperties: + type: string + type: object + "410": + description: Deleted + schema: + additionalProperties: + type: string + type: object + summary: Save a chat conversation (upsert) + tags: + - chat-history + /api/conversations/{id}: + delete: + description: Deletes the conversation and keeps a tombstone so no client can + write it back. Deleting an unknown or already deleted id succeeds. + parameters: + - description: Conversation ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Invalid id + schema: + additionalProperties: + type: string + type: object + summary: Delete a chat conversation + tags: + - chat-history + get: + parameters: + - description: Conversation ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/schema.Conversation' + "404": + description: Never existed + schema: + additionalProperties: + type: string + type: object + "410": + description: Deleted + schema: + additionalProperties: + type: string + type: object + summary: Get a chat conversation + tags: + - chat-history + put: + consumes: + - application/json + parameters: + - description: Conversation ID (overrides the body id) + in: path + name: id + required: true + type: string + - description: Conversation payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/schema.Conversation' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/schema.Conversation' + "400": + description: Invalid id or body + schema: + additionalProperties: + type: string + type: object + "410": + description: Deleted + schema: + additionalProperties: + type: string + type: object + summary: Save a chat conversation by id (upsert) + tags: + - chat-history + /api/conversations/bulk: + put: + consumes: + - application/json + description: Upserts every conversation in the payload and deletes the user's + other live conversations. Ids the user deleted earlier are not written and + are listed in `skipped`. + parameters: + - description: Conversations + in: body + name: body + required: true + schema: + $ref: '#/definitions/schema.ConversationBulkRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/schema.ConversationBulkResponse' + "400": + description: Invalid id or body + schema: + additionalProperties: + type: string + type: object + summary: Replace all chat conversations + tags: + - chat-history /api/failover: get: produces: From 8c80e84d35fecad5c3d05698b37b460db69e36ea Mon Sep 17 00:00:00 2001 From: Stefan Walcz Date: Tue, 13 Oct 2026 10:29:35 +0200 Subject: [PATCH 11/17] fix(react-ui): let the server decide which chats were deleted mergeRemoteAndLocal kept every chat that was only in the browser and let the server copy win for every shared id. A stale browser therefore uploaded chats deleted on another device as if they were new drafts, and a message sent while the list request was still in flight was replaced by the older server copy and then lost from localStorage too. The first load now reconciles with the server's tombstones and versions: * a chat the server lists under "deleted" is dropped locally; * a chat on both sides keeps the copy with the newer updatedAt, so an edit made during the load wins and is uploaded afterwards; * a chat only in the browser is uploaded only if it was never synced (no version). With a version the server has seen it and no longer has it, so it is dropped instead of resurrected (this also keeps one account's cached chats out of another account on a shared browser); * deletes made while the list is loading are queued and sent once it arrives. Saves record the version the server returns, and a 410 on save drops the chat locally instead of retrying it forever. Chats received from the server count as already sent, so adopting a server copy no longer echoes it straight back. The bulk localStorage migration is gone: it replaced the whole server set from a possibly stale browser, and the per-chat saves already upload never-synced drafts through the tombstone check. The Playwright spec drives each case against a stubbed /api/conversations that follows the server contract. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Stefan Walcz --- .../react-ui/e2e/chat-history-sync.spec.js | 185 ++++++++++++++++++ core/http/react-ui/src/hooks/useChat.js | 143 ++++++++++---- docs/content/features/chat-history.md | 12 ++ 3 files changed, 300 insertions(+), 40 deletions(-) create mode 100644 core/http/react-ui/e2e/chat-history-sync.spec.js diff --git a/core/http/react-ui/e2e/chat-history-sync.spec.js b/core/http/react-ui/e2e/chat-history-sync.spec.js new file mode 100644 index 000000000000..0a296a781da1 --- /dev/null +++ b/core/http/react-ui/e2e/chat-history-sync.spec.js @@ -0,0 +1,185 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockChat, openChat, storedChats, chatOf, pair, answerWith } from './chat-fixtures.js' + +// Server-side chat history (see docs/content/features/chat-history.md). The +// server keeps a tombstone for every deleted conversation and refuses to +// write it again; these specs pin the client half: a stale browser must drop +// what the server deleted, and must not lose what it typed while the list +// was loading. + +const T0 = new Date('2026-10-09T12:00:00Z').getTime() + +// A stand-in for /api/conversations with the server's contract: tombstones +// in the list, 410 for a write of a deleted id, versions on every write. +// `hiddenDeleted` are tombstones the list does not report yet (deleted on +// another device after this tab loaded the list). `holdList` keeps the list +// request pending until release() is called. +async function fakeHistoryServer(page, { conversations = [], deleted = [], hiddenDeleted = [], holdList = false } = {}) { + const live = new Map(conversations.map(c => [c.id, c])) + const tombs = new Map(deleted.map(id => [id, { id, version: 9, deletedAt: T0 }])) + const hidden = new Set(hiddenDeleted) + const requests = [] + let release = () => {} + const listGate = holdList ? new Promise(resolve => { release = resolve }) : Promise.resolve() + + await page.route(/\/api\/conversations(\/[^?]*)?(\?.*)?$/, async (route) => { + const req = route.request() + const path = new URL(req.url()).pathname + const rest = path.slice(path.indexOf('/api/conversations') + '/api/conversations'.length) + const id = rest.startsWith('/') ? decodeURIComponent(rest.slice(1)) : null + const method = req.method() + const body = method === 'PUT' || method === 'POST' ? req.postDataJSON() : null + requests.push({ method, id, body }) + + if (method === 'GET' && !id) { + await listGate + return route.fulfill({ json: { conversations: [...live.values()], deleted: [...tombs.values()] } }) + } + if (method === 'PUT' && id && id !== 'bulk') { + if (tombs.has(id) || hidden.has(id)) { + return route.fulfill({ status: 410, json: { error: 'conversation was deleted' } }) + } + const saved = { ...body, id, version: (live.get(id)?.version || 0) + 1 } + live.set(id, saved) + return route.fulfill({ json: saved }) + } + if (method === 'DELETE' && id) { + live.delete(id) + tombs.set(id, { id, version: 9, deletedAt: T0 }) + return route.fulfill({ json: { status: 'ok' } }) + } + return route.fulfill({ status: 404, json: { error: 'not found' } }) + }) + + return { + release: () => release(), + saves: (id) => requests.filter(r => r.method === 'PUT' && r.id === id), + deletes: (id) => requests.filter(r => r.method === 'DELETE' && r.id === id), + bulk: () => requests.filter(r => r.id === 'bulk'), + } +} + +const storedIds = async (page) => (await storedChats(page)).chats.map(c => c.id) + +async function openMenu(page) { + await page.keyboard.press('Control+k') + await expect(page.getByTestId('chats-menu')).toBeVisible() +} + +test.describe('Chat history sync with the server', () => { + test.beforeEach(async ({ page }) => { + await mockChat(page) + }) + + test('a stale client drops a chat deleted on another device instead of uploading it again', async ({ page }) => { + const server = await fakeHistoryServer(page, { + conversations: [chatOf('kept', 'Kept on the server', 'qwen3-8b', pair('q', 'a'), { version: 1 }, T0)], + deleted: ['gone'], + }) + await openChat(page, [ + chatOf('gone', 'Deleted on the laptop', 'qwen3-8b', pair('old q', 'old a'), { version: 2 }, T0 + 5_000), + chatOf('kept', 'Kept on the server', 'qwen3-8b', pair('q', 'a'), { version: 1 }, T0), + chatOf('draft', 'Never synced draft', 'qwen3-8b', pair('draft q', 'draft a'), {}, T0 - 5_000), + ], 'gone') + + // The draft is uploaded: that is the save pass the deleted chat must not be in. + await expect.poll(() => server.saves('draft').length).toBe(1) + expect(server.saves('gone')).toHaveLength(0) + await expect.poll(() => storedIds(page)).toEqual(['kept', 'draft']) + + await openMenu(page) + await expect(page.getByTestId('chats-row').filter({ hasText: 'Deleted on the laptop' })).toHaveCount(0) + await expect(page.getByTestId('chats-row').filter({ hasText: 'Never synced draft' })).toHaveCount(1) + }) + + test('a synced chat the server no longer has is dropped, not resurrected', async ({ page }) => { + // Another account signed in on this browser, or an admin purge: the chat + // has a version, so it was on the server, and the server does not list it. + const server = await fakeHistoryServer(page, { conversations: [] }) + await openChat(page, [ + chatOf('synced', 'Synced earlier', 'qwen3-8b', pair('q', 'a'), { version: 3 }, T0), + chatOf('draft', 'Fresh draft', 'qwen3-8b', pair('q', 'a'), {}, T0 - 1_000), + ]) + await expect.poll(() => server.saves('draft').length).toBe(1) + expect(server.saves('synced')).toHaveLength(0) + // An empty server list is no reason to replace the server's set with + // this browser's cache. + expect(server.bulk()).toHaveLength(0) + await expect.poll(() => storedIds(page)).toEqual(['draft']) + }) + + test('a 410 on save drops the chat locally', async ({ page }) => { + // Deleted on another device after this tab loaded the list. + const server = await fakeHistoryServer(page, { hiddenDeleted: ['late'] }) + await openChat(page, [ + chatOf('late', 'Deleted meanwhile', 'qwen3-8b', pair('q', 'a'), {}, T0), + chatOf('other', 'Still here', 'qwen3-8b', pair('q', 'a'), {}, T0 - 1_000), + ]) + await expect.poll(() => server.saves('late').length).toBe(1) + await expect.poll(() => storedIds(page)).toEqual(['other']) + // Dropped, not retried. + await page.waitForTimeout(1_000) + expect(server.saves('late')).toHaveLength(1) + }) + + test('a message sent while the list is loading survives the older server copy', async ({ page }) => { + const server = await fakeHistoryServer(page, { + holdList: true, + conversations: [chatOf('x', 'Release notes', 'qwen3-8b', pair('first question', 'first answer'), { version: 4 }, T0)], + }) + await answerWith(page, 'Answer written during the probe') + await openChat(page, [chatOf('x', 'Release notes', 'qwen3-8b', pair('first question', 'first answer'), { version: 4 }, T0)]) + + await page.getByTestId('chat-input').fill('Question typed while loading') + await page.getByTestId('chat-input').press('Enter') + await expect(page.getByText('Answer written during the probe')).toBeVisible() + + server.release() + // The newer local copy wins and is uploaded with the new turn in it. + await expect.poll(() => server.saves('x').map(r => JSON.stringify(r.body.history)).join('\n')) + .toContain('Question typed while loading') + await expect(page.getByText('Question typed while loading')).toBeVisible() + await expect(page.getByText('Answer written during the probe')).toBeVisible() + }) + + test('a newer copy from the server replaces the older local one', async ({ page }) => { + const remote = chatOf('x', 'Release notes', 'qwen3-8b', + [...pair('first question', 'first answer'), ...pair('asked on the phone', 'answered on the phone')], { version: 5 }, T0 + 60_000) + const server = await fakeHistoryServer(page, { conversations: [remote] }) + await openChat(page, [chatOf('x', 'Release notes', 'qwen3-8b', pair('first question', 'first answer'), { version: 4 }, T0)]) + + await expect(page.getByText('answered on the phone')).toBeVisible() + // Adopting the server copy is not a change to send back. + await page.waitForTimeout(1_000) + expect(server.saves('x')).toHaveLength(0) + }) + + test('a delete made while the list is loading reaches the server and wins', async ({ page }) => { + const server = await fakeHistoryServer(page, { + holdList: true, + conversations: [ + chatOf('a', 'Keep me', 'qwen3-8b', pair('q', 'a'), { version: 1 }, T0), + chatOf('b', 'Delete me', 'qwen3-8b', pair('q', 'b'), { version: 1 }, T0 - 1_000), + ], + }) + await openChat(page, [ + chatOf('a', 'Keep me', 'qwen3-8b', pair('q', 'a'), { version: 1 }, T0), + chatOf('b', 'Delete me', 'qwen3-8b', pair('q', 'b'), { version: 1 }, T0 - 1_000), + ]) + + await openMenu(page) + const row = page.getByTestId('chats-row').filter({ hasText: 'Delete me' }) + await row.hover() + await row.getByTitle('Delete chat').click() + const toast = page.getByTestId('chat-undo-toast') + await toast.getByRole('button', { name: 'Dismiss' }).click() + await expect(toast).toHaveCount(0) + + server.release() + await expect.poll(() => server.deletes('b').length).toBe(1) + // The server still listed it when the answer left; the local delete wins. + await expect.poll(() => storedIds(page)).toEqual(['a']) + await expect(page.getByTestId('chats-row').filter({ hasText: 'Delete me' })).toHaveCount(0) + expect(server.saves('b')).toHaveLength(0) + }) +}) diff --git a/core/http/react-ui/src/hooks/useChat.js b/core/http/react-ui/src/hooks/useChat.js index e523d52a24b0..e640e4ad4358 100644 --- a/core/http/react-ui/src/hooks/useChat.js +++ b/core/http/react-ui/src/hooks/useChat.js @@ -115,7 +115,8 @@ function loadChats() { } // serializeChat strips React-only state (streaming flags, transient UI bits) -// before persistence. Used by both localStorage and the server. +// before persistence. This is what goes to the server; `version` is the +// server's and is never sent back. function serializeChat(chat) { return { id: chat.id, @@ -140,7 +141,9 @@ function serializeChat(chat) { function saveChats(chats, activeChatId) { try { const data = { - chats: chats.map(serializeChat), + // The local cache keeps the server version: it is how the next load + // tells a chat the server has seen from a never-synced draft. + chats: chats.map(chat => ({ ...serializeChat(chat), version: chat.version })), activeChatId, lastSaved: Date.now(), } @@ -152,18 +155,39 @@ 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) +// The server omits empty fields; the UI expects a history array. +function fromServer(conv) { + return { ...conv, history: Array.isArray(conv.history) ? conv.history : [] } +} + +// reconcileWithServer merges the server's list into the chats this tab +// already has (from localStorage, plus anything typed while the list was +// loading). The server is authoritative about deletions: +// - a chat the server lists as deleted is dropped, whatever this tab has; +// - a chat on both sides keeps the copy with the newer updatedAt, so an +// edit made during the load is not overwritten by the older server copy; +// - a chat only here is kept when it was never synced (no version) and +// uploaded afterwards. With a version the server has seen it and no +// longer has it, so it is dropped rather than resurrected; +// - a chat only on the server is added. +function reconcileWithServer(remote, deleted, local) { + const gone = new Set(deleted.map(t => t.id)) + const remoteById = new Map(remote.map(c => [c.id, fromServer(c)])) + const out = [] for (const c of local) { - if (!byId.has(c.id)) byId.set(c.id, c) + if (gone.has(c.id)) continue + const r = remoteById.get(c.id) + if (r) { + remoteById.delete(c.id) + out.push((c.updatedAt || 0) > (r.updatedAt || 0) ? { ...c, version: r.version } : r) + } else if (!c.version) { + out.push(c) + } } - return Array.from(byId.values()).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)) + for (const r of remoteById.values()) { + if (!gone.has(r.id)) out.push(r) + } + return out.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)) } function createNewChat(model = '', systemPrompt = '', mcpMode = false) { @@ -221,56 +245,91 @@ export function useChat(initialModel = '') { const activeChat = chats.find(c => c.id === activeChatId) || chats[0] - // 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. + // Server-side persistence. serverEnabledRef is null while we are still + // probing, true once a successful list arrives, false on any error + // (feature disabled, auth off, denied, network down). serializedSentRef + // caches the JSON last pushed (or received) per chat so unchanged chats + // are not written again. Deletes made while probing wait in + // pendingDeletesRef ('*' = delete all) and are sent once the server answers. const serverEnabledRef = useRef(null) const serializedSentRef = useRef(new Map()) - const bootstrappedRef = useRef(false) + const pendingDeletesRef = useRef(new Set()) + + // Every path that empties the list must leave one chat to show. + const nonEmpty = useCallback((list) => (list.length > 0 ? list : [createNewChat(initialModel)]), [initialModel]) + + const dropChatLocally = useCallback((chatId) => { + serializedSentRef.current.delete(chatId) + setChats(prev => (prev.some(c => c.id === chatId) ? nonEmpty(prev.filter(c => c.id !== chatId)) : prev)) + }, [nonEmpty]) 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(() => {}) - } + const deleted = Array.isArray(resp?.deleted) ? [...resp.deleted] : [] + const pending = pendingDeletesRef.current + pendingDeletesRef.current = new Set() + if (pending.has('*')) { + chatHistoryApi.deleteAll().catch(() => {}) + for (const c of remote) deleted.push({ id: c.id }) + } + for (const id of pending) { + if (id === '*') continue + chatHistoryApi.delete(id).catch(() => {}) + deleted.push({ id }) + } + // What the server holds counts as sent, so adopting a server copy + // does not echo it straight back. + for (const c of remote) { + serializedSentRef.current.set(c.id, JSON.stringify(serializeChat(fromServer(c)))) } + serverEnabledRef.current = true + setChats(prev => nonEmpty(reconcileWithServer(remote, deleted, prev))) }) .catch(() => { if (!cancelled) serverEnabledRef.current = false }) - .finally(() => { - if (!cancelled) bootstrappedRef.current = true - }) return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // The reconcile can remove the open chat. + useEffect(() => { + if (chats.length > 0 && !chats.some(c => c.id === activeChatId)) { + setActiveChatId(chats[0].id) + } + }, [chats, activeChatId]) + 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. + if (serverEnabledRef.current !== true) return + 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) + .then(saved => { + if (!saved?.version) return + setChats(prev => { + if (!prev.some(c => c.id === saved.id && c.version !== saved.version)) return prev + return prev.map(c => (c.id === saved.id ? { ...c, version: saved.version } : c)) + }) + }) + .catch(err => { + if (err?.status === 410) { + // Deleted on another device: the server keeps it deleted, so + // drop this copy instead of retrying it forever. + dropChatLocally(chat.id) + return + } + // Keep localStorage as the copy on transient server failures; + // the next change retries. serializedSentRef.current.delete(chat.id) }) - } } }, [chats, activeChatId]) @@ -320,6 +379,8 @@ export function useChat(initialModel = '') { serializedSentRef.current.delete(chatId) if (serverEnabledRef.current === true) { chatHistoryApi.delete(chatId).catch(() => {}) + } else if (serverEnabledRef.current === null) { + pendingDeletesRef.current.add(chatId) } }, [activeChatId]) @@ -335,6 +396,8 @@ export function useChat(initialModel = '') { serializedSentRef.current.clear() if (serverEnabledRef.current === true) { chatHistoryApi.deleteAll().catch(() => {}) + } else if (serverEnabledRef.current === null) { + pendingDeletesRef.current = new Set(['*']) } }, [activeChat?.model]) diff --git a/docs/content/features/chat-history.md b/docs/content/features/chat-history.md index eba12271709b..27f529c5137b 100644 --- a/docs/content/features/chat-history.md +++ b/docs/content/features/chat-history.md @@ -28,6 +28,18 @@ Every conversation also carries a server-assigned `version`: `1` after the first Within a live conversation the last write wins: there is no merge of two devices editing the same conversation at the same time. `createdAt` and `updatedAt` (Unix milliseconds) are stored as the client sends them, so clients can compare `updatedAt` to pick the newer copy; the server only fills them in when they are `0`. +## How the web UI syncs + +On load the Chat page asks the server for the list and reconciles it with what the browser has: + +- A conversation the server reports as deleted is removed locally. +- A conversation on both sides keeps the copy with the newer `updatedAt`. A message you send while the list is still loading is kept, and uploaded afterwards. +- A conversation only in the browser is uploaded if it has never been synced (no `version`). If it has a version but the server no longer has it, it is removed locally: it was synced before, so the server dropped it (or the browser now belongs to another account). +- A conversation only on the server is added. +- A conversation you delete while the list is still loading is deleted on the server once the list arrives. + +After that, every change is saved on its own (debounced, only conversations that changed). When a save returns `410`, the UI removes that conversation locally. On any other error it keeps the browser copy and tries again with the next change. `localStorage` stays a cache of the last state either way. The web UI does not use the bulk endpoint: every upload goes through the per-conversation save, so the tombstone check applies to each one. + ## API All endpoints act on the conversations of the calling user and need a session or API key. From 2cfca0c383680e2fe3a48dfcffe31af85ea058cf Mon Sep 17 00:00:00 2001 From: Stefan Walcz Date: Tue, 13 Oct 2026 10:31:50 +0200 Subject: [PATCH 12/17] fix(chathistory): keep the LocalAI Assistant toggle on synced chats master's createNewChat sets localaiAssistant, but serializeChat did not send it and schema.Conversation had no field for it, so a chat with the Assistant mode on came back with it off after a reload from the server (and the local cache dropped it too). Add the field on both sides. The other fields createNewChat sets were already covered; a handler spec now round-trips all of them, and the UI spec checks the toggle on the way up and in the local cache. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Stefan Walcz --- .../react-ui/e2e/chat-history-sync.spec.js | 12 +++++++++++ core/http/react-ui/src/hooks/useChat.js | 1 + core/http/routes/chat_history_auth_test.go | 21 +++++++++++++++++++ core/schema/chat_conversation.go | 21 +++++++++++-------- docs/content/features/chat-history.md | 2 +- swagger/docs.go | 4 ++++ swagger/swagger.json | 4 ++++ swagger/swagger.yaml | 5 +++++ 8 files changed, 60 insertions(+), 10 deletions(-) diff --git a/core/http/react-ui/e2e/chat-history-sync.spec.js b/core/http/react-ui/e2e/chat-history-sync.spec.js index 0a296a781da1..631d92da0dad 100644 --- a/core/http/react-ui/e2e/chat-history-sync.spec.js +++ b/core/http/react-ui/e2e/chat-history-sync.spec.js @@ -182,4 +182,16 @@ test.describe('Chat history sync with the server', () => { await expect(page.getByTestId('chats-row').filter({ hasText: 'Delete me' })).toHaveCount(0) expect(server.saves('b')).toHaveLength(0) }) + + test('the LocalAI Assistant toggle survives the trip to the server and back', async ({ page }) => { + const server = await fakeHistoryServer(page, { + conversations: [chatOf('remote', 'Assistant on the phone', 'qwen3-8b', pair('q', 'a'), { version: 1, localaiAssistant: true }, T0)], + }) + await openChat(page, [chatOf('local', 'Assistant draft', 'qwen3-8b', pair('q', 'a'), { localaiAssistant: true }, T0 + 1_000)]) + + await expect.poll(() => server.saves('local').length).toBe(1) + expect(server.saves('local')[0].body.localaiAssistant).toBe(true) + await expect.poll(async () => (await storedChats(page)).chats.map(c => [c.id, !!c.localaiAssistant])) + .toEqual([['local', true], ['remote', true]]) + }) }) diff --git a/core/http/react-ui/src/hooks/useChat.js b/core/http/react-ui/src/hooks/useChat.js index e640e4ad4358..a46db2d292c0 100644 --- a/core/http/react-ui/src/hooks/useChat.js +++ b/core/http/react-ui/src/hooks/useChat.js @@ -128,6 +128,7 @@ function serializeChat(chat) { mcpServers: chat.mcpServers, mcpResources: chat.mcpResources, clientMCPServers: chat.clientMCPServers, + localaiAssistant: chat.localaiAssistant, temperature: chat.temperature, topP: chat.topP, topK: chat.topK, diff --git a/core/http/routes/chat_history_auth_test.go b/core/http/routes/chat_history_auth_test.go index 0bc71562be3a..e13c4a9ad2d5 100644 --- a/core/http/routes/chat_history_auth_test.go +++ b/core/http/routes/chat_history_auth_test.go @@ -161,6 +161,27 @@ var _ = Describe("Chat history endpoints", func() { Expect(listedIDs(s.do("alice", http.MethodGet, "/api/conversations", nil))).To(ConsistOf("c2")) }) + It("round-trips every field the UI stores on a chat", func() { + body := conversationBody("c1", "Assistant chat") + body["localaiAssistant"] = true + body["mcpMode"] = true + body["mcpServers"] = []string{"github"} + body["mcpResources"] = []string{"res://a"} + body["clientMCPServers"] = []map[string]any{{"id": "local", "url": "http://localhost:9000"}} + body["systemPrompt"] = "be brief" + body["temperature"] = 0.2 + body["contextSize"] = 8192 + Expect(s.do("alice", http.MethodPut, "/api/conversations/c1", body).Code).To(Equal(http.StatusOK)) + + rec := s.do("alice", http.MethodGet, "/api/conversations/c1", nil) + Expect(rec.Code).To(Equal(http.StatusOK)) + got := decodeJSON(rec) + for _, key := range []string{"localaiAssistant", "mcpMode", "mcpServers", "mcpResources", "clientMCPServers", "systemPrompt", "temperature", "contextSize"} { + Expect(got).To(HaveKey(key), "field %q was dropped", key) + } + Expect(got["localaiAssistant"]).To(BeTrue()) + }) + It("takes the id from the path over the body", func() { rec := s.do("alice", http.MethodPut, "/api/conversations/from-path", conversationBody("from-body", "x")) Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) diff --git a/core/schema/chat_conversation.go b/core/schema/chat_conversation.go index e10b57124b3d..041af58b8fe6 100644 --- a/core/schema/chat_conversation.go +++ b/core/schema/chat_conversation.go @@ -12,15 +12,18 @@ import ( // 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" swaggertype:"array,object"` - 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" swaggertype:"array,object"` + ID string `json:"id"` + Name string `json:"name"` + Model string `json:"model,omitempty"` + History json.RawMessage `json:"history,omitempty" swaggertype:"array,object"` + 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" swaggertype:"array,object"` + // LocalAIAssistant wires the chat to the in-process admin MCP server + // (the LocalAI Assistant mode of the Chat page). + LocalAIAssistant bool `json:"localaiAssistant,omitempty"` Temperature *float64 `json:"temperature,omitempty"` TopP *float64 `json:"topP,omitempty"` TopK *float64 `json:"topK,omitempty"` diff --git a/docs/content/features/chat-history.md b/docs/content/features/chat-history.md index 27f529c5137b..fe2a142cbe3a 100644 --- a/docs/content/features/chat-history.md +++ b/docs/content/features/chat-history.md @@ -56,7 +56,7 @@ All endpoints act on the conversations of the calling user and need a session or Status codes: `400` for a malformed id or body, `401` without credentials, `403` without the `chat_history` permission, `409` when concurrent writers kept racing on the same id (retry), `410` for a deleted conversation. -Conversation ids are 1 to 128 characters from `A-Z a-z 0-9 . _ -`. The message list (`history`) is stored as sent, so every message shape the UI uses (text, images, audio, files, tool calls, thinking) survives the round trip. +Conversation ids are 1 to 128 characters from `A-Z a-z 0-9 . _ -`. A conversation carries what the Chat page keeps per chat: name, model, system prompt, sampling settings, MCP and LocalAI Assistant settings, token usage and context size (`schema.Conversation` in `/swagger`). The message list (`history`) is stored as sent, so every message shape the UI uses (text, images, audio, files, tool calls, thinking) survives the round trip. ### Example diff --git a/swagger/docs.go b/swagger/docs.go index bfb9327804c9..4fa7bbee58be 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -6158,6 +6158,10 @@ const docTemplate = `{ "id": { "type": "string" }, + "localaiAssistant": { + "description": "LocalAIAssistant wires the chat to the in-process admin MCP server\n(the LocalAI Assistant mode of the Chat page).", + "type": "boolean" + }, "mcpMode": { "type": "boolean" }, diff --git a/swagger/swagger.json b/swagger/swagger.json index 65489c11fdbe..5f2740b6b965 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -6155,6 +6155,10 @@ "id": { "type": "string" }, + "localaiAssistant": { + "description": "LocalAIAssistant wires the chat to the in-process admin MCP server\n(the LocalAI Assistant mode of the Chat page).", + "type": "boolean" + }, "mcpMode": { "type": "boolean" }, diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index a15bbe30b3f3..32a615917590 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -992,6 +992,11 @@ definitions: type: array id: type: string + localaiAssistant: + description: |- + LocalAIAssistant wires the chat to the in-process admin MCP server + (the LocalAI Assistant mode of the Chat page). + type: boolean mcpMode: type: boolean mcpResources: From ef0ae8f03faf0b7ccef6522dbffeddc836584de0 Mon Sep 17 00:00:00 2001 From: Stefan Walcz Date: Sat, 17 Oct 2026 12:25:55 +0200 Subject: [PATCH 13/17] fix(react-ui): scope chat cache per account, retry unconfirmed deletes Two gaps in the client half of server-side chat history, both found in review: The browser cache was one shared localStorage key. A draft that one account could not upload (a failed first save) stayed in it, and after that account signed out and another signed in, the reconcile kept the unversioned draft and the save pass uploaded it into the second account. The cache now lives under localai_chats_data:, Chat remounts its state when the account changes, and nothing is synced without a signed-in account. The shared key is left for browser-only use without auth and is not uploaded when signed in, because nothing tells which account it belongs to. Deletes were dropped locally and their DELETE errors swallowed; only deletes made while the first list was loading were queued, and not persisted. A delete made while the server was unreachable therefore came back with the next list. Deletes (single, delete all, and deletes on the Home page) are now kept as pending deletes in the account's cache until the server confirms them, override the server list in the reconcile, and are sent again on the next change, on the online event and on the next load. While a delete-all is pending no conversation is uploaded, so a chat started after it is not caught by it. The Playwright specs replay both review scenarios (account switch after a failed first save; delete and delete-all while offline) with counter-checks that the same account keeps and uploads its own draft and that a confirmed delete is neither re-sent nor resurrected. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Stefan Walcz --- .../react-ui/e2e/chat-history-sync.spec.js | 280 +++++++++++++++++- core/http/react-ui/src/hooks/useChat.js | 172 +++++++---- core/http/react-ui/src/pages/Chat.jsx | 9 + core/http/react-ui/src/pages/Home.jsx | 21 +- core/http/react-ui/src/utils/chatStore.js | 45 +++ .../react-ui/src/utils/homeConversations.js | 48 ++- docs/content/features/chat-history.md | 12 +- 7 files changed, 481 insertions(+), 106 deletions(-) create mode 100644 core/http/react-ui/src/utils/chatStore.js diff --git a/core/http/react-ui/e2e/chat-history-sync.spec.js b/core/http/react-ui/e2e/chat-history-sync.spec.js index 631d92da0dad..bde242d51e0a 100644 --- a/core/http/react-ui/e2e/chat-history-sync.spec.js +++ b/core/http/react-ui/e2e/chat-history-sync.spec.js @@ -1,5 +1,5 @@ import { test, expect } from './coverage-fixtures.js' -import { mockChat, openChat, storedChats, chatOf, pair, answerWith } from './chat-fixtures.js' +import { mockChat, chatOf, pair, answerWith, CHATS_KEY } from './chat-fixtures.js' // Server-side chat history (see docs/content/features/chat-history.md). The // server keeps a tombstone for every deleted conversation and refuses to @@ -22,7 +22,10 @@ async function fakeHistoryServer(page, { conversations = [], deleted = [], hidde let release = () => {} const listGate = holdList ? new Promise(resolve => { release = resolve }) : Promise.resolve() + let down = false + await page.route(/\/api\/conversations(\/[^?]*)?(\?.*)?$/, async (route) => { + if (down) return route.abort('internetdisconnected') const req = route.request() const path = new URL(req.url()).pathname const rest = path.slice(path.indexOf('/api/conversations') + '/api/conversations'.length) @@ -48,17 +51,88 @@ async function fakeHistoryServer(page, { conversations = [], deleted = [], hidde tombs.set(id, { id, version: 9, deletedAt: T0 }) return route.fulfill({ json: { status: 'ok' } }) } + if (method === 'DELETE' && !id) { + for (const liveId of live.keys()) tombs.set(liveId, { id: liveId, version: 9, deletedAt: T0 }) + live.clear() + return route.fulfill({ json: { status: 'ok' } }) + } return route.fulfill({ status: 404, json: { error: 'not found' } }) }) return { release: () => release(), - saves: (id) => requests.filter(r => r.method === 'PUT' && r.id === id), + setDown: (value) => { down = value }, + liveIds: () => [...live.keys()].sort(), + saves: (id) => requests.filter(r => r.method === 'PUT' && (id === undefined || r.id === id)), deletes: (id) => requests.filter(r => r.method === 'DELETE' && r.id === id), + deleteAlls: () => requests.filter(r => r.method === 'DELETE' && !r.id), bulk: () => requests.filter(r => r.id === 'bulk'), } } +// Signs the browser in as `who()`, read on every request so a spec can sign +// one account out and another in on the same browser. +async function signedInAs(page, who) { + await page.route('**/api/auth/status', route => route.fulfill({ + json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], hasUsers: true, user: who() }, + })) +} + +const ALICE = { id: 'u-alice', name: 'Alice', email: 'alice@example.com', role: 'admin', permissions: {} } +const BOB = { id: 'u-bob', name: 'Bob', email: 'bob@example.com', role: 'admin', permissions: {} } + +// Each account has its own browser cache (src/utils/chatStore.js). +const keyOf = (user) => `${CHATS_KEY}:${user.id}` + +const storedChats = (page, user = ALICE) => + page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), keyOf(user)) + +// Open Chat signed in as Alice with these conversations in her cache. +async function openChat(page, chats, active = chats[0]?.id) { + await page.addInitScript(([key, data]) => { + if (!localStorage.getItem(key)) localStorage.setItem(key, JSON.stringify(data)) + }, [keyOf(ALICE), { chats, activeChatId: active, lastSaved: Date.now() }]) + await page.goto('/app/chat') + await page.getByTestId('chat-composer').waitFor() +} + +// The server half per account: the session decides whose conversations a +// request reads and writes. Saves for an account in `failSaves` answer 500, +// as a first save that does not get through. +async function fakeAccountServer(page, who, { failSaves = [] } = {}) { + const accounts = new Map() + const failing = new Set(failSaves) + const requests = [] + const of = (userId) => { + if (!accounts.has(userId)) accounts.set(userId, new Map()) + return accounts.get(userId) + } + await page.route(/\/api\/conversations(\/[^?]*)?(\?.*)?$/, async (route) => { + const req = route.request() + const user = who().id + const live = of(user) + const path = new URL(req.url()).pathname + const rest = path.slice(path.indexOf('/api/conversations') + '/api/conversations'.length) + const id = rest.startsWith('/') ? decodeURIComponent(rest.slice(1)) : null + const method = req.method() + const body = method === 'PUT' ? req.postDataJSON() : null + requests.push({ user, method, id, body }) + if (method === 'GET' && !id) return route.fulfill({ json: { conversations: [...live.values()], deleted: [] } }) + if (method === 'PUT' && id) { + if (failing.has(user)) return route.fulfill({ status: 500, json: { error: 'database is locked' } }) + const saved = { ...body, id, version: (live.get(id)?.version || 0) + 1 } + live.set(id, saved) + return route.fulfill({ json: saved }) + } + return route.fulfill({ status: 404, json: { error: 'not found' } }) + }) + return { + recover: (userId) => failing.delete(userId), + saves: (userId) => requests.filter(r => r.user === userId && r.method === 'PUT'), + liveTexts: (userId) => [...of(userId).values()].map(c => JSON.stringify(c.history)), + } +} + const storedIds = async (page) => (await storedChats(page)).chats.map(c => c.id) async function openMenu(page) { @@ -69,6 +143,7 @@ async function openMenu(page) { test.describe('Chat history sync with the server', () => { test.beforeEach(async ({ page }) => { await mockChat(page) + await signedInAs(page, () => ALICE) }) test('a stale client drops a chat deleted on another device instead of uploading it again', async ({ page }) => { @@ -195,3 +270,204 @@ test.describe('Chat history sync with the server', () => { .toEqual([['local', true], ['remote', true]]) }) }) + +test.describe('Chat history belongs to the signed-in account', () => { + test.beforeEach(async ({ page }) => { + await mockChat(page) + }) + + test('a draft one account could not save is not uploaded into the next account on this browser', async ({ page }) => { + let who = ALICE + await signedInAs(page, () => who) + const server = await fakeAccountServer(page, () => who, { failSaves: [ALICE.id] }) + await answerWith(page, 'Answer meant for Alice') + + await page.goto('/app/chat') + await page.getByTestId('chat-composer').waitFor() + await page.getByTestId('chat-input').fill('Alice private question') + await page.getByTestId('chat-input').press('Enter') + await expect(page.getByText('Answer meant for Alice')).toBeVisible() + // The first save of Alice's chat does not get through. + await expect.poll(() => server.saves(ALICE.id).some(r => JSON.stringify(r.body).includes('Alice private question'))).toBe(true) + + // Alice signs out, Bob signs in on the same browser. His list is empty. + who = BOB + await page.goto('/app/chat') + await page.getByTestId('chat-composer').waitFor() + + // Bob's first save pass is the one Alice's draft must not be part of. + await expect.poll(() => server.saves(BOB.id).length).toBeGreaterThan(0) + await page.waitForTimeout(1_000) + expect(server.saves(BOB.id).filter(r => JSON.stringify(r.body).includes('Alice private'))).toHaveLength(0) + expect(server.liveTexts(BOB.id).join('\n')).not.toContain('Alice private') + await openMenu(page) + await expect(page.getByTestId('chats-row').filter({ hasText: 'Alice private question' })).toHaveCount(0) + }) + + test('the same account still gets its unsaved draft back and uploads it', async ({ page }) => { + let who = ALICE + await signedInAs(page, () => who) + const server = await fakeAccountServer(page, () => who, { failSaves: [ALICE.id] }) + await answerWith(page, 'Answer meant for Alice') + + await page.goto('/app/chat') + await page.getByTestId('chat-composer').waitFor() + await page.getByTestId('chat-input').fill('Alice private question') + await page.getByTestId('chat-input').press('Enter') + await expect(page.getByText('Answer meant for Alice')).toBeVisible() + await expect.poll(() => server.saves(ALICE.id).length).toBeGreaterThan(0) + + // Bob uses the browser in between; then Alice signs in again and the + // server takes writes again. + who = BOB + await page.goto('/app/chat') + await page.getByTestId('chat-composer').waitFor() + await expect.poll(() => server.saves(BOB.id).length).toBeGreaterThan(0) + + who = ALICE + server.recover(ALICE.id) + await page.goto('/app/chat') + await page.getByTestId('chat-composer').waitFor() + await expect.poll(() => server.liveTexts(ALICE.id).join('\n')).toContain('Alice private question') + await openMenu(page) + await expect(page.getByTestId('chats-row').filter({ hasText: 'Alice private question' })).toHaveCount(1) + }) +}) + +test.describe('Deletes stay pending until the server confirms them', () => { + const remote = () => [ + chatOf('a', 'Keep me', 'qwen3-8b', pair('q', 'a'), { version: 1 }, T0), + chatOf('b', 'Delete me', 'qwen3-8b', pair('q', 'b'), { version: 1 }, T0 - 1_000), + ] + + test.beforeEach(async ({ page }) => { + await mockChat(page) + await signedInAs(page, () => ALICE) + }) + + // Opens Chat with nothing in the browser and waits until the list has been + // reconciled: the empty chat Chat starts with is uploaded only after that. + async function openSynced(page, server) { + await page.goto('/app/chat') + await page.getByTestId('chat-composer').waitFor() + await expect.poll(() => server.saves().length).toBeGreaterThan(0) + await openMenu(page) + await expect(page.getByTestId('chats-row').filter({ hasText: 'Delete me' })).toHaveCount(1) + } + + async function deleteRow(page, name) { + const row = page.getByTestId('chats-row').filter({ hasText: name }) + await row.hover() + await row.getByTitle('Delete chat').click() + const toast = page.getByTestId('chat-undo-toast') + await toast.getByRole('button', { name: 'Dismiss' }).click() + await expect(toast).toHaveCount(0) + } + + async function reopen(page) { + await page.goto('/app/chat') + await page.getByTestId('chat-composer').waitFor() + await openMenu(page) + } + + test('a delete made while the server is unreachable is sent later and the chat does not come back', async ({ page, context }) => { + const server = await fakeHistoryServer(page, { conversations: remote() }) + await openSynced(page, server) + + server.setDown(true) + await context.setOffline(true) + await deleteRow(page, 'Delete me') + await expect(page.getByTestId('chats-row').filter({ hasText: 'Delete me' })).toHaveCount(0) + await page.waitForTimeout(500) + + server.setDown(false) + await context.setOffline(false) + await reopen(page) + await expect.poll(() => server.liveIds().filter(id => id === 'a' || id === 'b')).toEqual(['a']) + await expect(page.getByTestId('chats-row').filter({ hasText: 'Keep me' })).toHaveCount(1) + await expect(page.getByTestId('chats-row').filter({ hasText: 'Delete me' })).toHaveCount(0) + }) + + test('"clear all" made while the server is unreachable is sent later and nothing comes back', async ({ page, context }) => { + const server = await fakeHistoryServer(page, { conversations: remote() }) + await openSynced(page, server) + + server.setDown(true) + await context.setOffline(true) + await page.getByRole('button', { name: 'Clear all' }).click() + await page.getByRole('button', { name: 'Delete all' }).click() + await page.waitForTimeout(500) + + server.setDown(false) + await context.setOffline(false) + await reopen(page) + await expect.poll(() => server.liveIds().filter(id => id === 'a' || id === 'b')).toEqual([]) + await expect(page.getByTestId('chats-row').filter({ hasText: 'Keep me' })).toHaveCount(0) + await expect(page.getByTestId('chats-row').filter({ hasText: 'Delete me' })).toHaveCount(0) + }) + + test('a delete the server confirmed is not sent again and does not come back', async ({ page }) => { + const server = await fakeHistoryServer(page, { conversations: remote() }) + await openSynced(page, server) + + await deleteRow(page, 'Delete me') + await expect.poll(() => server.deletes('b').length).toBe(1) + expect(server.liveIds()).not.toContain('b') + + await reopen(page) + await expect(page.getByTestId('chats-row').filter({ hasText: 'Keep me' })).toHaveCount(1) + await expect(page.getByTestId('chats-row').filter({ hasText: 'Delete me' })).toHaveCount(0) + await page.waitForTimeout(1_000) + expect(server.deletes('b')).toHaveLength(1) + }) +}) + +test.describe('Chat history from Home and without an account', () => { + test('a delete on the Home page reaches the server through Chat', async ({ page }) => { + await mockChat(page) + await signedInAs(page, () => ALICE) + const server = await fakeHistoryServer(page, { + conversations: [ + chatOf('a', 'Keep me', 'qwen3-8b', pair('q', 'kept answer'), { version: 1 }, T0), + chatOf('b', 'Delete me', 'qwen3-8b', pair('q', 'deleted answer'), { version: 1 }, T0 - 1_000), + ], + }) + await page.addInitScript(([key, data]) => { + if (!localStorage.getItem(key)) localStorage.setItem(key, JSON.stringify(data)) + }, [keyOf(ALICE), { + chats: [ + chatOf('a', 'Keep me', 'qwen3-8b', pair('q', 'kept answer'), { version: 1 }, Date.now()), + chatOf('b', 'Delete me', 'qwen3-8b', pair('q', 'deleted answer'), { version: 1 }, Date.now() - 1_000), + ], + activeChatId: 'a', + }]) + + await page.goto('/app') + const row = page.getByTestId('home-conversation').filter({ hasText: 'Delete me' }) + await row.hover() + await row.getByRole('button', { name: /delete conversation/i }).click() + await expect(page.getByTestId('home-undo-toast')).toBeVisible() + // Resuming another chat makes the delete final and opens Chat. + await page.getByTestId('home-conversation').filter({ hasText: 'Keep me' }).locator('.home-row__open').click() + await expect(page).toHaveURL(/\/app\/chat/) + + await expect.poll(() => server.deletes('b').length).toBe(1) + await openMenu(page) + await expect(page.getByTestId('chats-row').filter({ hasText: 'Keep me' })).toHaveCount(1) + await expect(page.getByTestId('chats-row').filter({ hasText: 'Delete me' })).toHaveCount(0) + }) + + test('nobody signed in: chats stay in the browser and nothing is sent', async ({ page }) => { + await mockChat(page) + const server = await fakeHistoryServer(page, {}) + await page.addInitScript(([key, data]) => { + if (!localStorage.getItem(key)) localStorage.setItem(key, JSON.stringify(data)) + }, [CHATS_KEY, { chats: [chatOf('local', 'Browser only', 'qwen3-8b', pair('q', 'a'), {}, T0)], activeChatId: 'local' }]) + await page.goto('/app/chat') + await page.getByTestId('chat-composer').waitFor() + await openMenu(page) + await expect(page.getByTestId('chats-row').filter({ hasText: 'Browser only' })).toHaveCount(1) + await page.waitForTimeout(1_000) + expect(server.saves()).toHaveLength(0) + }) +}) diff --git a/core/http/react-ui/src/hooks/useChat.js b/core/http/react-ui/src/hooks/useChat.js index a46db2d292c0..a50af49bf8ba 100644 --- a/core/http/react-ui/src/hooks/useChat.js +++ b/core/http/react-ui/src/hooks/useChat.js @@ -4,6 +4,8 @@ import { apiUrl } from '../utils/basePath' import { effectiveSystemPrompt } from '../utils/systemPrompt' import { useDebouncedEffect } from './useDebounce' import { chatHistoryApi } from '../utils/api' +import { useAuth } from '../context/AuthContext' +import { chatsStorageKey, readChatStore, writeChatStore } from '../utils/chatStore' const thinkingTagRegex = /([\s\S]*?)<\/thinking>|([\s\S]*?)<\/think>|<\|channel>thought([\s\S]*?)/g const openThinkTagRegex = /||<\|channel>thought/ @@ -97,23 +99,6 @@ function extractThinking(text) { import { generateId } from '../utils/format' -const CHATS_STORAGE_KEY = 'localai_chats_data' - -function loadChats() { - try { - const stored = localStorage.getItem(CHATS_STORAGE_KEY) - if (stored) { - const data = JSON.parse(stored) - if (data && Array.isArray(data.chats)) { - return data - } - } - } catch (_e) { - localStorage.removeItem(CHATS_STORAGE_KEY) - } - return null -} - // serializeChat strips React-only state (streaming flags, transient UI bits) // before persistence. This is what goes to the server; `version` is the // server's and is never sent back. @@ -139,21 +124,14 @@ function serializeChat(chat) { } } -function saveChats(chats, activeChatId) { - try { - const data = { - // The local cache keeps the server version: it is how the next load - // tells a chat the server has seen from a never-synced draft. - chats: chats.map(chat => ({ ...serializeChat(chat), version: chat.version })), - activeChatId, - lastSaved: Date.now(), - } - localStorage.setItem(CHATS_STORAGE_KEY, JSON.stringify(data)) - } catch (err) { - if (err.name === 'QuotaExceededError' || err.code === 22) { - console.warn('localStorage quota exceeded') - } - } +function saveChats(key, chats, activeChatId, pendingDeletes) { + writeChatStore(key, { + // The local cache keeps the server version: it is how the next load + // tells a chat the server has seen from a never-synced draft. + chats: chats.map(chat => ({ ...serializeChat(chat), version: chat.version })), + activeChatId, + pendingDeletes: [...pendingDeletes], + }) } // The server omits empty fields; the UI expects a history array. @@ -216,17 +194,33 @@ function createNewChat(model = '', systemPrompt = '', mcpMode = false) { } export function useChat(initialModel = '') { + // The cache and the server sync belong to the signed-in account. Chat + // remounts this hook when the account changes, so the key is fixed for the + // hook's lifetime. Without an account there is no server history and the + // chats stay in the browser only. + const { user } = useAuth() + const accountId = user?.id || '' + const storageKey = chatsStorageKey(accountId) + const storedRef = useRef(undefined) + if (storedRef.current === undefined) storedRef.current = readChatStore(storageKey) + + // Deletes the server has not confirmed yet ('*' = delete all). They are + // persisted with the cache and sent again until the server answers, so a + // delete made while the server is unreachable is not undone by the next + // list. + const pendingDeletesRef = useRef(null) + if (pendingDeletesRef.current === null) { + pendingDeletesRef.current = new Set(accountId ? (storedRef.current?.pendingDeletes || []) : []) + } + const [chats, setChats] = useState(() => { - const stored = loadChats() - if (stored && stored.chats.length > 0) return stored.chats + const pending = pendingDeletesRef.current + const stored = (storedRef.current?.chats || []).filter(c => !pending.has(c.id)) + if (stored.length > 0) return stored return [createNewChat(initialModel)] }) - const [activeChatId, setActiveChatId] = useState(() => { - const stored = loadChats() - if (stored && stored.activeChatId) return stored.activeChatId - return chats[0]?.id - }) + const [activeChatId, setActiveChatId] = useState(() => storedRef.current?.activeChatId || chats[0]?.id) const [isStreaming, setIsStreaming] = useState(false) const [streamingChatId, setStreamingChatId] = useState(null) @@ -248,13 +242,52 @@ export function useChat(initialModel = '') { // Server-side persistence. serverEnabledRef is null while we are still // probing, true once a successful list arrives, false on any error - // (feature disabled, auth off, denied, network down). serializedSentRef - // caches the JSON last pushed (or received) per chat so unchanged chats - // are not written again. Deletes made while probing wait in - // pendingDeletesRef ('*' = delete all) and are sent once the server answers. + // (feature disabled, auth off, denied, network down) and when nobody is + // signed in. serializedSentRef caches the JSON last pushed (or received) + // per chat so unchanged chats are not written again. deletesInFlightRef + // holds the pending deletes with a request on the way; deleteAllSeqRef + // counts "delete all" requests, so the answer to an older one does not + // clear a newer one. const serverEnabledRef = useRef(null) const serializedSentRef = useRef(new Map()) - const pendingDeletesRef = useRef(new Set()) + const deletesInFlightRef = useRef(new Set()) + const deleteAllSeqRef = useRef(0) + // Bumped when a confirmed "delete all" lets the held-back saves go out. + const [syncTick, setSyncTick] = useState(0) + + const persistPending = useCallback(() => { + const data = readChatStore(storageKey) || { chats: [], activeChatId: null } + writeChatStore(storageKey, { ...data, pendingDeletes: [...pendingDeletesRef.current] }) + }, [storageKey]) + + // flushPendingDeletes sends every pending delete that has no request on the + // way. A delete leaves the queue only when the server confirms it (DELETE + // is idempotent server-side); on any other outcome it stays and is sent + // again with the next change, when the browser comes back online, or on + // the next load. + const flushPendingDeletes = useCallback(() => { + if (serverEnabledRef.current !== true) return + const inFlight = deletesInFlightRef.current + for (const id of pendingDeletesRef.current) { + if (inFlight.has(id)) continue + inFlight.add(id) + const seq = deleteAllSeqRef.current + const request = id === '*' ? chatHistoryApi.deleteAll() : chatHistoryApi.delete(id) + request + .then(() => true, err => err?.status === 400) // 400: an id the server never accepts + .then(done => { + inFlight.delete(id) + if (!done) return + if (id === '*' && seq !== deleteAllSeqRef.current) { + flushPendingDeletes() + return + } + pendingDeletesRef.current.delete(id) + persistPending() + if (id === '*') setSyncTick(t => t + 1) + }) + } + }, [persistPending]) // Every path that empties the list must leave one chat to show. const nonEmpty = useCallback((list) => (list.length > 0 ? list : [createNewChat(initialModel)]), [initialModel]) @@ -265,22 +298,24 @@ export function useChat(initialModel = '') { }, [nonEmpty]) useEffect(() => { + if (!accountId) { + serverEnabledRef.current = false + return + } let cancelled = false chatHistoryApi.list() .then(resp => { if (cancelled) return const remote = Array.isArray(resp?.conversations) ? resp.conversations : [] const deleted = Array.isArray(resp?.deleted) ? [...resp.deleted] : [] + // A pending delete wins over the list: the server may still have the + // chat because the delete has not reached it yet. const pending = pendingDeletesRef.current - pendingDeletesRef.current = new Set() if (pending.has('*')) { - chatHistoryApi.deleteAll().catch(() => {}) for (const c of remote) deleted.push({ id: c.id }) } for (const id of pending) { - if (id === '*') continue - chatHistoryApi.delete(id).catch(() => {}) - deleted.push({ id }) + if (id !== '*') deleted.push({ id }) } // What the server holds counts as sent, so adopting a server copy // does not echo it straight back. @@ -289,6 +324,7 @@ export function useChat(initialModel = '') { } serverEnabledRef.current = true setChats(prev => nonEmpty(reconcileWithServer(remote, deleted, prev))) + flushPendingDeletes() }) .catch(() => { if (!cancelled) serverEnabledRef.current = false @@ -297,6 +333,11 @@ export function useChat(initialModel = '') { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + useEffect(() => { + window.addEventListener('online', flushPendingDeletes) + return () => window.removeEventListener('online', flushPendingDeletes) + }, [flushPendingDeletes]) + // The reconcile can remove the open chat. useEffect(() => { if (chats.length > 0 && !chats.some(c => c.id === activeChatId)) { @@ -305,9 +346,14 @@ export function useChat(initialModel = '') { }, [chats, activeChatId]) useDebouncedEffect(() => { - saveChats(chats, activeChatId) + saveChats(storageKey, chats, activeChatId, pendingDeletesRef.current) if (serverEnabledRef.current !== true) return + flushPendingDeletes() + // Until a "delete all" is confirmed, a chat saved now would be caught by + // it when it lands. The confirmation bumps syncTick and the saves follow. + if (pendingDeletesRef.current.has('*')) return for (const chat of chats) { + if (pendingDeletesRef.current.has(chat.id)) continue const serialized = serializeChat(chat) const json = JSON.stringify(serialized) if (serializedSentRef.current.get(chat.id) === json) continue @@ -332,7 +378,7 @@ export function useChat(initialModel = '') { serializedSentRef.current.delete(chat.id) }) } - }, [chats, activeChatId]) + }, [chats, activeChatId, syncTick]) const addChat = useCallback((model = '', systemPrompt = '', mcpMode = false) => { const chat = createNewChat(model, systemPrompt, mcpMode) @@ -378,12 +424,11 @@ export function useChat(initialModel = '') { return filtered }) serializedSentRef.current.delete(chatId) - if (serverEnabledRef.current === true) { - chatHistoryApi.delete(chatId).catch(() => {}) - } else if (serverEnabledRef.current === null) { - pendingDeletesRef.current.add(chatId) - } - }, [activeChatId]) + if (!accountId) return + pendingDeletesRef.current.add(chatId) + persistPending() + flushPendingDeletes() + }, [activeChatId, accountId, persistPending, flushPendingDeletes]) const deleteAllChats = useCallback(() => { const chat = createNewChat(activeChat?.model || '') @@ -395,12 +440,13 @@ export function useChat(initialModel = '') { setTokensPerSecond(null) setMaxTokensPerSecond(null) serializedSentRef.current.clear() - if (serverEnabledRef.current === true) { - chatHistoryApi.deleteAll().catch(() => {}) - } else if (serverEnabledRef.current === null) { - pendingDeletesRef.current = new Set(['*']) - } - }, [activeChat?.model]) + if (!accountId) return + // Covers every delete still pending before it. + pendingDeletesRef.current = new Set(['*']) + deleteAllSeqRef.current += 1 + persistPending() + flushPendingDeletes() + }, [activeChat?.model, accountId, persistPending, flushPendingDeletes]) const renameChat = useCallback((chatId, name) => { setChats(prev => prev.map(c => diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index 3a9cd400de79..486e14703e6c 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -97,7 +97,16 @@ function formatLoadEta(seconds) { return `${Math.floor(minutes / 60)}h ${minutes % 60}m` } +// One chat state per account: useChat binds its browser cache and its server +// sync to the signed-in account, so a different account gets a fresh mount +// instead of the previous account's chats. export default function Chat() { + const { user } = useAuth() + return +} + +// eslint-disable-next-line no-unused-vars -- used as JSX above +function ChatPage() { const { model: urlModel } = useParams() const { addToast } = useOutletContext() const navigate = useNavigate() diff --git a/core/http/react-ui/src/pages/Home.jsx b/core/http/react-ui/src/pages/Home.jsx index 22bbcc98427f..02f68d0b6037 100644 --- a/core/http/react-ui/src/pages/Home.jsx +++ b/core/http/react-ui/src/pages/Home.jsx @@ -32,6 +32,7 @@ import { greetingKey } from '../utils/greeting' import { listConversations, setActiveConversation, removeConversation, } from '../utils/homeConversations' +import { chatsStorageKey } from '../utils/chatStore' // eslint-disable-next-line no-unused-vars import Skeleton from '../components/Skeleton' import { staggerStyle } from '../hooks/useStagger' @@ -56,7 +57,9 @@ export default function Home() { const navigate = useNavigate() const { addToast } = useOutletContext() const { t, i18n } = useTranslation('home') - const { isAdmin } = useAuth() + const { isAdmin, user } = useAuth() + // The signed-in account's own chat cache (see chatStore). + const chatsKey = chatsStorageKey(user?.id) const branding = useBranding() const { resources } = useResources() const { operations } = useOperations() @@ -92,7 +95,7 @@ export default function Home() { const [distributedMode, setDistributedMode] = useState(false) const [clusterData, setClusterData] = useState(null) const [stripOpen, setStripOpen] = useState(false) - const [conversations, setConversations] = useState(() => listConversations()) + const [conversations, setConversations] = useState(() => listConversations(chatsKey)) const [leavingId, setLeavingId] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) const textareaRef = useRef(null) @@ -355,19 +358,19 @@ export default function Home() { // ----- Conversations ----------------------------------------------------- - const refreshConversations = useCallback(() => setConversations(listConversations()), []) + const refreshConversations = useCallback(() => setConversations(listConversations(chatsKey)), [chatsKey]) // Another tab can write chats, and Chat writes them after a short delay. Read // again when the tab changes or comes back into focus. useEffect(() => { - const onStorage = (e) => { if (!e.key || e.key === 'localai_chats_data') refreshConversations() } + const onStorage = (e) => { if (!e.key || e.key === chatsKey) refreshConversations() } window.addEventListener('storage', onStorage) window.addEventListener('focus', refreshConversations) return () => { window.removeEventListener('storage', onStorage) window.removeEventListener('focus', refreshConversations) } - }, [refreshConversations]) + }, [chatsKey, refreshConversations]) // A delete is final when its undo time ends. Until then the chat stays in // storage and only the row is hidden. @@ -375,10 +378,10 @@ export default function Home() { const id = pendingRef.current if (!id) return pendingRef.current = null - removeConversation(id) + removeConversation(id, chatsKey) setPendingDelete(null) refreshConversations() - }, [refreshConversations]) + }, [chatsKey, refreshConversations]) const commitRef = useRef(commitDelete) commitRef.current = commitDelete @@ -410,9 +413,9 @@ export default function Home() { const resumeConversation = useCallback((conv) => { commitDelete() - if (setActiveConversation(conv.id)) navigate('/app/chat') + if (setActiveConversation(conv.id, chatsKey)) navigate('/app/chat') else refreshConversations() - }, [commitDelete, navigate, refreshConversations]) + }, [chatsKey, commitDelete, navigate, refreshConversations]) const visibleConversations = useMemo( () => conversations.filter(c => c.id !== pendingDelete?.id), diff --git a/core/http/react-ui/src/utils/chatStore.js b/core/http/react-ui/src/utils/chatStore.js new file mode 100644 index 000000000000..d910c4216c4c --- /dev/null +++ b/core/http/react-ui/src/utils/chatStore.js @@ -0,0 +1,45 @@ +// The browser cache of the Chat page's conversations. Chat writes it, Home +// reads it. +// +// Signed in, every account has its own entry: a draft one account could not +// upload must never be found, and uploaded, by the next account that signs in +// on the same browser. Without an account (auth disabled) the original key is +// used, so existing browser-only histories keep working. +// +// Besides the chats the entry holds `pendingDeletes`: ids the user deleted +// that the server has not confirmed yet ('*' = delete all). They survive a +// reload and are sent again until the server answers. + +export const CHATS_STORAGE_KEY = 'localai_chats_data' + +export function chatsStorageKey(accountId) { + return accountId ? `${CHATS_STORAGE_KEY}:${accountId}` : CHATS_STORAGE_KEY +} + +export function isChatsStorageKey(key) { + return key === CHATS_STORAGE_KEY || (typeof key === 'string' && key.startsWith(`${CHATS_STORAGE_KEY}:`)) +} + +export function readChatStore(key) { + try { + const raw = localStorage.getItem(key) + if (!raw) return null + const data = JSON.parse(raw) + if (!data || !Array.isArray(data.chats)) return null + return { ...data, pendingDeletes: Array.isArray(data.pendingDeletes) ? data.pendingDeletes : [] } + } catch { + return null + } +} + +export function writeChatStore(key, data) { + try { + localStorage.setItem(key, JSON.stringify({ ...data, lastSaved: Date.now() })) + return true + } catch (err) { + if (err?.name === 'QuotaExceededError' || err?.code === 22) { + console.warn('localStorage quota exceeded') + } + return false + } +} diff --git a/core/http/react-ui/src/utils/homeConversations.js b/core/http/react-ui/src/utils/homeConversations.js index 099a13ad1782..8e8e14893949 100644 --- a/core/http/react-ui/src/utils/homeConversations.js +++ b/core/http/react-ui/src/utils/homeConversations.js @@ -1,33 +1,15 @@ // Conversations on the Home page: read from the same browser storage the Chat -// page writes (see useChat), grouped by day for the "Jump back in" list. +// page writes (see useChat and chatStore), grouped by day for the "Jump back +// in" list. Every function takes the storage key of the signed-in account +// (chatsStorageKey); without one it reads the browser-only key. // // Home only reads, resumes and deletes. It never writes a chat's content, so a // stored conversation keeps the exact shape Chat expects. -export const CHATS_STORAGE_KEY = 'localai_chats_data' +import { CHATS_STORAGE_KEY, readChatStore, writeChatStore } from './chatStore' const DAY_MS = 24 * 60 * 60 * 1000 -function readStore() { - try { - const raw = localStorage.getItem(CHATS_STORAGE_KEY) - if (!raw) return null - const data = JSON.parse(raw) - return data && Array.isArray(data.chats) ? data : null - } catch { - return null - } -} - -function writeStore(data) { - try { - localStorage.setItem(CHATS_STORAGE_KEY, JSON.stringify({ ...data, lastSaved: Date.now() })) - return true - } catch { - return false - } -} - // A message body is a string, or a list of blocks ({type:'text'|'image_url'|...}). function messageText(message) { const content = message?.content @@ -94,8 +76,8 @@ export function conversationsFromChats(chats) { } // Chats that hold at least one message, newest first. -export function listConversations() { - const data = readStore() +export function listConversations(key = CHATS_STORAGE_KEY) { + const data = readChatStore(key) return data ? conversationsFromChats(data.chats) : [] } @@ -130,19 +112,23 @@ export function groupConversations(items, now = Date.now()) { } // Make a stored chat the one Chat opens on. -export function setActiveConversation(id) { - const data = readStore() +export function setActiveConversation(id, key = CHATS_STORAGE_KEY) { + const data = readChatStore(key) if (!data || !data.chats.some(c => c.id === id)) return false - return writeStore({ ...data, activeChatId: id }) + return writeChatStore(key, { ...data, activeChatId: id }) } // Remove a chat from storage. Home keeps the chat until its undo time ends -// and calls this only then, so there is nothing to put back afterwards. -export function removeConversation(id) { - const data = readStore() +// and calls this only then, so there is nothing to put back afterwards. The +// id is queued as a pending delete: Home does not talk to the server, so the +// Chat page sends the delete the next time it syncs. Without that a synced +// chat removed here would come straight back from the server. +export function removeConversation(id, key = CHATS_STORAGE_KEY) { + const data = readChatStore(key) if (!data || !data.chats.some(c => c.id === id)) return false const chats = data.chats.filter(c => c.id !== id) let activeChatId = data.activeChatId if (activeChatId === id) activeChatId = chats[0]?.id - return writeStore({ ...data, chats, activeChatId }) + const pendingDeletes = data.pendingDeletes.includes(id) ? data.pendingDeletes : [...data.pendingDeletes, id] + return writeChatStore(key, { ...data, chats, activeChatId, pendingDeletes }) } diff --git a/docs/content/features/chat-history.md b/docs/content/features/chat-history.md index fe2a142cbe3a..06dcbbbff783 100644 --- a/docs/content/features/chat-history.md +++ b/docs/content/features/chat-history.md @@ -8,7 +8,7 @@ url = "/features/chat-history/" Chat history keeps the conversations of the web UI [Chat]({{%relref "features/chat" %}}) page on the server, per user, so they survive a browser reset and follow you to another device. It is built on the [user authentication system]({{%relref "features/authentication" %}}): conversations live in the same database as users, sessions and API keys (SQLite by default, PostgreSQL when `LOCALAI_AUTH_DATABASE_URL` points at one). {{% notice note %}} -Chat history is only active when user authentication is enabled (`LOCALAI_AUTH=true`). Without it, or with only legacy API keys, the endpoints below are not registered and the web UI keeps conversations in the browser (`localai_chats_data` in `localStorage`), as before. Disabling the web UI (`LOCALAI_DISABLE_WEBUI=true`) also disables chat history. +Chat history is only active when user authentication is enabled (`LOCALAI_AUTH=true`). Without it, or with only legacy API keys, the endpoints below are not registered and the web UI keeps conversations in the browser (`localai_chats_data` in `localStorage`), as before. With it, the browser copy is kept per account (see [One browser cache per account](#one-browser-cache-per-account)). Disabling the web UI (`LOCALAI_DISABLE_WEBUI=true`) also disables chat history. {{% /notice %}} ## Permission @@ -40,6 +40,16 @@ On load the Chat page asks the server for the list and reconciles it with what t After that, every change is saved on its own (debounced, only conversations that changed). When a save returns `410`, the UI removes that conversation locally. On any other error it keeps the browser copy and tries again with the next change. `localStorage` stays a cache of the last state either way. The web UI does not use the bulk endpoint: every upload goes through the per-conversation save, so the tombstone check applies to each one. +### Deletes wait for the server + +A delete (one conversation, **Clear all**, or a delete on the Home page) is removed from the screen at once, and kept in the browser as a pending delete until the server confirms it. If the server cannot be reached, the delete is sent again with the next change, when the browser comes back online, and on the next load; until then the list from the server cannot bring the conversation back. While a **Clear all** is pending, no conversation is uploaded, so a chat started after it is not caught by it. + +### One browser cache per account + +The browser cache belongs to the signed-in account: it is stored under `localai_chats_data:`, together with that account's pending deletes. When one account signs out and another signs in on the same browser, the second account starts from its own cache and its own server list. A conversation the first account could not upload yet (for example because its first save failed) stays in the first account's cache and is uploaded when that account signs in again; it is never uploaded into another account. Signing out does not delete the cache. + +Conversations stored in the browser before chat history was enabled live under the shared key `localai_chats_data`. When you are signed in, the Chat page does not read that key and does not upload it, because the browser cannot tell which account it belongs to. Without user authentication the web UI keeps using it, as before. + ## API All endpoints act on the conversations of the calling user and need a session or API key. From 8816a7200922b52131289bb0c782f50cfd3eba97 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:05:33 +0000 Subject: [PATCH 14/17] fix(react-ui): persist cleared chats immediately Navigation cancels the debounced cache write and can restore unsynced chats after Clear all. Save the cleared list with its pending deletion before returning, and cover immediate navigation in the browser spec. Assisted-by: Codex:GPT-6 Signed-off-by: Stefan Walcz --- .../react-ui/e2e/chat-history-sync.spec.js | 23 +++++++++++++++++++ core/http/react-ui/src/hooks/useChat.js | 6 +++-- docs/content/features/chat-history.md | 2 ++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/core/http/react-ui/e2e/chat-history-sync.spec.js b/core/http/react-ui/e2e/chat-history-sync.spec.js index bde242d51e0a..7fc9919c2caf 100644 --- a/core/http/react-ui/e2e/chat-history-sync.spec.js +++ b/core/http/react-ui/e2e/chat-history-sync.spec.js @@ -406,6 +406,29 @@ test.describe('Deletes stay pending until the server confirms them', () => { await expect(page.getByTestId('chats-row').filter({ hasText: 'Delete me' })).toHaveCount(0) }) + test('clear all persists before navigation can cancel the debounced save', async ({ page }) => { + const server = await fakeHistoryServer(page) + server.setDown(true) + await openChat(page, [chatOf('draft', 'Unsynced draft', 'qwen3-8b', pair('private draft', 'answer'), {}, T0)]) + await openMenu(page) + // Freeze the debounce: assert the cache produced by the delete itself. + await page.clock.install() + await page.clock.pauseAt(new Date()) + await page.getByRole('button', { name: 'Clear all' }).click() + await page.getByRole('button', { name: 'Delete all' }).click() + const stored = await storedChats(page) + expect(stored.chats.map(c => c.id)).not.toContain('draft') + expect(stored.pendingDeletes).toEqual(['*']) + + await page.clock.resume() + server.setDown(false) + await reopen(page) + await expect.poll(() => server.deleteAlls().length).toBe(1) + await page.waitForTimeout(1_000) + expect(server.saves('draft')).toHaveLength(0) + await expect(page.getByTestId('chats-row').filter({ hasText: 'Unsynced draft' })).toHaveCount(0) + }) + test('a delete the server confirmed is not sent again and does not come back', async ({ page }) => { const server = await fakeHistoryServer(page, { conversations: remote() }) await openSynced(page, server) diff --git a/core/http/react-ui/src/hooks/useChat.js b/core/http/react-ui/src/hooks/useChat.js index a50af49bf8ba..57db3a245d2f 100644 --- a/core/http/react-ui/src/hooks/useChat.js +++ b/core/http/react-ui/src/hooks/useChat.js @@ -444,9 +444,11 @@ export function useChat(initialModel = '') { // Covers every delete still pending before it. pendingDeletesRef.current = new Set(['*']) deleteAllSeqRef.current += 1 - persistPending() + // Navigation cancels the debounced save; persist the cleared list with + // its delete intent so an unsynced draft cannot return on the next load. + saveChats(storageKey, [chat], chat.id, pendingDeletesRef.current) flushPendingDeletes() - }, [activeChat?.model, accountId, persistPending, flushPendingDeletes]) + }, [activeChat?.model, accountId, storageKey, flushPendingDeletes]) const renameChat = useCallback((chatId, name) => { setChats(prev => prev.map(c => diff --git a/docs/content/features/chat-history.md b/docs/content/features/chat-history.md index 06dcbbbff783..656c55f497c1 100644 --- a/docs/content/features/chat-history.md +++ b/docs/content/features/chat-history.md @@ -44,6 +44,8 @@ After that, every change is saved on its own (debounced, only conversations that A delete (one conversation, **Clear all**, or a delete on the Home page) is removed from the screen at once, and kept in the browser as a pending delete until the server confirms it. If the server cannot be reached, the delete is sent again with the next change, when the browser comes back online, and on the next load; until then the list from the server cannot bring the conversation back. While a **Clear all** is pending, no conversation is uploaded, so a chat started after it is not caught by it. +Clearing all chats immediately updates the browser cache, including unsynced drafts. You can leave the page without waiting for the server to confirm deletion. + ### One browser cache per account The browser cache belongs to the signed-in account: it is stored under `localai_chats_data:`, together with that account's pending deletes. When one account signs out and another signs in on the same browser, the second account starts from its own cache and its own server list. A conversation the first account could not upload yet (for example because its first save failed) stays in the first account's cache and is uploaded when that account signs in again; it is never uploaded into another account. Signing out does not delete the cache. From a5b50c19e2cad587c16795d334a7407c9e869e29 Mon Sep 17 00:00:00 2001 From: Stefan Walcz Date: Mon, 19 Oct 2026 10:56:23 +0200 Subject: [PATCH 15/17] test(react-ui): make the chat history sync specs deterministic - "clear all persists before navigation" opened a single chat, and the menu offers no Clear all for one chat, so the spec timed out in every run. It now opens two unsynced chats and checks that neither survives. Reverting 03cd9c115's save in clearAllChats turns it red again. - "a delete the server confirmed is not sent again" reopened the page as soon as the server had the request, before the page had the answer; the unconfirmed delete was then rightly sent again (4 of 5 runs). It now waits until the cache holds no pending delete. Docs-Not-Needed: test-only change. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Stefan Walcz --- core/http/react-ui/e2e/chat-history-sync.spec.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/core/http/react-ui/e2e/chat-history-sync.spec.js b/core/http/react-ui/e2e/chat-history-sync.spec.js index 7fc9919c2caf..d6f0f3cbd454 100644 --- a/core/http/react-ui/e2e/chat-history-sync.spec.js +++ b/core/http/react-ui/e2e/chat-history-sync.spec.js @@ -409,7 +409,11 @@ test.describe('Deletes stay pending until the server confirms them', () => { test('clear all persists before navigation can cancel the debounced save', async ({ page }) => { const server = await fakeHistoryServer(page) server.setDown(true) - await openChat(page, [chatOf('draft', 'Unsynced draft', 'qwen3-8b', pair('private draft', 'answer'), {}, T0)]) + // Two chats: with a single chat the menu offers no Clear all. + await openChat(page, [ + chatOf('draft', 'Unsynced draft', 'qwen3-8b', pair('private draft', 'answer'), {}, T0), + chatOf('other', 'Other draft', 'qwen3-8b', pair('second draft', 'answer'), {}, T0 - 1000), + ]) await openMenu(page) // Freeze the debounce: assert the cache produced by the delete itself. await page.clock.install() @@ -418,6 +422,7 @@ test.describe('Deletes stay pending until the server confirms them', () => { await page.getByRole('button', { name: 'Delete all' }).click() const stored = await storedChats(page) expect(stored.chats.map(c => c.id)).not.toContain('draft') + expect(stored.chats.map(c => c.id)).not.toContain('other') expect(stored.pendingDeletes).toEqual(['*']) await page.clock.resume() @@ -426,6 +431,7 @@ test.describe('Deletes stay pending until the server confirms them', () => { await expect.poll(() => server.deleteAlls().length).toBe(1) await page.waitForTimeout(1_000) expect(server.saves('draft')).toHaveLength(0) + expect(server.saves('other')).toHaveLength(0) await expect(page.getByTestId('chats-row').filter({ hasText: 'Unsynced draft' })).toHaveCount(0) }) @@ -436,6 +442,9 @@ test.describe('Deletes stay pending until the server confirms them', () => { await deleteRow(page, 'Delete me') await expect.poll(() => server.deletes('b').length).toBe(1) expect(server.liveIds()).not.toContain('b') + // The server has the request; wait until the page has its answer too. + // Reopening earlier leaves the delete unconfirmed, and it is rightly sent again. + await expect.poll(async () => (await storedChats(page)).pendingDeletes).toEqual([]) await reopen(page) await expect(page.getByTestId('chats-row').filter({ hasText: 'Keep me' })).toHaveCount(1) From 5f42128f132cc62fc5e4118a4e4c4386500343fc Mon Sep 17 00:00:00 2001 From: Stefan Walcz Date: Mon, 19 Oct 2026 19:27:47 +0200 Subject: [PATCH 16/17] fix(chathistory): keep the chat's reasoning effort on the server reasoningEffort (#12700, #12716) is part of the chat the UI syncs since the master merge, but schema.Conversation had no field for it, so the server dropped it and a reload from the server reset the setting. Add the field. The handler spec that round-trips every chat field now includes reasoningEffort. A schema test reads serializeChat in useChat.js and fails for any key the UI sends that schema.Conversation cannot hold, so the next such drift shows up as a failing test rather than in review (it reports reasoningEffort on the previous head). Swagger and docs updated. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Stefan Walcz --- core/http/routes/chat_history_auth_test.go | 4 +- core/schema/chat_conversation.go | 21 ++++++----- core/schema/chat_conversation_ui_test.go | 43 ++++++++++++++++++++++ docs/content/features/chat-history.md | 2 +- swagger/docs.go | 4 ++ swagger/swagger.json | 4 ++ swagger/swagger.yaml | 5 +++ 7 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 core/schema/chat_conversation_ui_test.go diff --git a/core/http/routes/chat_history_auth_test.go b/core/http/routes/chat_history_auth_test.go index e13c4a9ad2d5..f6f03ebbc9b3 100644 --- a/core/http/routes/chat_history_auth_test.go +++ b/core/http/routes/chat_history_auth_test.go @@ -171,15 +171,17 @@ var _ = Describe("Chat history endpoints", func() { body["systemPrompt"] = "be brief" body["temperature"] = 0.2 body["contextSize"] = 8192 + body["reasoningEffort"] = "low" Expect(s.do("alice", http.MethodPut, "/api/conversations/c1", body).Code).To(Equal(http.StatusOK)) rec := s.do("alice", http.MethodGet, "/api/conversations/c1", nil) Expect(rec.Code).To(Equal(http.StatusOK)) got := decodeJSON(rec) - for _, key := range []string{"localaiAssistant", "mcpMode", "mcpServers", "mcpResources", "clientMCPServers", "systemPrompt", "temperature", "contextSize"} { + for _, key := range []string{"localaiAssistant", "mcpMode", "mcpServers", "mcpResources", "clientMCPServers", "systemPrompt", "temperature", "contextSize", "reasoningEffort"} { Expect(got).To(HaveKey(key), "field %q was dropped", key) } Expect(got["localaiAssistant"]).To(BeTrue()) + Expect(got["reasoningEffort"]).To(Equal("low")) }) It("takes the id from the path over the body", func() { diff --git a/core/schema/chat_conversation.go b/core/schema/chat_conversation.go index 041af58b8fe6..05d7b2456c22 100644 --- a/core/schema/chat_conversation.go +++ b/core/schema/chat_conversation.go @@ -23,15 +23,18 @@ type Conversation struct { ClientMCPServers json.RawMessage `json:"clientMCPServers,omitempty" swaggertype:"array,object"` // LocalAIAssistant wires the chat to the in-process admin MCP server // (the LocalAI Assistant mode of the Chat page). - LocalAIAssistant bool `json:"localaiAssistant,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"` + LocalAIAssistant bool `json:"localaiAssistant,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"topP,omitempty"` + TopK *float64 `json:"topK,omitempty"` + // ReasoningEffort is the chat's reasoning_effort choice ('' = model config, + // 'none' also hides reasoning traces). + ReasoningEffort string `json:"reasoningEffort,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"` // Version is assigned by the server: 1 on the first save, +1 on every // later write including the delete. Clients use a non-zero version to // tell a conversation the server has seen from a never-synced draft. diff --git a/core/schema/chat_conversation_ui_test.go b/core/schema/chat_conversation_ui_test.go new file mode 100644 index 000000000000..d67b11575ac5 --- /dev/null +++ b/core/schema/chat_conversation_ui_test.go @@ -0,0 +1,43 @@ +package schema_test + +import ( + "os" + "reflect" + "regexp" + "strings" + "testing" + + "github.com/mudler/LocalAI/core/schema" +) + +// TestConversationKeepsEveryFieldTheUISends guards against the server +// silently dropping a chat setting: every key serializeChat in the React +// UI sends must have a JSON field on schema.Conversation. reasoningEffort +// and localaiAssistant were each lost this way once. +func TestConversationKeepsEveryFieldTheUISends(t *testing.T) { + src, err := os.ReadFile("../http/react-ui/src/hooks/useChat.js") + if err != nil { + t.Fatalf("read useChat.js: %v", err) + } + body := regexp.MustCompile(`(?s)function serializeChat\(chat\) \{\s*return \{(.*?)\n \}`).FindSubmatch(src) + if body == nil { + t.Fatal("serializeChat not found in useChat.js") + } + sent := regexp.MustCompile(`(?m)^\s*(\w+):`).FindAllSubmatch(body[1], -1) + if len(sent) < 10 { + t.Fatalf("only %d keys found in serializeChat; the pattern no longer matches", len(sent)) + } + + known := map[string]bool{} + ty := reflect.TypeOf(schema.Conversation{}) + for i := 0; i < ty.NumField(); i++ { + if name := strings.Split(ty.Field(i).Tag.Get("json"), ",")[0]; name != "" { + known[name] = true + } + } + for _, m := range sent { + if key := string(m[1]); !known[key] { + t.Errorf("serializeChat sends %q but schema.Conversation has no field for it; the server drops it", key) + } + } +} diff --git a/docs/content/features/chat-history.md b/docs/content/features/chat-history.md index 656c55f497c1..997e7f848e36 100644 --- a/docs/content/features/chat-history.md +++ b/docs/content/features/chat-history.md @@ -68,7 +68,7 @@ All endpoints act on the conversations of the calling user and need a session or Status codes: `400` for a malformed id or body, `401` without credentials, `403` without the `chat_history` permission, `409` when concurrent writers kept racing on the same id (retry), `410` for a deleted conversation. -Conversation ids are 1 to 128 characters from `A-Z a-z 0-9 . _ -`. A conversation carries what the Chat page keeps per chat: name, model, system prompt, sampling settings, MCP and LocalAI Assistant settings, token usage and context size (`schema.Conversation` in `/swagger`). The message list (`history`) is stored as sent, so every message shape the UI uses (text, images, audio, files, tool calls, thinking) survives the round trip. +Conversation ids are 1 to 128 characters from `A-Z a-z 0-9 . _ -`. A conversation carries what the Chat page keeps per chat: name, model, system prompt, sampling and reasoning-effort settings, MCP and LocalAI Assistant settings, token usage and context size (`schema.Conversation` in `/swagger`). The message list (`history`) is stored as sent, so every message shape the UI uses (text, images, audio, files, tool calls, thinking) survives the round trip. ### Example diff --git a/swagger/docs.go b/swagger/docs.go index acd0a0204d03..7b91fde74cae 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -6491,6 +6491,10 @@ const docTemplate = `{ "name": { "type": "string" }, + "reasoningEffort": { + "description": "ReasoningEffort is the chat's reasoning_effort choice ('' = model config,\n'none' also hides reasoning traces).", + "type": "string" + }, "systemPrompt": { "type": "string" }, diff --git a/swagger/swagger.json b/swagger/swagger.json index 776ddfbbe14e..cd3281083d3f 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -6488,6 +6488,10 @@ "name": { "type": "string" }, + "reasoningEffort": { + "description": "ReasoningEffort is the chat's reasoning_effort choice ('' = model config,\n'none' also hides reasoning traces).", + "type": "string" + }, "systemPrompt": { "type": "string" }, diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 36eb79b0956f..d9cd427694be 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -1061,6 +1061,11 @@ definitions: type: string name: type: string + reasoningEffort: + description: |- + ReasoningEffort is the chat's reasoning_effort choice ('' = model config, + 'none' also hides reasoning traces). + type: string systemPrompt: type: string temperature: From 67819792977f57a2d5f2a41fef7335383f56c6a4 Mon Sep 17 00:00:00 2001 From: mudler-agent Date: Mon, 19 Oct 2026 20:26:10 +0000 Subject: [PATCH 17/17] test(chat): use Ginkgo for the schema drift guard The new drift guard uses testing methods forbidden by repository lint. Keep its field coverage and fail-fast parser checks in Ginkgo/Gomega. Assisted-by: nib:gpt-6.1-sol --- core/schema/chat_conversation_ui_test.go | 53 +++++++++++------------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/core/schema/chat_conversation_ui_test.go b/core/schema/chat_conversation_ui_test.go index d67b11575ac5..567864e94b6c 100644 --- a/core/schema/chat_conversation_ui_test.go +++ b/core/schema/chat_conversation_ui_test.go @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT package schema_test import ( @@ -5,39 +6,33 @@ import ( "reflect" "regexp" "strings" - "testing" "github.com/mudler/LocalAI/core/schema" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) -// TestConversationKeepsEveryFieldTheUISends guards against the server -// silently dropping a chat setting: every key serializeChat in the React -// UI sends must have a JSON field on schema.Conversation. reasoningEffort -// and localaiAssistant were each lost this way once. -func TestConversationKeepsEveryFieldTheUISends(t *testing.T) { - src, err := os.ReadFile("../http/react-ui/src/hooks/useChat.js") - if err != nil { - t.Fatalf("read useChat.js: %v", err) - } - body := regexp.MustCompile(`(?s)function serializeChat\(chat\) \{\s*return \{(.*?)\n \}`).FindSubmatch(src) - if body == nil { - t.Fatal("serializeChat not found in useChat.js") - } - sent := regexp.MustCompile(`(?m)^\s*(\w+):`).FindAllSubmatch(body[1], -1) - if len(sent) < 10 { - t.Fatalf("only %d keys found in serializeChat; the pattern no longer matches", len(sent)) - } +// Every key serializeChat sends needs a JSON field so persistence cannot +// silently discard a chat setting. +var _ = Describe("Conversation UI schema", func() { + It("keeps every field the UI sends", func() { + src, err := os.ReadFile("../http/react-ui/src/hooks/useChat.js") + Expect(err).NotTo(HaveOccurred(), "read useChat.js") + body := regexp.MustCompile(`(?s)function serializeChat\(chat\) \{\s*return \{(.*?)\n \}`).FindSubmatch(src) + Expect(body).NotTo(BeNil(), "serializeChat not found in useChat.js") + sent := regexp.MustCompile(`(?m)^\s*(\w+):`).FindAllSubmatch(body[1], -1) + Expect(len(sent)).To(BeNumerically(">=", 10), "the serializeChat pattern no longer matches") - known := map[string]bool{} - ty := reflect.TypeOf(schema.Conversation{}) - for i := 0; i < ty.NumField(); i++ { - if name := strings.Split(ty.Field(i).Tag.Get("json"), ",")[0]; name != "" { - known[name] = true + known := map[string]bool{} + ty := reflect.TypeOf(schema.Conversation{}) + for i := 0; i < ty.NumField(); i++ { + if name := strings.Split(ty.Field(i).Tag.Get("json"), ",")[0]; name != "" { + known[name] = true + } } - } - for _, m := range sent { - if key := string(m[1]); !known[key] { - t.Errorf("serializeChat sends %q but schema.Conversation has no field for it; the server drops it", key) + for _, m := range sent { + key := string(m[1]) + Expect(known).To(HaveKey(key), "serializeChat sends %q but schema.Conversation has no field for it; the server drops it", key) } - } -} + }) +})