Skip to content
Open
24 changes: 24 additions & 0 deletions core/application/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/mudler/LocalAI/core/http/auth"
mcpTools "github.com/mudler/LocalAI/core/http/endpoints/mcp"
"github.com/mudler/LocalAI/core/services/agentpool"
"github.com/mudler/LocalAI/core/services/chathistory"
"github.com/mudler/LocalAI/core/services/facerecognition"
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/core/services/monitoring"
Expand Down Expand Up @@ -57,6 +58,7 @@ type Application struct {
agentPoolService atomic.Pointer[agentpool.AgentPoolService]
faceRegistry facerecognition.Registry
voiceRegistry voicerecognition.Registry
chatHistoryStore *chathistory.Store
authDB *gorm.DB
metricsService *monitoring.LocalAIMetricsService
statsRecorder *billing.Recorder
Expand Down Expand Up @@ -203,6 +205,13 @@ func (a *Application) VoiceRegistry() voicerecognition.Registry {
return a.voiceRegistry
}

// ChatHistoryStore returns the server-side WebUI chat history store, or nil
// when the feature is disabled (LOCALAI_DISABLE_WEBUI=true or persistence
// path could not be resolved).
func (a *Application) ChatHistoryStore() *chathistory.Store {
return a.chatHistoryStore
}

// AuthDB returns the auth database connection, or nil if auth is not enabled.
func (a *Application) AuthDB() *gorm.DB {
return a.authDB
Expand Down Expand Up @@ -409,6 +418,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 @@ -88,6 +88,8 @@ var quietPaths = []string{"/api/operations", "/api/resources", "/healthz", "/rea
// @tag.description Document reranking
// @tag.name instructions
// @tag.description API instruction discovery — browse instruction areas and get endpoint guides
// @tag.name chat-history
// @tag.description Server-side persistence of WebUI chat conversations

func API(application *application.Application) (*echo.Echo, error) {
e := echo.New()
Expand Down Expand Up @@ -384,7 +386,8 @@ func API(application *application.Application) (*echo.Echo, error) {
}

mcpMw := auth.RequireFeature(application.AuthDB(), auth.FeatureMCP)
routes.RegisterLocalAIRoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application.TemplatesEvaluator(), application, adminMiddleware, mcpJobsMw, mcpMw)
chatHistoryMw := auth.RequireFeature(application.AuthDB(), auth.FeatureChatHistory)
routes.RegisterLocalAIRoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application.TemplatesEvaluator(), application, adminMiddleware, mcpJobsMw, mcpMw, chatHistoryMw)
routes.RegisterAgentPoolRoutes(e, application, agentsMw, skillsMw, collectionsMw)
// Fine-tuning routes
fineTuningMw := auth.RequireFeature(application.AuthDB(), auth.FeatureFineTuning)
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 @@ -123,6 +123,15 @@ var RouteFeatureRegistry = []RouteFeature{
{"GET", "/api/fine-tuning/jobs/:id/download", FeatureFineTuning},
{"POST", "/api/fine-tuning/datasets", FeatureFineTuning},

// Chat History (server-side persistence of WebUI conversations, #9432)
{"GET", "/api/conversations", FeatureChatHistory},
{"DELETE", "/api/conversations", FeatureChatHistory},
{"POST", "/api/conversations", FeatureChatHistory},
{"PUT", "/api/conversations/bulk", FeatureChatHistory},
{"GET", "/api/conversations/:id", FeatureChatHistory},
{"PUT", "/api/conversations/:id", FeatureChatHistory},
{"DELETE", "/api/conversations/:id", FeatureChatHistory},

// Quantization
{"POST", "/api/quantization/jobs", FeatureQuantization},
{"GET", "/api/quantization/jobs", FeatureQuantization},
Expand Down Expand Up @@ -181,5 +190,6 @@ func APIFeatureMetas() []FeatureMeta {
{FeatureFaceRecognition, "Face Recognition", true},
{FeatureVoiceRecognition, "Voice Recognition", true},
{FeatureAudioTransform, "Audio Transform", true},
{FeatureChatHistory, "Chat History", true},
}
}
2 changes: 2 additions & 0 deletions core/http/auth/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const (
FeatureFaceRecognition = "face_recognition"
FeatureVoiceRecognition = "voice_recognition"
FeatureAudioTransform = "audio_transform"
FeatureChatHistory = "chat_history"
)

// AgentFeatures lists agent-related features (default OFF).
Expand All @@ -71,6 +72,7 @@ var APIFeatures = []string{
FeatureVAD, FeatureDetection, FeatureVideo, FeatureEmbeddings, FeatureSound,
FeatureRealtime, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores,
FeatureFaceRecognition, FeatureVoiceRecognition, FeatureAudioTransform,
FeatureChatHistory,
}

// AllFeatures lists all known features (used by UI and validation).
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 @@ -116,6 +116,12 @@ var instructionDefs = []instructionDef{
Tags: []string{"router"},
Intro: "Add a `router:` block to a ModelConfig to turn it into a routing model. The block declares a classifier (today: `feature` — handcrafted rules over prompt length and code-fence presence), a list of candidates (label + downstream model + optional rule), and a fallback. When a client addresses the routing model, the RouteModel middleware invokes the classifier, picks a candidate, and rewrites input.Model — the standard model-resolution path then runs ACL, disabled-state, and per-model PII against the chosen target. Depth-1 invariant: candidates must NOT themselves carry a `router:` block; runtime check returns 500 on violation. Decisions are logged to GET /api/router/decisions and surfaced in the /app/middleware Routing tab. POST /api/router/decide is the programmatic decision-oracle: external routers (e.g. an organisation-wide router service) send `{router, input}` and receive the classifier's label set + candidate model WITHOUT LocalAI rewriting, forwarding, or recording the call. Shares the classifier cache with the in-band path so warm-up costs are paid once.",
},
{
Name: "chat-history",
Description: "Server-side persistence of WebUI chat conversations (#9432)",
Tags: []string{"chat-history"},
Intro: "Per-user CRUD over chat conversations. POST/PUT upsert by id; PUT /bulk replaces the entire conversation set in one shot (used for localStorage migration).",
},
}

// swaggerState holds parsed swagger spec data, initialised once.
Expand Down
2 changes: 1 addition & 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(16))
Expect(instructions).To(HaveLen(17))

// Verify each instruction has required fields and correct URL format
for _, s := range instructions {
Expand Down
161 changes: 161 additions & 0 deletions core/http/endpoints/localai/chat_history.go
Original file line number Diff line number Diff line change
@@ -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/<id> 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"})
}
}
Loading