Skip to content

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

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

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

Conversation

@TLoE419
Copy link
Copy Markdown

@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 added 6 commits May 18, 2026 18:05
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>
@TLoE419 TLoE419 marked this pull request as ready for review May 19, 2026 21:37

// 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

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

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