Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
285c580
feat(schema): add Conversation schema for chat history (#9432)
TLoE419 May 19, 2026
e8ab2e1
feat(chathistory): add file-based per-user conversation store (#9432)
TLoE419 May 19, 2026
7f5aece
feat(api): expose chat conversation CRUD endpoints (#9432)
TLoE419 May 19, 2026
1bbe0a3
feat(react-ui): sync chat history to server with localStorage fallbac…
TLoE419 May 19, 2026
bb68090
test(chathistory): rewrite tests in Ginkgo/Gomega
TLoE419 May 19, 2026
c6fdd2f
refactor(chathistory): switch store to GORM-backed authDB
TLoE419 May 25, 2026
620b57b
refactor(schema): drop now-unused ConversationsFile envelope
TLoE419 May 25, 2026
b881566
test(chathistory): switch tests to testutil.SetupTestDB
TLoE419 May 25, 2026
8972605
test(chathistory): cover the conversation HTTP handlers
walcz-de Oct 13, 2026
78da518
fix(chathistory): make deletions final with tombstones and versions
walcz-de Oct 13, 2026
8c80e84
fix(react-ui): let the server decide which chats were deleted
walcz-de Oct 13, 2026
2cfca0c
fix(chathistory): keep the LocalAI Assistant toggle on synced chats
walcz-de Oct 13, 2026
ef0ae8f
fix(react-ui): scope chat cache per account, retry unconfirmed deletes
walcz-de Oct 17, 2026
8816a72
fix(react-ui): persist cleared chats immediately
localai-org-maint-bot Oct 19, 2026
3e5d178
Merge upstream/master into feat/chat-history-server
walcz-de Oct 19, 2026
a5b50c1
test(react-ui): make the chat history sync specs deterministic
walcz-de Oct 19, 2026
5f42128
fix(chathistory): keep the chat's reasoning effort on the server
walcz-de Oct 19, 2026
6781979
test(chat): use Ginkgo for the schema drift guard
mudler-agent Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions core/application/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -69,6 +70,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
Expand Down Expand Up @@ -291,6 +293,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
Expand Down Expand Up @@ -667,6 +676,21 @@ func (a *Application) start() error {

a.agentJobService = agentJobService

// Initialize chat history store for the WebUI (issue #9432). Reuses the
// shared auth database so per-user chat history sits alongside agent
// configs and jobs in one place — mudler's review on #9902 called out
// the file-based store as inconsistent with the rest of LocalAI's
// per-user state. When auth is disabled the store stays nil and the
// React UI falls back to localStorage.
if !a.applicationConfig.DisableWebUI && a.authDB != nil {
store, err := chathistory.New(a.authDB)
if err != nil {
xlog.Warn("Chat history persistence disabled: failed to initialise store", "error", err)
} else {
a.chatHistoryStore = store
}
}

return nil
}

Expand Down
5 changes: 4 additions & 1 deletion core/http/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -512,7 +514,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)
Expand Down
10 changes: 10 additions & 0 deletions core/http/auth/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,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},
Expand Down Expand Up @@ -222,5 +231,6 @@ func APIFeatureMetas() []FeatureMeta {
{FeatureAudioTransform, "Audio Transform", true},
{FeaturePIIFilter, "PII Analyze / Redact", true},
{FeatureDecisions, "Decisions", true},
{FeatureChatHistory, "Chat History", true},
}
}
4 changes: 3 additions & 1 deletion core/http/auth/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,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).
Expand All @@ -81,6 +82,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).
Expand Down
6 changes: 6 additions & 0 deletions core/http/endpoints/localai/api_instructions.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,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",
Tags: []string{"chat-history"},
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`.",
},
}

// swaggerState holds parsed swagger spec data, initialised once.
Expand Down
3 changes: 2 additions & 1 deletion core/http/endpoints/localai/api_instructions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ var _ = Describe("API Instructions Endpoints", func() {

instructions, ok := resp["instructions"].([]any)
Expect(ok).To(BeTrue())
Expect(instructions).To(HaveLen(22))
Expect(instructions).To(HaveLen(23))

// Verify each instruction has required fields and correct URL format
for _, s := range instructions {
Expand Down Expand Up @@ -83,6 +83,7 @@ var _ = Describe("API Instructions Endpoints", func() {
"3d",
"failover",
"decisions",
"chat-history",
"motion",
))
})
Expand Down
189 changes: 189 additions & 0 deletions core/http/endpoints/localai/chat_history.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package localai

import (
"errors"
"net/http"

"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/chathistory"
)

// 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
// @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 {
userID := getUserID(c)
convs, err := store.List(userID)
if err != nil {
return chatHistoryError(c, err)
}
deleted, err := store.Tombstones(userID)
if err != nil {
return chatHistoryError(c, err)
}
return c.JSON(http.StatusOK, schema.ConversationList{Conversations: convs, Deleted: deleted})
}
}

// GetConversationEndpoint returns a single conversation by ID.
//
// @Summary Get a chat conversation
// @Tags chat-history
// @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 {
return chatHistoryError(c, err)
}
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/<id> works as expected).
//
// @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
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 {
return chatHistoryError(c, err)
}
return c.JSON(http.StatusOK, saved)
}
}

// SaveConversationByIDEndpoint is SaveConversationEndpoint under its own
// name so the PUT route gets its own swagger entry.
//
// @Summary Save a chat conversation by id (upsert)
// @Tags chat-history
// @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 schema.ConversationBulkRequest
if err := c.Bind(&payload); err != nil {
return c.JSON(http.StatusBadRequest, 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, schema.ConversationBulkResponse{
Status: "ok",
Count: len(payload.Conversations) - len(skipped),
Skipped: skipped,
})
}
}

// DeleteConversationEndpoint deletes a single conversation, leaving a
// tombstone. Idempotent.
//
// @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 {
return chatHistoryError(c, err)
}
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
}
}

// 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 chatHistoryError(c, err)
}
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
}
}
Loading
Loading