Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 23 additions & 0 deletions core/application/application.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package application

import (
"cmp"
"context"
"math/rand/v2"
"path/filepath"
"sync"
"sync/atomic"
"time"
Expand All @@ -11,6 +13,7 @@ import (
"github.com/mudler/LocalAI/core/config"
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/nodes"
Expand Down Expand Up @@ -50,6 +53,7 @@ type Application struct {
agentPoolService atomic.Pointer[agentpool.AgentPoolService]
faceRegistry facerecognition.Registry
voiceRegistry voicerecognition.Registry
chatHistoryStore *chathistory.Store
authDB *gorm.DB
watchdogMutex sync.Mutex
watchdogStop chan bool
Expand Down Expand Up @@ -180,6 +184,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 @@ -280,6 +291,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
}

Expand Down
5 changes: 4 additions & 1 deletion core/http/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,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 @@ -366,7 +368,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 @@ -92,6 +92,12 @@ var instructionDefs = []instructionDef{
Tags: []string{"branding"},
Intro: "GET /api/branding is public so the login screen can render the configured logo before authentication. Text fields are saved through POST /api/settings; binary assets (logo, horizontal logo, favicon) use multipart upload at /api/branding/asset/{kind} and are served back from /branding/asset/{kind}.",
},
{
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(12))
Expect(instructions).To(HaveLen(13))

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