diff --git a/.github/gallery-agent/agent.go b/.github/gallery-agent/agent.go index 0b6a0d81a..87eee4f7e 100644 --- a/.github/gallery-agent/agent.go +++ b/.github/gallery-agent/agent.go @@ -13,8 +13,8 @@ import ( "github.com/ghodss/yaml" hfapi "github.com/mudler/LocalAI/pkg/huggingface-api" - cogito "github.com/mudler/cogito" - + "github.com/mudler/cogito" + "github.com/mudler/cogito/clients" "github.com/mudler/cogito/structures" "github.com/sashabaranov/go-openai/jsonschema" ) @@ -25,7 +25,7 @@ var ( openAIBaseURL = os.Getenv("OPENAI_BASE_URL") galleryIndexPath = os.Getenv("GALLERY_INDEX_PATH") //defaultclient - llm = cogito.NewOpenAILLM(openAIModel, openAIKey, openAIBaseURL) + llm = clients.NewOpenAILLM(openAIModel, openAIKey, openAIBaseURL) ) // cleanTextContent removes trailing spaces, tabs, and normalizes line endings diff --git a/README.md b/README.md index af937aa01..c177fb61e 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,7 @@ local-ai run oci://localai/phi-2:latest For more information, see [💻 Getting started](https://localai.io/basics/getting_started/index.html), if you are interested in our roadmap items and future enhancements, you can see the [Issues labeled as Roadmap here](https://github.com/mudler/LocalAI/issues?q=is%3Aissue+is%3Aopen+label%3Aroadmap) ## 📰 Latest project news +- March 2026: [Agent management](https://github.com/mudler/LocalAI/pull/8820), [New React UI](https://github.com/mudler/LocalAI/pull/8772), [WebRTC](https://github.com/mudler/LocalAI/pull/8790),[MLX-distributed via P2P and RDMA](https://github.com/mudler/LocalAI/pull/8801) - February 2026: [Realtime API for audio-to-audio with tool calling](https://github.com/mudler/LocalAI/pull/6245), [ACE-Step 1.5 support](https://github.com/mudler/LocalAI/pull/8396) - January 2026: **LocalAI 3.10.0** - Major release with Anthropic API support, Open Responses API for stateful agents, video & image generation suite (LTX-2), unified GPU backends, tool streaming & XML parsing, system-aware backend gallery, crash fixes for AVX-only CPUs and AMD VRAM reporting, request tracing, and new backends: **Moonshine** (ultra-fast transcription), **Pocket-TTS** (lightweight TTS). Vulkan arm64 builds now available. [Release notes](https://github.com/mudler/LocalAI/releases/tag/v3.10.0). - December 2025: [Dynamic Memory Resource reclaimer](https://github.com/mudler/LocalAI/pull/7583), [Automatic fitting of models to multiple GPUS(llama.cpp)](https://github.com/mudler/LocalAI/pull/7584), [Added Vibevoice backend](https://github.com/mudler/LocalAI/pull/7494) @@ -240,6 +241,7 @@ Roadmap items: [List of issues](https://github.com/mudler/LocalAI/issues?q=is%3A - 📈 [Reranker API](https://localai.io/features/reranker/) - 🆕🖧 [P2P Inferencing](https://localai.io/features/distribute/) - 🆕🔌 [Model Context Protocol (MCP)](https://localai.io/docs/features/mcp/) - Agentic capabilities with external tools and [LocalAGI's Agentic capabilities](https://github.com/mudler/LocalAGI) +- 🆕🤖 [Built-in Agents](https://localai.io/features/agents/) - Autonomous AI agents with tool use, knowledge base (RAG), skills, SSE streaming, import/export, and [Agent Hub](https://agenthub.localai.io) — powered by [LocalAGI](https://github.com/mudler/LocalAGI) - 🔊 Voice activity detection (Silero-VAD support) - 🌍 Integrated WebUI! diff --git a/core/application/application.go b/core/application/application.go index 38a9d2cf9..a95410611 100644 --- a/core/application/application.go +++ b/core/application/application.go @@ -8,6 +8,7 @@ import ( "github.com/mudler/LocalAI/core/services" "github.com/mudler/LocalAI/core/templates" "github.com/mudler/LocalAI/pkg/model" + "github.com/mudler/xlog" ) type Application struct { @@ -18,6 +19,7 @@ type Application struct { templatesEvaluator *templates.Evaluator galleryService *services.GalleryService agentJobService *services.AgentJobService + agentPoolService *services.AgentPoolService watchdogMutex sync.Mutex watchdogStop chan bool p2pMutex sync.Mutex @@ -59,6 +61,10 @@ func (a *Application) AgentJobService() *services.AgentJobService { return a.agentJobService } +func (a *Application) AgentPoolService() *services.AgentPoolService { + return a.agentPoolService +} + // StartupConfig returns the original startup configuration (from env vars, before file loading) func (a *Application) StartupConfig() *config.ApplicationConfig { return a.startupConfig @@ -88,5 +94,19 @@ func (a *Application) start() error { a.agentJobService = agentJobService + // Initialize agent pool service (LocalAGI integration) + if a.applicationConfig.AgentPool.Enabled { + aps, err := services.NewAgentPoolService(a.applicationConfig) + if err == nil { + if err := aps.Start(a.applicationConfig.Context); err != nil { + xlog.Error("Failed to start agent pool", "error", err) + } else { + a.agentPoolService = aps + } + } else { + xlog.Error("Failed to create agent pool service", "error", err) + } + } + return nil } diff --git a/core/cli/run.go b/core/cli/run.go index 3aab06450..7583d97f9 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -87,6 +87,28 @@ type RunCMD struct { AgentJobRetentionDays int `env:"LOCALAI_AGENT_JOB_RETENTION_DAYS,AGENT_JOB_RETENTION_DAYS" default:"30" help:"Number of days to keep agent job history (default: 30)" group:"api"` OpenResponsesStoreTTL string `env:"LOCALAI_OPEN_RESPONSES_STORE_TTL,OPEN_RESPONSES_STORE_TTL" default:"0" help:"TTL for Open Responses store (e.g., 1h, 30m, 0 = no expiration)" group:"api"` + // Agent Pool (LocalAGI) + DisableAgents bool `env:"LOCALAI_DISABLE_AGENTS" default:"false" help:"Disable the agent pool feature" group:"agents"` + AgentPoolAPIURL string `env:"LOCALAI_AGENT_POOL_API_URL" help:"Default API URL for agents (defaults to self-referencing LocalAI)" group:"agents"` + AgentPoolAPIKey string `env:"LOCALAI_AGENT_POOL_API_KEY" help:"Default API key for agents (defaults to first LocalAI API key)" group:"agents"` + AgentPoolDefaultModel string `env:"LOCALAI_AGENT_POOL_DEFAULT_MODEL" help:"Default model for agents" group:"agents"` + AgentPoolMultimodalModel string `env:"LOCALAI_AGENT_POOL_MULTIMODAL_MODEL" help:"Default multimodal model for agents" group:"agents"` + AgentPoolTranscriptionModel string `env:"LOCALAI_AGENT_POOL_TRANSCRIPTION_MODEL" help:"Default transcription model for agents" group:"agents"` + AgentPoolTranscriptionLanguage string `env:"LOCALAI_AGENT_POOL_TRANSCRIPTION_LANGUAGE" help:"Default transcription language for agents" group:"agents"` + AgentPoolTTSModel string `env:"LOCALAI_AGENT_POOL_TTS_MODEL" help:"Default TTS model for agents" group:"agents"` + AgentPoolStateDir string `env:"LOCALAI_AGENT_POOL_STATE_DIR" help:"State directory for agent pool" group:"agents"` + AgentPoolTimeout string `env:"LOCALAI_AGENT_POOL_TIMEOUT" default:"5m" help:"Default agent timeout" group:"agents"` + AgentPoolEnableSkills bool `env:"LOCALAI_AGENT_POOL_ENABLE_SKILLS" default:"false" help:"Enable skills service for agents" group:"agents"` + AgentPoolVectorEngine string `env:"LOCALAI_AGENT_POOL_VECTOR_ENGINE" default:"chromem" help:"Vector engine type for agent knowledge base" group:"agents"` + AgentPoolEmbeddingModel string `env:"LOCALAI_AGENT_POOL_EMBEDDING_MODEL" default:"granite-embedding-107m-multilingual" help:"Embedding model for agent knowledge base" group:"agents"` + AgentPoolCustomActionsDir string `env:"LOCALAI_AGENT_POOL_CUSTOM_ACTIONS_DIR" help:"Custom actions directory for agents" group:"agents"` + AgentPoolDatabaseURL string `env:"LOCALAI_AGENT_POOL_DATABASE_URL" help:"Database URL for agent collections" group:"agents"` + AgentPoolMaxChunkingSize int `env:"LOCALAI_AGENT_POOL_MAX_CHUNKING_SIZE" default:"400" help:"Maximum chunking size for knowledge base documents" group:"agents"` + AgentPoolChunkOverlap int `env:"LOCALAI_AGENT_POOL_CHUNK_OVERLAP" default:"0" help:"Chunk overlap size for knowledge base documents" group:"agents"` + AgentPoolEnableLogs bool `env:"LOCALAI_AGENT_POOL_ENABLE_LOGS" default:"false" help:"Enable agent logging" group:"agents"` + AgentPoolCollectionDBPath string `env:"LOCALAI_AGENT_POOL_COLLECTION_DB_PATH" help:"Database path for agent collections" group:"agents"` + AgentHubURL string `env:"LOCALAI_AGENT_HUB_URL" default:"https://agenthub.localai.io" help:"URL for the agent hub where users can browse and download agent configurations" group:"agents"` + Version bool } @@ -203,6 +225,68 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error { opts = append(opts, config.DisableMCP) } + // Agent Pool + if r.DisableAgents { + opts = append(opts, config.DisableAgentPool) + } + if r.AgentPoolAPIURL != "" { + opts = append(opts, config.WithAgentPoolAPIURL(r.AgentPoolAPIURL)) + } + if r.AgentPoolAPIKey != "" { + opts = append(opts, config.WithAgentPoolAPIKey(r.AgentPoolAPIKey)) + } + if r.AgentPoolDefaultModel != "" { + opts = append(opts, config.WithAgentPoolDefaultModel(r.AgentPoolDefaultModel)) + } + if r.AgentPoolMultimodalModel != "" { + opts = append(opts, config.WithAgentPoolMultimodalModel(r.AgentPoolMultimodalModel)) + } + if r.AgentPoolTranscriptionModel != "" { + opts = append(opts, config.WithAgentPoolTranscriptionModel(r.AgentPoolTranscriptionModel)) + } + if r.AgentPoolTranscriptionLanguage != "" { + opts = append(opts, config.WithAgentPoolTranscriptionLanguage(r.AgentPoolTranscriptionLanguage)) + } + if r.AgentPoolTTSModel != "" { + opts = append(opts, config.WithAgentPoolTTSModel(r.AgentPoolTTSModel)) + } + if r.AgentPoolStateDir != "" { + opts = append(opts, config.WithAgentPoolStateDir(r.AgentPoolStateDir)) + } + if r.AgentPoolTimeout != "" { + opts = append(opts, config.WithAgentPoolTimeout(r.AgentPoolTimeout)) + } + if r.AgentPoolEnableSkills { + opts = append(opts, config.EnableAgentPoolSkills) + } + if r.AgentPoolVectorEngine != "" { + opts = append(opts, config.WithAgentPoolVectorEngine(r.AgentPoolVectorEngine)) + } + if r.AgentPoolEmbeddingModel != "" { + opts = append(opts, config.WithAgentPoolEmbeddingModel(r.AgentPoolEmbeddingModel)) + } + if r.AgentPoolCustomActionsDir != "" { + opts = append(opts, config.WithAgentPoolCustomActionsDir(r.AgentPoolCustomActionsDir)) + } + if r.AgentPoolDatabaseURL != "" { + opts = append(opts, config.WithAgentPoolDatabaseURL(r.AgentPoolDatabaseURL)) + } + if r.AgentPoolMaxChunkingSize > 0 { + opts = append(opts, config.WithAgentPoolMaxChunkingSize(r.AgentPoolMaxChunkingSize)) + } + if r.AgentPoolChunkOverlap > 0 { + opts = append(opts, config.WithAgentPoolChunkOverlap(r.AgentPoolChunkOverlap)) + } + if r.AgentPoolEnableLogs { + opts = append(opts, config.EnableAgentPoolLogs) + } + if r.AgentPoolCollectionDBPath != "" { + opts = append(opts, config.WithAgentPoolCollectionDBPath(r.AgentPoolCollectionDBPath)) + } + if r.AgentHubURL != "" { + opts = append(opts, config.WithAgentHubURL(r.AgentHubURL)) + } + if idleWatchDog || busyWatchDog { opts = append(opts, config.EnableWatchDog) if idleWatchDog { diff --git a/core/config/application_config.go b/core/config/application_config.go index 92277e5f6..63fc3de2b 100644 --- a/core/config/application_config.go +++ b/core/config/application_config.go @@ -90,6 +90,33 @@ type ApplicationConfig struct { OpenResponsesStoreTTL time.Duration // TTL for Open Responses store (0 = no expiration) PathWithoutAuth []string + + // Agent Pool (LocalAGI integration) + AgentPool AgentPoolConfig +} + +// AgentPoolConfig holds configuration for the LocalAGI agent pool integration. +type AgentPoolConfig struct { + Enabled bool // default: true (disabled by LOCALAI_DISABLE_AGENTS=true) + StateDir string // default: DynamicConfigsDir (LocalAI configuration folder) + APIURL string // default: self-referencing LocalAI (http://127.0.0.1:) + APIKey string // default: first API key from LocalAI config + DefaultModel string + MultimodalModel string + TranscriptionModel string + TranscriptionLanguage string + TTSModel string + Timeout string // default: "5m" + EnableSkills bool + EnableLogs bool + CustomActionsDir string + CollectionDBPath string + VectorEngine string // default: "chromem" + EmbeddingModel string // default: "granite-embedding-107m-multilingual" + MaxChunkingSize int // default: 400 + ChunkOverlap int // default: 0 + DatabaseURL string + AgentHubURL string // default: "https://agenthub.localai.io" } type AppOption func(*ApplicationConfig) @@ -104,6 +131,14 @@ func NewApplicationConfig(o ...AppOption) *ApplicationConfig { LRUEvictionRetryInterval: 1 * time.Second, // Default: 1 second WatchDogInterval: 500 * time.Millisecond, // Default: 500ms TracingMaxItems: 1024, + AgentPool: AgentPoolConfig{ + Enabled: true, + Timeout: "5m", + VectorEngine: "chromem", + EmbeddingModel: "granite-embedding-107m-multilingual", + MaxChunkingSize: 400, + AgentHubURL: "https://agenthub.localai.io", + }, PathWithoutAuth: []string{ "/static/", "/generated-audio/", @@ -541,6 +576,122 @@ func WithHttpGetExemptedEndpoints(endpoints []string) AppOption { } } +// Agent Pool options + +var DisableAgentPool = func(o *ApplicationConfig) { + o.AgentPool.Enabled = false +} + +func WithAgentPoolAPIURL(url string) AppOption { + return func(o *ApplicationConfig) { + o.AgentPool.APIURL = url + } +} + +func WithAgentPoolAPIKey(key string) AppOption { + return func(o *ApplicationConfig) { + o.AgentPool.APIKey = key + } +} + +func WithAgentPoolDefaultModel(model string) AppOption { + return func(o *ApplicationConfig) { + o.AgentPool.DefaultModel = model + } +} + +func WithAgentPoolMultimodalModel(model string) AppOption { + return func(o *ApplicationConfig) { + o.AgentPool.MultimodalModel = model + } +} + +func WithAgentPoolTranscriptionModel(model string) AppOption { + return func(o *ApplicationConfig) { + o.AgentPool.TranscriptionModel = model + } +} + +func WithAgentPoolTranscriptionLanguage(lang string) AppOption { + return func(o *ApplicationConfig) { + o.AgentPool.TranscriptionLanguage = lang + } +} + +func WithAgentPoolTTSModel(model string) AppOption { + return func(o *ApplicationConfig) { + o.AgentPool.TTSModel = model + } +} + +func WithAgentPoolStateDir(dir string) AppOption { + return func(o *ApplicationConfig) { + o.AgentPool.StateDir = dir + } +} + +func WithAgentPoolTimeout(timeout string) AppOption { + return func(o *ApplicationConfig) { + o.AgentPool.Timeout = timeout + } +} + +var EnableAgentPoolSkills = func(o *ApplicationConfig) { + o.AgentPool.EnableSkills = true +} + +func WithAgentPoolVectorEngine(engine string) AppOption { + return func(o *ApplicationConfig) { + o.AgentPool.VectorEngine = engine + } +} + +func WithAgentPoolEmbeddingModel(model string) AppOption { + return func(o *ApplicationConfig) { + o.AgentPool.EmbeddingModel = model + } +} + +func WithAgentPoolCustomActionsDir(dir string) AppOption { + return func(o *ApplicationConfig) { + o.AgentPool.CustomActionsDir = dir + } +} + +func WithAgentPoolDatabaseURL(url string) AppOption { + return func(o *ApplicationConfig) { + o.AgentPool.DatabaseURL = url + } +} + +func WithAgentPoolMaxChunkingSize(size int) AppOption { + return func(o *ApplicationConfig) { + o.AgentPool.MaxChunkingSize = size + } +} + +func WithAgentPoolChunkOverlap(overlap int) AppOption { + return func(o *ApplicationConfig) { + o.AgentPool.ChunkOverlap = overlap + } +} + +var EnableAgentPoolLogs = func(o *ApplicationConfig) { + o.AgentPool.EnableLogs = true +} + +func WithAgentPoolCollectionDBPath(path string) AppOption { + return func(o *ApplicationConfig) { + o.AgentPool.CollectionDBPath = path + } +} + +func WithAgentHubURL(url string) AppOption { + return func(o *ApplicationConfig) { + o.AgentPool.AgentHubURL = url + } +} + // ToConfigLoaderOptions returns a slice of ConfigLoader Option. // Some options defined at the application level are going to be passed as defaults for // all the configuration for the models. @@ -621,6 +772,15 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings { openResponsesStoreTTL = "0" // default: no expiration } + // Agent Pool settings + agentPoolEnabled := o.AgentPool.Enabled + agentPoolDefaultModel := o.AgentPool.DefaultModel + agentPoolEmbeddingModel := o.AgentPool.EmbeddingModel + agentPoolMaxChunkingSize := o.AgentPool.MaxChunkingSize + agentPoolChunkOverlap := o.AgentPool.ChunkOverlap + agentPoolEnableLogs := o.AgentPool.EnableLogs + agentPoolCollectionDBPath := o.AgentPool.CollectionDBPath + return RuntimeSettings{ WatchdogEnabled: &watchdogEnabled, WatchdogIdleEnabled: &watchdogIdle, @@ -655,6 +815,13 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings { ApiKeys: &apiKeys, AgentJobRetentionDays: &agentJobRetentionDays, OpenResponsesStoreTTL: &openResponsesStoreTTL, + AgentPoolEnabled: &agentPoolEnabled, + AgentPoolDefaultModel: &agentPoolDefaultModel, + AgentPoolEmbeddingModel: &agentPoolEmbeddingModel, + AgentPoolMaxChunkingSize: &agentPoolMaxChunkingSize, + AgentPoolChunkOverlap: &agentPoolChunkOverlap, + AgentPoolEnableLogs: &agentPoolEnableLogs, + AgentPoolCollectionDBPath: &agentPoolCollectionDBPath, } } @@ -804,6 +971,36 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req } // This setting doesn't require restart, can be updated dynamically } + // Agent Pool settings + if settings.AgentPoolEnabled != nil { + o.AgentPool.Enabled = *settings.AgentPoolEnabled + requireRestart = true + } + if settings.AgentPoolDefaultModel != nil { + o.AgentPool.DefaultModel = *settings.AgentPoolDefaultModel + requireRestart = true + } + if settings.AgentPoolEmbeddingModel != nil { + o.AgentPool.EmbeddingModel = *settings.AgentPoolEmbeddingModel + requireRestart = true + } + if settings.AgentPoolMaxChunkingSize != nil { + o.AgentPool.MaxChunkingSize = *settings.AgentPoolMaxChunkingSize + requireRestart = true + } + if settings.AgentPoolChunkOverlap != nil { + o.AgentPool.ChunkOverlap = *settings.AgentPoolChunkOverlap + requireRestart = true + } + if settings.AgentPoolEnableLogs != nil { + o.AgentPool.EnableLogs = *settings.AgentPoolEnableLogs + requireRestart = true + } + if settings.AgentPoolCollectionDBPath != nil { + o.AgentPool.CollectionDBPath = *settings.AgentPoolCollectionDBPath + requireRestart = true + } + // Note: ApiKeys requires special handling (merging with startup keys) - handled in caller return requireRestart diff --git a/core/config/runtime_settings.go b/core/config/runtime_settings.go index 9c4d4531d..ea1765719 100644 --- a/core/config/runtime_settings.go +++ b/core/config/runtime_settings.go @@ -63,4 +63,13 @@ type RuntimeSettings struct { // Open Responses settings OpenResponsesStoreTTL *string `json:"open_responses_store_ttl,omitempty"` // TTL for stored responses (e.g., "1h", "30m", "0" = no expiration) + + // Agent Pool settings + AgentPoolEnabled *bool `json:"agent_pool_enabled,omitempty"` + AgentPoolDefaultModel *string `json:"agent_pool_default_model,omitempty"` + AgentPoolEmbeddingModel *string `json:"agent_pool_embedding_model,omitempty"` + AgentPoolMaxChunkingSize *int `json:"agent_pool_max_chunking_size,omitempty"` + AgentPoolChunkOverlap *int `json:"agent_pool_chunk_overlap,omitempty"` + AgentPoolEnableLogs *bool `json:"agent_pool_enable_logs,omitempty"` + AgentPoolCollectionDBPath *string `json:"agent_pool_collection_db_path,omitempty"` } diff --git a/core/http/app.go b/core/http/app.go index 60f74bf14..faf343a38 100644 --- a/core/http/app.go +++ b/core/http/app.go @@ -240,6 +240,7 @@ func API(application *application.Application) (*echo.Echo, error) { } routes.RegisterLocalAIRoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application.TemplatesEvaluator(), application) + routes.RegisterAgentPoolRoutes(e, application) routes.RegisterOpenAIRoutes(e, requestExtractor, application) routes.RegisterAnthropicRoutes(e, requestExtractor, application) routes.RegisterOpenResponsesRoutes(e, requestExtractor, application) diff --git a/core/http/endpoints/localai/agent_collections.go b/core/http/endpoints/localai/agent_collections.go new file mode 100644 index 000000000..83ca818c0 --- /dev/null +++ b/core/http/endpoints/localai/agent_collections.go @@ -0,0 +1,221 @@ +package localai + +import ( + "net/http" + "net/url" + "strings" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/application" +) + +func ListCollectionsEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + collections, err := svc.ListCollections() + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]any{ + "collections": collections, + "count": len(collections), + }) + } +} + +func CreateCollectionEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + var payload struct { + Name string `json:"name"` + } + if err := c.Bind(&payload); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + if err := svc.CreateCollection(payload.Name); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusCreated, map[string]string{"status": "ok", "name": payload.Name}) + } +} + +func UploadToCollectionEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + name := c.Param("name") + file, err := c.FormFile("file") + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "file required"}) + } + if svc.CollectionEntryExists(name, file.Filename) { + return c.JSON(http.StatusConflict, map[string]string{"error": "entry already exists"}) + } + src, err := file.Open() + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + defer src.Close() + if err := svc.UploadToCollection(name, file.Filename, src); err != nil { + if strings.Contains(err.Error(), "not found") { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]string{"status": "ok", "filename": file.Filename}) + } +} + +func ListCollectionEntriesEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + entries, err := svc.ListCollectionEntries(c.Param("name")) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]any{ + "entries": entries, + "count": len(entries), + }) + } +} + +func GetCollectionEntryContentEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + entryParam := c.Param("*") + entry, err := url.PathUnescape(entryParam) + if err != nil { + entry = entryParam + } + content, chunkCount, err := svc.GetCollectionEntryContent(c.Param("name"), entry) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]any{ + "content": content, + "chunk_count": chunkCount, + }) + } +} + +func SearchCollectionEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + var payload struct { + Query string `json:"query"` + MaxResults int `json:"max_results"` + } + if err := c.Bind(&payload); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + results, err := svc.SearchCollection(c.Param("name"), payload.Query, payload.MaxResults) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]any{ + "results": results, + "count": len(results), + }) + } +} + +func ResetCollectionEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + if err := svc.ResetCollection(c.Param("name")); err != nil { + if strings.Contains(err.Error(), "not found") { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + } +} + +func DeleteCollectionEntryEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + var payload struct { + Entry string `json:"entry"` + } + if err := c.Bind(&payload); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + remaining, err := svc.DeleteCollectionEntry(c.Param("name"), payload.Entry) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]any{ + "remaining_entries": remaining, + "count": len(remaining), + }) + } +} + +func AddCollectionSourceEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + var payload struct { + URL string `json:"url"` + UpdateInterval int `json:"update_interval"` + } + if err := c.Bind(&payload); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + if payload.UpdateInterval < 1 { + payload.UpdateInterval = 60 + } + if err := svc.AddCollectionSource(c.Param("name"), payload.URL, payload.UpdateInterval); err != nil { + if strings.Contains(err.Error(), "not found") { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + } +} + +func RemoveCollectionSourceEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + var payload struct { + URL string `json:"url"` + } + if err := c.Bind(&payload); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + if err := svc.RemoveCollectionSource(c.Param("name"), payload.URL); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + } +} + +func ListCollectionSourcesEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + sources, err := svc.ListCollectionSources(c.Param("name")) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]any{ + "sources": sources, + "count": len(sources), + }) + } +} diff --git a/core/http/endpoints/localai/agent_responses.go b/core/http/endpoints/localai/agent_responses.go new file mode 100644 index 000000000..391926223 --- /dev/null +++ b/core/http/endpoints/localai/agent_responses.go @@ -0,0 +1,213 @@ +package localai + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/application" + coreTypes "github.com/mudler/LocalAGI/core/types" + "github.com/mudler/xlog" + "github.com/sashabaranov/go-openai" +) + +// agentResponsesRequest is the minimal subset of the OpenResponses request body +// needed to route to an agent. +type agentResponsesRequest struct { + Model string `json:"model"` + Input json.RawMessage `json:"input"` + PreviousResponseID string `json:"previous_response_id,omitempty"` + Tools []json.RawMessage `json:"tools,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` +} + +// AgentResponsesInterceptor returns a middleware that intercepts /v1/responses +// requests when the model name matches an agent in the pool. If no agent matches, +// it restores the request body and falls through to the normal responses pipeline. +func AgentResponsesInterceptor(app *application.Application) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + if svc == nil { + return next(c) + } + + // Read and buffer the body so we can peek at the model name + body, err := io.ReadAll(c.Request().Body) + if err != nil { + return next(c) + } + // Always restore the body for the next handler + c.Request().Body = io.NopCloser(bytes.NewReader(body)) + + var req agentResponsesRequest + if err := json.Unmarshal(body, &req); err != nil || req.Model == "" { + return next(c) + } + + // Check if this model name is an agent + ag := svc.GetAgent(req.Model) + if ag == nil { + return next(c) + } + + // This is an agent — handle the request directly + messages := parseInputToMessages(req.Input) + if len(messages) == 0 { + return c.JSON(http.StatusBadRequest, map[string]any{ + "error": map[string]string{ + "type": "invalid_request_error", + "message": "no input messages provided", + }, + }) + } + + jobOptions := []coreTypes.JobOption{ + coreTypes.WithConversationHistory(messages), + } + + res := ag.Ask(jobOptions...) + + if res == nil { + return c.JSON(http.StatusInternalServerError, map[string]any{ + "error": map[string]string{ + "type": "server_error", + "message": "agent request failed or was cancelled", + }, + }) + } + if res.Error != nil { + xlog.Error("Error asking agent via responses API", "agent", req.Model, "error", res.Error) + return c.JSON(http.StatusInternalServerError, map[string]any{ + "error": map[string]string{ + "type": "server_error", + "message": res.Error.Error(), + }, + }) + } + + id := fmt.Sprintf("resp_%s", uuid.New().String()) + + return c.JSON(http.StatusOK, map[string]any{ + "id": id, + "object": "response", + "created_at": time.Now().Unix(), + "status": "completed", + "model": req.Model, + "previous_response_id": nil, + "output": []any{ + map[string]any{ + "type": "message", + "id": fmt.Sprintf("msg_%d", time.Now().UnixNano()), + "status": "completed", + "role": "assistant", + "content": []map[string]any{ + { + "type": "output_text", + "text": res.Response, + "annotations": []any{}, + }, + }, + }, + }, + }) + } + } +} + +// parseInputToMessages converts the raw JSON input (string or message array) to openai messages. +func parseInputToMessages(raw json.RawMessage) []openai.ChatCompletionMessage { + if len(raw) == 0 { + return nil + } + + // Try as string first + var text string + if err := json.Unmarshal(raw, &text); err == nil && text != "" { + return []openai.ChatCompletionMessage{ + {Role: "user", Content: text}, + } + } + + // Try as array of message objects + var messages []struct { + Type string `json:"type,omitempty"` + Role string `json:"role,omitempty"` + Content json.RawMessage `json:"content,omitempty"` + CallId string `json:"call_id,omitempty"` + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` + Output string `json:"output,omitempty"` + } + if err := json.Unmarshal(raw, &messages); err != nil { + return nil + } + + var result []openai.ChatCompletionMessage + for _, m := range messages { + switch m.Type { + case "function_call": + result = append(result, openai.ChatCompletionMessage{ + Role: "assistant", + ToolCalls: []openai.ToolCall{ + { + Type: "function", + ID: m.CallId, + Function: openai.FunctionCall{ + Arguments: m.Arguments, + Name: m.Name, + }, + }, + }, + }) + case "function_call_output": + if m.CallId != "" && m.Output != "" { + result = append(result, openai.ChatCompletionMessage{ + Role: "tool", + Content: m.Output, + ToolCallID: m.CallId, + }) + } + default: + if m.Role == "" { + continue + } + content := parseMessageContent(m.Content) + if content != "" { + result = append(result, openai.ChatCompletionMessage{ + Role: m.Role, + Content: content, + }) + } + } + } + return result +} + +// parseMessageContent extracts text from either a string or array of content items. +func parseMessageContent(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var text string + if err := json.Unmarshal(raw, &text); err == nil { + return text + } + var items []struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + } + if err := json.Unmarshal(raw, &items); err == nil { + for _, item := range items { + if item.Type == "text" || item.Type == "input_text" { + return item.Text + } + } + } + return "" +} diff --git a/core/http/endpoints/localai/agent_skills.go b/core/http/endpoints/localai/agent_skills.go new file mode 100644 index 000000000..0971e1f73 --- /dev/null +++ b/core/http/endpoints/localai/agent_skills.go @@ -0,0 +1,381 @@ +package localai + +import ( + "io" + "net/http" + "strings" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/application" + skilldomain "github.com/mudler/skillserver/pkg/domain" +) + +type skillResponse struct { + Name string `json:"name"` + Content string `json:"content"` + Description string `json:"description,omitempty"` + License string `json:"license,omitempty"` + Compatibility string `json:"compatibility,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + AllowedTools string `json:"allowed-tools,omitempty"` + ReadOnly bool `json:"readOnly"` +} + +func skillToResponse(s skilldomain.Skill) skillResponse { + out := skillResponse{Name: s.Name, Content: s.Content, ReadOnly: s.ReadOnly} + if s.Metadata != nil { + out.Description = s.Metadata.Description + out.License = s.Metadata.License + out.Compatibility = s.Metadata.Compatibility + out.Metadata = s.Metadata.Metadata + out.AllowedTools = s.Metadata.AllowedTools + } + return out +} + +func skillsToResponses(skills []skilldomain.Skill) []skillResponse { + out := make([]skillResponse, len(skills)) + for i, s := range skills { + out[i] = skillToResponse(s) + } + return out +} + +func ListSkillsEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + skills, err := svc.ListSkills() + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, skillsToResponses(skills)) + } +} + +func GetSkillsConfigEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + cfg := svc.GetSkillsConfig() + return c.JSON(http.StatusOK, cfg) + } +} + +func SearchSkillsEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + query := c.QueryParam("q") + skills, err := svc.SearchSkills(query) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, skillsToResponses(skills)) + } +} + +func CreateSkillEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + var payload struct { + Name string `json:"name"` + Description string `json:"description"` + Content string `json:"content"` + License string `json:"license,omitempty"` + Compatibility string `json:"compatibility,omitempty"` + AllowedTools string `json:"allowed-tools,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + } + if err := c.Bind(&payload); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + skill, err := svc.CreateSkill(payload.Name, payload.Description, payload.Content, payload.License, payload.Compatibility, payload.AllowedTools, payload.Metadata) + if err != nil { + if strings.Contains(err.Error(), "already exists") { + return c.JSON(http.StatusConflict, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusCreated, skillToResponse(*skill)) + } +} + +func GetSkillEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + skill, err := svc.GetSkill(c.Param("name")) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, skillToResponse(*skill)) + } +} + +func UpdateSkillEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + var payload struct { + Description string `json:"description"` + Content string `json:"content"` + License string `json:"license,omitempty"` + Compatibility string `json:"compatibility,omitempty"` + AllowedTools string `json:"allowed-tools,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + } + if err := c.Bind(&payload); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + skill, err := svc.UpdateSkill(c.Param("name"), payload.Description, payload.Content, payload.License, payload.Compatibility, payload.AllowedTools, payload.Metadata) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, skillToResponse(*skill)) + } +} + +func DeleteSkillEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + if err := svc.DeleteSkill(c.Param("name")); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + } +} + +func ExportSkillEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + // The wildcard param captures the path after /export/ + name := c.Param("*") + data, err := svc.ExportSkill(name) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + c.Response().Header().Set("Content-Disposition", "attachment; filename="+name+".tar.gz") + c.Response().Header().Set("Content-Type", "application/gzip") + return c.Blob(http.StatusOK, "application/gzip", data) + } +} + +func ImportSkillEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + file, err := c.FormFile("file") + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "file required"}) + } + src, err := file.Open() + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + defer src.Close() + data, err := io.ReadAll(src) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + skill, err := svc.ImportSkill(data) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusCreated, skill) + } +} + +// --- Skill Resources --- + +func ListSkillResourcesEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + resources, skill, err := svc.ListSkillResources(c.Param("name")) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + scripts := []map[string]any{} + references := []map[string]any{} + assets := []map[string]any{} + for _, res := range resources { + m := map[string]any{ + "path": res.Path, + "name": res.Name, + "size": res.Size, + "mime_type": res.MimeType, + "readable": res.Readable, + "modified": res.Modified.Format("2006-01-02T15:04:05Z07:00"), + } + switch res.Type { + case "script": + scripts = append(scripts, m) + case "reference": + references = append(references, m) + case "asset": + assets = append(assets, m) + } + } + return c.JSON(http.StatusOK, map[string]any{ + "scripts": scripts, + "references": references, + "assets": assets, + "readOnly": skill.ReadOnly, + }) + } +} + +func GetSkillResourceEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + content, info, err := svc.GetSkillResource(c.Param("name"), c.Param("*")) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + if c.QueryParam("encoding") == "base64" || !info.Readable { + return c.JSON(http.StatusOK, map[string]any{ + "content": content.Content, + "encoding": content.Encoding, + "mime_type": content.MimeType, + "size": content.Size, + }) + } + c.Response().Header().Set("Content-Type", content.MimeType) + return c.String(http.StatusOK, content.Content) + } +} + +func CreateSkillResourceEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + file, err := c.FormFile("file") + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "file is required"}) + } + path := c.FormValue("path") + if path == "" { + path = file.Filename + } + src, err := file.Open() + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to open file"}) + } + defer src.Close() + data, err := io.ReadAll(src) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + if err := svc.CreateSkillResource(c.Param("name"), path, data); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusCreated, map[string]string{"path": path}) + } +} + +func UpdateSkillResourceEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + var payload struct { + Content string `json:"content"` + } + if err := c.Bind(&payload); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + if err := svc.UpdateSkillResource(c.Param("name"), c.Param("*"), payload.Content); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + } +} + +func DeleteSkillResourceEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + if err := svc.DeleteSkillResource(c.Param("name"), c.Param("*")); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + } +} + +// --- Git Repos --- + +func ListGitReposEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + repos, err := svc.ListGitRepos() + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, repos) + } +} + +func AddGitRepoEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + var payload struct { + URL string `json:"url"` + } + if err := c.Bind(&payload); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + repo, err := svc.AddGitRepo(payload.URL) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusCreated, repo) + } +} + +func UpdateGitRepoEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + var payload struct { + URL string `json:"url"` + Enabled *bool `json:"enabled"` + } + if err := c.Bind(&payload); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + repo, err := svc.UpdateGitRepo(c.Param("id"), payload.URL, payload.Enabled) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, repo) + } +} + +func DeleteGitRepoEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + if err := svc.DeleteGitRepo(c.Param("id")); err != nil { + if strings.Contains(err.Error(), "not found") { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + } +} + +func SyncGitRepoEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + if err := svc.SyncGitRepo(c.Param("id")); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusAccepted, map[string]string{"status": "syncing"}) + } +} + +func ToggleGitRepoEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + repo, err := svc.ToggleGitRepo(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, repo) + } +} diff --git a/core/http/endpoints/localai/agents.go b/core/http/endpoints/localai/agents.go new file mode 100644 index 000000000..20119d4c3 --- /dev/null +++ b/core/http/endpoints/localai/agents.go @@ -0,0 +1,333 @@ +package localai + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/application" + "github.com/mudler/LocalAI/core/services" + "github.com/mudler/LocalAGI/core/state" + coreTypes "github.com/mudler/LocalAGI/core/types" + agiServices "github.com/mudler/LocalAGI/services" +) + +func ListAgentsEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + statuses := svc.ListAgents() + agents := make([]string, 0, len(statuses)) + for name := range statuses { + agents = append(agents, name) + } + sort.Strings(agents) + resp := map[string]any{ + "agents": agents, + "agentCount": len(agents), + "actions": len(agiServices.AvailableActions), + "connectors": len(agiServices.AvailableConnectors), + "statuses": statuses, + } + if hubURL := svc.AgentHubURL(); hubURL != "" { + resp["agent_hub_url"] = hubURL + } + return c.JSON(http.StatusOK, resp) + } +} + +func CreateAgentEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + var cfg state.AgentConfig + if err := c.Bind(&cfg); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + if err := svc.CreateAgent(&cfg); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusCreated, map[string]string{"status": "ok"}) + } +} + +func GetAgentEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + name := c.Param("name") + ag := svc.GetAgent(name) + if ag == nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"}) + } + return c.JSON(http.StatusOK, map[string]any{ + "active": !ag.Paused(), + }) + } +} + +func UpdateAgentEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + name := c.Param("name") + var cfg state.AgentConfig + if err := c.Bind(&cfg); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + if err := svc.UpdateAgent(name, &cfg); err != nil { + if strings.Contains(err.Error(), "not found") { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + } +} + +func DeleteAgentEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + name := c.Param("name") + if err := svc.DeleteAgent(name); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + } +} + +func GetAgentConfigEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + name := c.Param("name") + cfg := svc.GetAgentConfig(name) + if cfg == nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"}) + } + return c.JSON(http.StatusOK, cfg) + } +} + +func PauseAgentEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + if err := svc.PauseAgent(c.Param("name")); err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + } +} + +func ResumeAgentEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + if err := svc.ResumeAgent(c.Param("name")); err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + } +} + +func GetAgentStatusEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + name := c.Param("name") + history := svc.GetAgentStatus(name) + if history == nil { + history = &state.Status{ActionResults: []coreTypes.ActionState{}} + } + entries := []string{} + for i := len(history.Results()) - 1; i >= 0; i-- { + h := history.Results()[i] + actionName := "" + if h.ActionCurrentState.Action != nil { + actionName = h.ActionCurrentState.Action.Definition().Name.String() + } + entries = append(entries, fmt.Sprintf("Reasoning: %s\nAction taken: %s\nParameters: %+v\nResult: %s", + h.Reasoning, + actionName, + h.ActionCurrentState.Params, + h.Result)) + } + return c.JSON(http.StatusOK, map[string]any{ + "Name": name, + "History": entries, + }) + } +} + +func GetAgentObservablesEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + name := c.Param("name") + history, err := svc.GetAgentObservables(name) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]any{ + "Name": name, + "History": history, + }) + } +} + +func ClearAgentObservablesEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + name := c.Param("name") + if err := svc.ClearAgentObservables(name); err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]any{"Name": name, "cleared": true}) + } +} + +func ChatWithAgentEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + name := c.Param("name") + var payload struct { + Message string `json:"message"` + } + if err := c.Bind(&payload); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request format"}) + } + message := strings.TrimSpace(payload.Message) + if message == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Message cannot be empty"}) + } + messageID, err := svc.Chat(name, message) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusAccepted, map[string]any{ + "status": "message_received", + "message_id": messageID, + }) + } +} + +func AgentSSEEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + name := c.Param("name") + manager := svc.GetSSEManager(name) + if manager == nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"}) + } + return services.HandleSSE(c, manager) + } +} + +func GetAgentConfigMetaEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + return c.JSON(http.StatusOK, svc.GetConfigMeta()) + } +} + +func ExportAgentEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + name := c.Param("name") + data, err := svc.ExportAgent(name) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.json", name)) + return c.JSONBlob(http.StatusOK, data) + } +} + +func ImportAgentEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + + // Try multipart form file first + file, err := c.FormFile("file") + if err == nil { + src, err := file.Open() + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to open file"}) + } + defer src.Close() + data, err := io.ReadAll(src) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to read file"}) + } + if err := svc.ImportAgent(data); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusCreated, map[string]string{"status": "ok"}) + } + + // Try JSON body + var cfg state.AgentConfig + if err := json.NewDecoder(c.Request().Body).Decode(&cfg); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request: provide a file or JSON body"}) + } + data, err := json.Marshal(&cfg) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + if err := svc.ImportAgent(data); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusCreated, map[string]string{"status": "ok"}) + } +} + +// --- Actions --- + +func ListActionsEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + return c.JSON(http.StatusOK, map[string]any{ + "actions": svc.ListAvailableActions(), + }) + } +} + +func GetActionDefinitionEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + actionName := c.Param("name") + + var payload struct { + Config map[string]string `json:"config"` + } + if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil { + payload.Config = map[string]string{} + } + + def, err := svc.GetActionDefinition(actionName, payload.Config) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, def) + } +} + +func ExecuteActionEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + svc := app.AgentPoolService() + actionName := c.Param("name") + + var payload struct { + Config map[string]string `json:"config"` + Params coreTypes.ActionParams `json:"params"` + } + if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + } + + result, err := svc.ExecuteAction(c.Request().Context(), actionName, payload.Config, payload.Params) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, result) + } +} diff --git a/core/http/endpoints/localai/mcp.go b/core/http/endpoints/localai/mcp.go index ff39e7dad..c7c44b67d 100644 --- a/core/http/endpoints/localai/mcp.go +++ b/core/http/endpoints/localai/mcp.go @@ -16,6 +16,7 @@ import ( "github.com/mudler/LocalAI/core/templates" "github.com/mudler/LocalAI/pkg/model" "github.com/mudler/cogito" + "github.com/mudler/cogito/clients" "github.com/mudler/xlog" ) @@ -121,7 +122,7 @@ func MCPEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator // and act like completion.go. // We can do this as cogito expects an interface and we can create one that // we satisfy to just call internally ComputeChoices - defaultLLM := cogito.NewOpenAILLM(config.Name, apiKey, "http://127.0.0.1:"+port) + defaultLLM := clients.NewLocalAILLM(config.Name, apiKey, "http://127.0.0.1:"+port) // Build cogito options using the consolidated method cogitoOpts := config.BuildCogitoOptions() diff --git a/core/http/endpoints/openresponses/responses.go b/core/http/endpoints/openresponses/responses.go index 540f29a51..9c0831d0a 100644 --- a/core/http/endpoints/openresponses/responses.go +++ b/core/http/endpoints/openresponses/responses.go @@ -22,6 +22,7 @@ import ( reason "github.com/mudler/LocalAI/pkg/reasoning" "github.com/mudler/LocalAI/pkg/utils" "github.com/mudler/cogito" + "github.com/mudler/cogito/clients" "github.com/mudler/xlog" ) @@ -1128,7 +1129,7 @@ func handleBackgroundMCPResponse(ctx context.Context, store *ResponseStore, resp } // Create OpenAI LLM client - defaultLLM := cogito.NewOpenAILLM(cfg.Name, apiKey, "http://127.0.0.1:"+port) + defaultLLM := clients.NewLocalAILLM(cfg.Name, apiKey, "http://127.0.0.1:"+port) // Build cogito options cogitoOpts := cfg.BuildCogitoOptions() @@ -2696,7 +2697,7 @@ func handleMCPResponse(c echo.Context, responseID string, createdAt int64, input defer cancel() // Create OpenAI LLM client - defaultLLM := cogito.NewOpenAILLM(cfg.Name, apiKey, "http://127.0.0.1:"+port) + defaultLLM := clients.NewLocalAILLM(cfg.Name, apiKey, "http://127.0.0.1:"+port) // Build cogito options cogitoOpts := cfg.BuildCogitoOptions() diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 963191ef9..de5d536f4 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -154,6 +154,7 @@ .sidebar-logo-img { width: 100%; + max-width: 140px; height: auto; padding: 0 var(--spacing-xs); } diff --git a/core/http/react-ui/src/components/SearchableModelSelect.jsx b/core/http/react-ui/src/components/SearchableModelSelect.jsx new file mode 100644 index 000000000..d5d945211 --- /dev/null +++ b/core/http/react-ui/src/components/SearchableModelSelect.jsx @@ -0,0 +1,155 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { useModels } from '../hooks/useModels' + +export default function SearchableModelSelect({ value, onChange, capability, placeholder = 'Type or select a model...', style }) { + const { models, loading } = useModels(capability) + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + const [focusIndex, setFocusIndex] = useState(-1) + const wrapperRef = useRef(null) + const listRef = useRef(null) + + // Sync external value into the input + useEffect(() => { + setQuery(value || '') + }, [value]) + + // Close on outside click + useEffect(() => { + const handler = (e) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target)) setOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, []) + + const filtered = models.filter(m => + m.id.toLowerCase().includes(query.toLowerCase()) + ) + + const commit = useCallback((val) => { + setQuery(val) + onChange(val) + setOpen(false) + setFocusIndex(-1) + }, [onChange]) + + const handleKeyDown = (e) => { + if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + setOpen(true) + return + } + if (!open) return + + if (e.key === 'ArrowDown') { + e.preventDefault() + setFocusIndex(i => Math.min(i + 1, filtered.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setFocusIndex(i => Math.max(i - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + if (focusIndex >= 0 && focusIndex < filtered.length) { + commit(filtered[focusIndex].id) + } else { + commit(query) + } + } else if (e.key === 'Escape') { + setOpen(false) + setFocusIndex(-1) + } + } + + // Scroll focused item into view + useEffect(() => { + if (focusIndex >= 0 && listRef.current) { + const item = listRef.current.children[focusIndex] + if (item) item.scrollIntoView({ block: 'nearest' }) + } + }, [focusIndex]) + + return ( +
+ + { + setQuery(e.target.value) + setOpen(true) + setFocusIndex(-1) + // Commit on every keystroke so the parent always has current value + onChange(e.target.value) + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + placeholder={loading ? 'Loading models...' : placeholder} + /> + {open && !loading && ( +
+ {filtered.length === 0 ? ( +
+ {query ? 'No matching models — value will be used as-is' : 'No models available'} +
+ ) : ( + filtered.map((m, i) => ( +
setFocusIndex(i)} + onMouseDown={(e) => { + e.preventDefault() + commit(m.id) + }} + > + {m.id} +
+ )) + )} +
+ )} +
+ ) +} diff --git a/core/http/react-ui/src/components/Sidebar.jsx b/core/http/react-ui/src/components/Sidebar.jsx index 0d1cf9ad4..b5ff6f282 100644 --- a/core/http/react-ui/src/components/Sidebar.jsx +++ b/core/http/react-ui/src/components/Sidebar.jsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from 'react' import { NavLink } from 'react-router-dom' import ThemeToggle from './ThemeToggle' @@ -12,13 +13,16 @@ const mainItems = [ { path: '/talk', icon: 'fas fa-phone', label: 'Talk' }, ] -const toolItems = [ - { path: '/agent-jobs', icon: 'fas fa-tasks', label: 'Agent Jobs' }, - { path: '/traces', icon: 'fas fa-chart-line', label: 'Traces' }, +const agentItems = [ + { path: '/agents', icon: 'fas fa-robot', label: 'Agents' }, + { path: '/skills', icon: 'fas fa-wand-magic-sparkles', label: 'Skills' }, + { path: '/collections', icon: 'fas fa-database', label: 'Memory' }, + { path: '/agent-jobs', icon: 'fas fa-tasks', label: 'MCP CI Jobs', feature: 'mcp' }, ] const systemItems = [ { path: '/backends', icon: 'fas fa-server', label: 'Backends' }, + { path: '/traces', icon: 'fas fa-chart-line', label: 'Traces' }, { path: '/p2p', icon: 'fas fa-circle-nodes', label: 'Swarm' }, { path: '/manage', icon: 'fas fa-desktop', label: 'System' }, { path: '/settings', icon: 'fas fa-cog', label: 'Settings' }, @@ -41,6 +45,11 @@ function NavItem({ item, onClose }) { } export default function Sidebar({ isOpen, onClose }) { + const [features, setFeatures] = useState({}) + useEffect(() => { + fetch('/api/features').then(r => r.json()).then(setFeatures).catch(() => {}) + }, []) + return ( <> {isOpen &&
} @@ -65,13 +74,15 @@ export default function Sidebar({ isOpen, onClose }) { ))}
- {/* Tools section */} -
-
Tools
- {toolItems.map(item => ( - - ))} -
+ {/* Agents section */} + {features.agents !== false && ( +
+
Agents
+ {agentItems.filter(item => !item.feature || features[item.feature] !== false).map(item => ( + + ))} +
+ )} {/* System section */}
diff --git a/core/http/react-ui/src/pages/AgentChat.jsx b/core/http/react-ui/src/pages/AgentChat.jsx new file mode 100644 index 000000000..3ed3669be --- /dev/null +++ b/core/http/react-ui/src/pages/AgentChat.jsx @@ -0,0 +1,279 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { useParams, useNavigate, useOutletContext } from 'react-router-dom' +import { agentsApi } from '../utils/api' + +export default function AgentChat() { + const { name } = useParams() + const navigate = useNavigate() + const { addToast } = useOutletContext() + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [processing, setProcessing] = useState(false) + const messagesEndRef = useRef(null) + const textareaRef = useRef(null) + const eventSourceRef = useRef(null) + const messageIdCounter = useRef(0) + + const nextId = useCallback(() => { + messageIdCounter.current += 1 + return messageIdCounter.current + }, []) + + // Connect to SSE endpoint + useEffect(() => { + const url = `/api/agents/${encodeURIComponent(name)}/sse` + const es = new EventSource(url) + eventSourceRef.current = es + + es.addEventListener('json_message', (e) => { + try { + const data = JSON.parse(e.data) + setMessages(prev => [...prev, { + id: nextId(), + sender: data.sender || (data.role === 'user' ? 'user' : 'agent'), + content: data.content || data.message || '', + timestamp: data.timestamp || Date.now(), + }]) + } catch (_err) { + // ignore malformed messages + } + }) + + es.addEventListener('json_message_status', (e) => { + try { + const data = JSON.parse(e.data) + if (data.status === 'processing') { + setProcessing(true) + } else if (data.status === 'completed') { + setProcessing(false) + } + } catch (_err) { + // ignore + } + }) + + es.addEventListener('status', (e) => { + const text = e.data + if (!text) return + setMessages(prev => [...prev, { + id: nextId(), + sender: 'system', + content: text, + timestamp: Date.now(), + }]) + }) + + es.addEventListener('json_error', (e) => { + try { + const data = JSON.parse(e.data) + addToast(data.error || data.message || 'Agent error', 'error') + } catch (_err) { + addToast('Agent error', 'error') + } + setProcessing(false) + }) + + es.onerror = () => { + addToast('SSE connection lost, attempting to reconnect...', 'warning') + } + + return () => { + es.close() + eventSourceRef.current = null + } + }, [name, addToast, nextId]) + + // Auto-scroll to bottom + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + const handleSend = useCallback(async () => { + const msg = input.trim() + if (!msg || processing) return + setInput('') + if (textareaRef.current) textareaRef.current.style.height = 'auto' + setProcessing(true) + try { + await agentsApi.chat(name, msg) + } catch (err) { + addToast(`Failed to send message: ${err.message}`, 'error') + setProcessing(false) + } + }, [input, processing, name, addToast]) + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + return ( +
+ + +
+

+ + {name} +

+
+ + +
+
+ +
+ {messages.length === 0 && !processing && ( +
+ Send a message to start chatting with {name}. +
+ )} + {messages.map(msg => ( +
+
+ {msg.sender === 'system' + ?
+ :
{msg.content}
+ } +
+ {new Date(msg.timestamp).toLocaleTimeString()} +
+
+
+ ))} + {processing && ( +
+
+
+ Thinking... +
+
+
+ )} +
+
+ +
+