Skip to content

feat: persist chat conversations server-side (#9432)#9902

Open
TLoE419 wants to merge 9 commits into
mudler:masterfrom
shihyunhuang:feature/persist-chat-history-server-side
Open

feat: persist chat conversations server-side (#9432)#9902
TLoE419 wants to merge 9 commits into
mudler:masterfrom
shihyunhuang:feature/persist-chat-history-server-side

Conversation

@TLoE419
Copy link
Copy Markdown
Contributor

@TLoE419 TLoE419 commented May 19, 2026

Description

This PR fixes #9432

Chat conversations in the WebUI previously lived only in browser localStorage. Switching browsers, opening an incognito window, or clearing site data wiped the user's entire chat history. This PR adds server-side persistence so chats survive across browsers / devices when the WebUI is enabled.

What changed

  • core/services/chathistory — new file-based, per-user persister at {DataPath|DynamicConfigsDir}/chat_history/{userID}/conversations.json (anonymous/ when auth is disabled). Atomic writes via tmp file + os.Rename so a crash mid-save never leaves a corrupted history.
  • /api/conversations CRUD endpoints — GET / POST / PUT / DELETE plus PUT /api/conversations/bulk for first-time localStorage migration. All endpoints are user-scoped via getUserID(c); callers cannot impersonate other users.
  • chat_history feature permission (default ON in APIFeatures), registered through the existing RouteFeatureRegistry so the unified feature middleware picks it up automatically.
  • React useChat hook — probes /api/conversations on mount. Server becomes authoritative when reachable; on 404 the hook silently falls back to the old localStorage-only behaviour, so this is fully backward-compatible.
  • Tests — Ginkgo/Gomega suite covering round-trip across instances, user isolation, path-traversal rejection, anonymous fallback, and bulk-replace overwrite. Existing api_instructions test updated for the new entry.
  • Discoverability — new chat-history instruction entry surfaces in /api/instructions, plus matching Swagger tag.

Notes for Reviewers

  • The issue reporter expected one of (1) chat persists, (2) clear docs, or (3) a configurable option. This PR delivers (1) automatically whenever DataPath resolves — no env vars to set.
  • Fully backward-compatible: when DisableWebUI=true or no persistence path resolves, Application.ChatHistoryStore() returns nil, route registration is skipped, and the React hook detects 404 to fall back to localStorage. Older deployments and disabled feature both keep working unchanged.
  • History is stored as json.RawMessage so the server stays agnostic to React message shapes — user / assistant / thinking / tool_call / tool_result roles mixed with text / image_url / audio_url / file attachments all round-trip lossless.
  • The atomic /bulk endpoint avoids partial-upload states where a retry would skip already-uploaded entries — important for the localStorage migration which only fires when the server is empty.
  • Auth disabled: all anonymous users share anonymous/conversations.json. Matches expected single-user-deployment behavior; per-user isolation kicks in automatically once auth is enabled.

Test plan

  • `go test ./core/services/chathistory/...` — passes (Ginkgo/Gomega)
  • `go test ./core/http/endpoints/localai/...` — passes
  • `make build` succeeds in a clean worktree
  • Manual: CRUD via curl against running server
  • Manual: WebUI in incognito window sees chats persisted by another window (issue feat: persist Chat History server-side #9432 repro)
  • Manual: `localStorage.clear()` + reload still shows server-side history
  • Manual: side-by-side vs. older binary (v4.1.3) confirms the React 404 fallback path

Signed commits

  • Yes, I signed my commits.

@TLoE419 TLoE419 marked this pull request as ready for review May 19, 2026 21:37
Comment thread core/services/chathistory/store.go Outdated

// 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")
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is fine in the case the user does not specify any DB. but IF we are adding this feature we should be consistent here and store the conversations in the DB

Comment thread core/services/chathistory/store.go Outdated
baseDir string

mu sync.Mutex
cache map[string]map[string]schema.Conversation // userID -> id -> conv
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this calls for a TTL or a mechanism to not grow this idenfinitely in memory

TLoE419 added a commit to shihyunhuang/LocalAI that referenced this pull request May 26, 2026
Addresses mudler's review on mudler#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]
TLoE419 added a commit to shihyunhuang/LocalAI that referenced this pull request May 26, 2026
Addresses mudler's review on mudler#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 <tloemizuchizu@gmail.com>
@TLoE419 TLoE419 force-pushed the feature/persist-chat-history-server-side branch from 4b37339 to cbb67e8 Compare May 26, 2026 16:52
TLoE419 added 9 commits May 26, 2026 10:50
Persist WebUI chat conversations server-side so browser refresh, private
windows, or device changes preserve user history (issue mudler#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 <tloemizuchizu@gmail.com>
…#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 <tloemizuchizu@gmail.com>
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 <tloemizuchizu@gmail.com>
mudler#9432)

useChat now probes /api/conversations on mount. When the endpoint
responds 200, the server becomes the authoritative source: the hook
merges remote conversations into local state and pushes per-chat PUT
updates on each debounced save. When the endpoint 404s - older LocalAI
deploys or the feature disabled - the hook silently keeps the old
localStorage-only behaviour, so the change is backward-compatible.

Migration: when the server is reachable but empty and the browser has
local conversations with history, the hook fires a single
PUT /api/conversations/bulk to seed the server. The atomic bulk endpoint
avoids partial-upload states where a retry would skip already-uploaded
entries.

Delete operations propagate to the server when it's available; failures
are silently swallowed so the local UI stays responsive on transient
network errors.

Assisted-by: Claude:claude-opus-4-7

Signed-off-by: TLoE419 <tloemizuchizu@gmail.com>
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 <tloemizuchizu@gmail.com>
The new chat-history instruction definition pushes the total
instructionDefs entries from 12 to 13. Update the hard-coded length
assertion in api_instructions_test.go to match.

The presence-level assertion in the sibling \"should include known
instruction names\" test already uses ContainElements rather than
ConsistOf, so no further edits are needed there.

Assisted-by: Claude:claude-opus-4-7

Signed-off-by: TLoE419 <tloemizuchizu@gmail.com>
Addresses mudler's review on mudler#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 <tloemizuchizu@gmail.com>
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 <tloemizuchizu@gmail.com>
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 <tloemizuchizu@gmail.com>
@TLoE419 TLoE419 force-pushed the feature/persist-chat-history-server-side branch from cbb67e8 to e6109ec Compare May 26, 2026 20:31
@TLoE419
Copy link
Copy Markdown
Contributor Author

TLoE419 commented May 26, 2026

Reworked the store to use Application.authDB via a new ConversationRecord GORM model — addresses your DB-consistency comment and removes the unbounded in-memory map. Also rebased on master so the branch is mergeable.

@TLoE419 TLoE419 requested a review from mudler May 26, 2026 20:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: persist Chat History server-side

2 participants