feat: add agentic management (#8820)

* feat: add standalone and agentic functionalities

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* expose agents via responses api

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-03-07 00:03:08 +01:00
committed by GitHub
parent e1df6807dc
commit ac48867b7d
39 changed files with 7168 additions and 35 deletions

View File

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

View File

@@ -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!

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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:<port>)
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

View File

@@ -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"`
}

View File

@@ -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)

View File

@@ -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),
})
}
}

View File

@@ -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 ""
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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()

View File

@@ -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()

View File

@@ -154,6 +154,7 @@
.sidebar-logo-img {
width: 100%;
max-width: 140px;
height: auto;
padding: 0 var(--spacing-xs);
}

View File

@@ -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 (
<div ref={wrapperRef} className="searchable-model-select" style={style}>
<style>{`
.searchable-model-select {
position: relative;
width: 280px;
}
.searchable-model-select input {
width: 100%;
}
.sms-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 50;
max-height: 220px;
overflow-y: auto;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
margin-top: 2px;
}
.sms-item {
padding: 6px 10px;
font-size: 0.8125rem;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sms-item:hover, .sms-item.sms-focused {
background: var(--color-bg-tertiary);
}
.sms-item.sms-active {
color: var(--color-primary);
font-weight: 600;
}
.sms-empty {
padding: 8px 10px;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
`}</style>
<input
className="input"
value={query}
onChange={(e) => {
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 && (
<div className="sms-dropdown" ref={listRef}>
{filtered.length === 0 ? (
<div className="sms-empty">
{query ? 'No matching models — value will be used as-is' : 'No models available'}
</div>
) : (
filtered.map((m, i) => (
<div
key={m.id}
className={`sms-item${i === focusIndex ? ' sms-focused' : ''}${m.id === value ? ' sms-active' : ''}`}
onMouseEnter={() => setFocusIndex(i)}
onMouseDown={(e) => {
e.preventDefault()
commit(m.id)
}}
>
{m.id}
</div>
))
)}
</div>
)}
</div>
)
}

View File

@@ -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 && <div className="sidebar-overlay" onClick={onClose} />}
@@ -65,13 +74,15 @@ export default function Sidebar({ isOpen, onClose }) {
))}
</div>
{/* Tools section */}
<div className="sidebar-section">
<div className="sidebar-section-title">Tools</div>
{toolItems.map(item => (
<NavItem key={item.path} item={item} onClose={onClose} />
))}
</div>
{/* Agents section */}
{features.agents !== false && (
<div className="sidebar-section">
<div className="sidebar-section-title">Agents</div>
{agentItems.filter(item => !item.feature || features[item.feature] !== false).map(item => (
<NavItem key={item.path} item={item} onClose={onClose} />
))}
</div>
)}
{/* System section */}
<div className="sidebar-section">

View File

@@ -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 (
<div className="page agent-chat-page">
<style>{`
.agent-chat-page {
display: flex;
flex-direction: column;
height: calc(100vh - 80px);
padding-bottom: 0 !important;
}
.agent-chat-messages {
flex: 1;
overflow-y: auto;
padding: var(--spacing-md);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.agent-chat-message {
display: flex;
max-width: 75%;
word-wrap: break-word;
}
.agent-chat-message-user {
align-self: flex-end;
}
.agent-chat-message-agent {
align-self: flex-start;
}
.agent-chat-bubble {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-wrap;
}
.agent-chat-message-user .agent-chat-bubble {
background: var(--color-bg-tertiary, #e5e7eb);
color: var(--color-text-primary);
border-bottom-right-radius: var(--radius-xs, 4px);
}
.agent-chat-message-agent .agent-chat-bubble {
background: var(--color-primary, #3b82f6);
color: #fff;
border-bottom-left-radius: var(--radius-xs, 4px);
}
.agent-chat-message-system {
align-self: center;
max-width: 90%;
}
.agent-chat-message-system .agent-chat-bubble {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-size: 0.8rem;
font-style: italic;
padding: var(--spacing-xs) var(--spacing-sm);
}
.agent-chat-timestamp {
font-size: 0.6875rem;
color: var(--color-text-muted);
margin-top: 2px;
padding: 0 var(--spacing-xs);
}
.agent-chat-message-user .agent-chat-timestamp {
text-align: right;
}
.agent-chat-input-area {
display: flex;
gap: var(--spacing-sm);
padding: var(--spacing-md);
border-top: 1px solid var(--color-border);
background: var(--color-bg-secondary);
align-items: flex-end;
}
.agent-chat-input-area textarea {
flex: 1;
min-height: 38px;
max-height: 150px;
resize: none;
overflow-y: auto;
line-height: 1.5;
font-family: inherit;
font-size: inherit;
}
.agent-chat-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
font-size: 0.9rem;
}
`}</style>
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 className="page-title">
<i className="fas fa-robot" style={{ marginRight: 'var(--spacing-xs)' }} />
{name}
</h1>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/agents/${encodeURIComponent(name)}/status`)} title="View status & observables">
<i className="fas fa-chart-bar" /> Status
</button>
<button className="btn btn-secondary btn-sm" onClick={() => setMessages([])} disabled={messages.length === 0} title="Clear chat history">
<i className="fas fa-eraser" /> Clear
</button>
</div>
</div>
<div className="agent-chat-messages">
{messages.length === 0 && !processing && (
<div className="agent-chat-empty">
Send a message to start chatting with {name}.
</div>
)}
{messages.map(msg => (
<div key={msg.id} className={`agent-chat-message agent-chat-message-${msg.sender}`}>
<div>
{msg.sender === 'system'
? <div className="agent-chat-bubble" dangerouslySetInnerHTML={{ __html: msg.content }} />
: <div className="agent-chat-bubble">{msg.content}</div>
}
<div className="agent-chat-timestamp">
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
</div>
))}
{processing && (
<div className="agent-chat-message agent-chat-message-agent">
<div>
<div className="agent-chat-bubble">
<i className="fas fa-circle-notch fa-spin" /> Thinking...
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="agent-chat-input-area">
<textarea
ref={textareaRef}
className="input"
value={input}
onChange={(e) => {
setInput(e.target.value)
// Auto-resize
const ta = e.target
ta.style.height = 'auto'
ta.style.height = Math.min(ta.scrollHeight, 150) + 'px'
}}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Shift+Enter for new line)"
disabled={processing}
rows={1}
/>
<button
className="btn btn-primary"
onClick={handleSend}
disabled={processing || !input.trim()}
>
<i className="fas fa-paper-plane" /> Send
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,849 @@
import { useState, useEffect, useMemo } from 'react'
import { useParams, useNavigate, useLocation, useOutletContext } from 'react-router-dom'
import { agentsApi } from '../utils/api'
import SearchableModelSelect from '../components/SearchableModelSelect'
// --- MCP STDIO helpers ---
function parseStdioServers(value) {
if (!value) return []
if (Array.isArray(value)) {
return value.map(s => ({
name: s.name || '',
command: s.cmd || s.command || '',
args: Array.isArray(s.args) ? [...s.args] : [],
env: Array.isArray(s.env) ? [...s.env]
: (s.env && typeof s.env === 'object') ? Object.entries(s.env).map(([k, v]) => `${k}=${v}`) : [],
}))
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
if (parsed.mcpServers) {
return Object.entries(parsed.mcpServers).map(([name, srv]) => ({
name,
command: srv.command || '',
args: srv.args || [],
env: Object.entries(srv.env || {}).map(([k, v]) => `${k}=${v}`),
}))
}
if (Array.isArray(parsed)) return parseStdioServers(parsed)
} catch { /* not valid JSON */ }
}
return []
}
function buildStdioJson(list) {
const mcpServers = {}
const usedKeys = new Set()
list.forEach((item, index) => {
let key = item.name?.trim() || `server${index}`
while (usedKeys.has(key)) key = `${key}_${index}`
usedKeys.add(key)
const envMap = {}
for (const e of (item.env || [])) {
const eqIdx = e.indexOf('=')
if (eqIdx > 0) envMap[e.slice(0, eqIdx)] = e.slice(eqIdx + 1)
}
mcpServers[key] = { command: item.command || '', args: item.args || [], env: envMap }
})
return JSON.stringify({ mcpServers }, null, 2)
}
// --- Shared UI components (same style as Settings page) ---
function Toggle({ checked, onChange, disabled }) {
return (
<label style={{
position: 'relative', display: 'inline-block', width: 40, height: 22, cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
}}>
<input
type="checkbox"
checked={checked || false}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
style={{ display: 'none' }}
/>
<span style={{
position: 'absolute', inset: 0, borderRadius: 22,
background: checked ? 'var(--color-primary)' : 'var(--color-toggle-off)',
transition: 'background 200ms',
}}>
<span style={{
position: 'absolute', top: 2, left: checked ? 20 : 2,
width: 18, height: 18, borderRadius: '50%',
background: '#fff', transition: 'left 200ms',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.2)',
}} />
</span>
</label>
)
}
function SettingRow({ label, description, children }) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: 'var(--spacing-sm) 0',
borderBottom: '1px solid var(--color-border-subtle)',
}}>
<div style={{ flex: 1, marginRight: 'var(--spacing-md)' }}>
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}>{label}</div>
{description && <div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>}
</div>
<div style={{ flexShrink: 0 }}>{children}</div>
</div>
)
}
// --- Form field components ---
function FormField({ field, value, onChange, disabled }) {
const id = `field-${field.name}`
const label = field.required
? <>{field.label} <span style={{ color: 'var(--color-error)' }}>*</span></>
: field.label
switch (field.type) {
case 'checkbox':
return (
<SettingRow label={label} description={field.helpText}>
<Toggle
checked={value === true || value === 'true'}
onChange={(v) => onChange(field.name, v)}
disabled={disabled}
/>
</SettingRow>
)
case 'select':
return (
<SettingRow label={label} description={field.helpText}>
<select id={id} className="input" style={{ width: 200 }} value={value ?? ''} onChange={(e) => onChange(field.name, e.target.value)} disabled={disabled}>
<option value=""> Select </option>
{(field.options || []).map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</SettingRow>
)
case 'textarea':
return (
<div style={{ padding: 'var(--spacing-sm) 0', borderBottom: '1px solid var(--color-border-subtle)' }}>
<div style={{ fontSize: '0.875rem', fontWeight: 500, marginBottom: 4 }}>{label}</div>
{field.helpText && <div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginBottom: 'var(--spacing-xs)' }}>{field.helpText}</div>}
<textarea
id={id}
className="textarea"
value={value ?? ''}
onChange={(e) => onChange(field.name, e.target.value)}
placeholder={field.placeholder || ''}
rows={5}
disabled={disabled}
style={field.name.includes('prompt') || field.name.includes('template') || field.name.includes('script')
? { fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8125rem' } : undefined}
/>
</div>
)
case 'number':
return (
<SettingRow label={label} description={field.helpText}>
<input
id={id} className="input" type="number" style={{ width: 120 }}
value={value ?? ''} onChange={(e) => onChange(field.name, e.target.value)}
placeholder={field.placeholder || ''} min={field.min} max={field.max} step={field.step}
disabled={disabled}
/>
</SettingRow>
)
default: {
const isModelField = /^(model|multimodal_model|transcription_model|tts_model|embedding_model)$/.test(field.name)
if (isModelField && !disabled && !field.disabled) {
const capabilityMap = {
model: 'FLAG_CHAT',
multimodal_model: 'FLAG_CHAT',
transcription_model: 'FLAG_TRANSCRIPT',
tts_model: 'FLAG_TTS',
embedding_model: undefined,
}
return (
<SettingRow label={label} description={field.helpText}>
<SearchableModelSelect
value={value ?? ''}
onChange={(v) => onChange(field.name, v)}
capability={capabilityMap[field.name]}
placeholder={field.placeholder || 'Type or select a model...'}
style={{ width: 250 }}
/>
</SettingRow>
)
}
return (
<SettingRow label={label} description={field.helpText}>
<input
id={id} className="input" type={field.type === 'password' ? 'password' : 'text'}
style={{ width: field.type === 'password' ? 200 : 250 }}
value={value ?? ''} onChange={(e) => onChange(field.name, e.target.value)}
placeholder={field.placeholder || ''} required={field.required}
disabled={disabled || field.disabled}
/>
</SettingRow>
)
}
}
}
// --- ConfigForm for connectors/actions/filters/dynamic_prompts ---
function ConfigForm({ items, fieldGroups, onChange, onRemove, onAdd, itemType, typeField, addButtonText }) {
const typeOptions = [
{ value: '', label: `Select a ${itemType} type` },
...(fieldGroups || []).map(g => ({ value: g.name, label: g.label })),
]
const parseConfig = (item) => {
if (!item?.config) return {}
try { return typeof item.config === 'string' ? JSON.parse(item.config || '{}') : item.config }
catch { return {} }
}
const handleConfigFieldChange = (index, fieldName, fieldValue, fieldType) => {
const config = parseConfig(items[index])
config[fieldName] = fieldType === 'checkbox' ? (fieldValue ? 'true' : 'false') : String(fieldValue)
onChange(index, { ...items[index], config: JSON.stringify(config) })
}
const label = itemType.charAt(0).toUpperCase() + itemType.slice(1).replace('_', ' ')
if (!fieldGroups?.length) {
return <p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>No {itemType} types available.</p>
}
return (
<div>
{items.map((item, index) => {
const typeName = (item || {})[typeField] || ''
const fieldGroup = fieldGroups.find(g => g.name === typeName)
const config = parseConfig(item)
return (
<div key={index} className="card" style={{ marginBottom: 'var(--spacing-md)', padding: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-md)' }}>
<h4 style={{ margin: 0, fontWeight: 600 }}>{label} #{index + 1}</h4>
<button type="button" className="btn btn-danger btn-sm" onClick={() => onRemove(index)}>
<i className="fas fa-times" />
</button>
</div>
<div className="form-group">
<label className="form-label">{label} Type</label>
<select className="input" value={typeName} onChange={(e) => onChange(index, { ...items[index], [typeField]: e.target.value, config: '{}' })}>
{typeOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
{fieldGroup?.fields?.map(f => {
const val = config[f.name] ?? ''
const fieldLabel = <>{f.label}{f.required && <span style={{ color: 'var(--color-error)' }}> *</span>}</>
if (f.type === 'checkbox') {
return (
<SettingRow key={f.name} label={fieldLabel} description={f.helpText}>
<Toggle checked={val === 'true' || val === true} onChange={(v) => handleConfigFieldChange(index, f.name, v, 'checkbox')} />
</SettingRow>
)
}
if (f.type === 'textarea') {
return (
<div key={f.name} style={{ padding: 'var(--spacing-sm) 0', borderBottom: '1px solid var(--color-border-subtle)' }}>
<div style={{ fontSize: '0.875rem', fontWeight: 500, marginBottom: 4 }}>{fieldLabel}</div>
{f.helpText && <div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginBottom: 'var(--spacing-xs)' }}>{f.helpText}</div>}
<textarea className="textarea" value={val} onChange={(e) => handleConfigFieldChange(index, f.name, e.target.value, 'text')} rows={3} placeholder={f.placeholder} />
</div>
)
}
if (f.type === 'select') {
return (
<SettingRow key={f.name} label={fieldLabel} description={f.helpText}>
<select className="input" style={{ width: 200 }} value={val} onChange={(e) => handleConfigFieldChange(index, f.name, e.target.value, 'text')}>
<option value=""> Select </option>
{(f.options || []).map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</SettingRow>
)
}
return (
<SettingRow key={f.name} label={fieldLabel} description={f.helpText}>
<input
className="input" type={f.type === 'number' ? 'number' : f.type === 'password' ? 'password' : 'text'}
style={{ width: f.type === 'number' ? 120 : 200 }}
value={val} onChange={(e) => handleConfigFieldChange(index, f.name, e.target.value, f.type)}
placeholder={f.placeholder} min={f.min} max={f.max} step={f.step}
/>
</SettingRow>
)
})}
</div>
)
})}
<button type="button" className="btn btn-secondary" onClick={onAdd}>
<i className="fas fa-plus" /> {addButtonText}
</button>
</div>
)
}
// --- Section definitions ---
const SECTIONS = [
{ id: 'BasicInfo', icon: 'fa-info-circle', label: 'Basic Info' },
{ id: 'ModelSettings', icon: 'fa-brain', label: 'Model Settings' },
{ id: 'MemorySettings', icon: 'fa-database', label: 'Memory' },
{ id: 'PromptsGoals', icon: 'fa-bullseye', label: 'Prompts & Goals' },
{ id: 'AdvancedSettings', icon: 'fa-cog', label: 'Advanced' },
{ id: 'MCP', icon: 'fa-server', label: 'MCP Servers' },
{ id: 'connectors', icon: 'fa-plug', label: 'Connectors' },
{ id: 'actions', icon: 'fa-bolt', label: 'Actions' },
{ id: 'filters', icon: 'fa-filter', label: 'Filters' },
{ id: 'dynamic_prompts', icon: 'fa-wand-magic-sparkles', label: 'Dynamic Prompts' },
]
// Fields handled by custom editors in the MCP section
const CUSTOM_FIELDS = new Set(['mcp_stdio_servers'])
// --- Main component ---
export default function AgentCreate() {
const { name } = useParams()
const navigate = useNavigate()
const location = useLocation()
const { addToast } = useOutletContext()
const isEdit = !!name
const importedConfig = location.state?.importedConfig || null
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [activeSection, setActiveSection] = useState('BasicInfo')
const [meta, setMeta] = useState(null)
const [form, setForm] = useState({})
const [connectors, setConnectors] = useState([])
const [actions, setActions] = useState([])
const [filters, setFilters] = useState([])
const [dynamicPrompts, setDynamicPrompts] = useState([])
const [mcpHttpServers, setMcpHttpServers] = useState([])
const [stdioServers, setStdioServers] = useState([])
// Group metadata Fields by tags.section
const fieldsBySection = useMemo(() => {
if (!meta?.Fields) return {}
const groups = {}
for (const field of meta.Fields) {
if (CUSTOM_FIELDS.has(field.name)) continue
const section = field.tags?.section || 'BasicInfo'
if (!groups[section]) groups[section] = []
groups[section].push(field)
}
return groups
}, [meta])
const visibleSections = useMemo(() => {
const items = [...SECTIONS]
if (isEdit) items.push({ id: 'export', icon: 'fa-download', label: 'Export' })
return items
}, [isEdit])
useEffect(() => {
const init = async () => {
try {
const [metaData, config] = await Promise.all([
agentsApi.configMeta().catch(() => null),
isEdit ? agentsApi.getConfig(name).catch(() => null) : Promise.resolve(null),
])
if (metaData) setMeta(metaData)
// Build defaults from metadata
const initialForm = {}
if (metaData?.Fields) {
for (const field of metaData.Fields) {
if (CUSTOM_FIELDS.has(field.name)) continue
if (field.type === 'checkbox') {
initialForm[field.name] = field.defaultValue != null ? !!field.defaultValue : false
} else {
initialForm[field.name] = field.defaultValue != null ? field.defaultValue : ''
}
}
}
// Override with existing config when editing or importing
const sourceConfig = config || importedConfig
if (sourceConfig) {
for (const key of Object.keys(initialForm)) {
if (sourceConfig[key] !== undefined && sourceConfig[key] !== null) {
initialForm[key] = sourceConfig[key]
}
}
if (!initialForm.name && name) initialForm.name = name
setConnectors(Array.isArray(sourceConfig.connectors) ? sourceConfig.connectors : [])
setActions(Array.isArray(sourceConfig.actions) ? sourceConfig.actions : [])
setFilters(Array.isArray(sourceConfig.filters) ? sourceConfig.filters : [])
setDynamicPrompts(Array.isArray(sourceConfig.dynamic_prompts) ? sourceConfig.dynamic_prompts : [])
setMcpHttpServers(Array.isArray(sourceConfig.mcp_servers) ? sourceConfig.mcp_servers : [])
setStdioServers(parseStdioServers(sourceConfig.mcp_stdio_servers))
}
setForm(initialForm)
} catch (err) {
addToast(`Failed to load configuration: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}
init()
}, [name, isEdit, importedConfig, addToast])
const updateField = (fieldName, value) => {
setForm(prev => ({ ...prev, [fieldName]: value }))
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!form.name?.toString().trim()) {
addToast('Agent name is required', 'warning')
return
}
setSaving(true)
try {
const payload = { ...form }
// Convert number fields
if (meta?.Fields) {
for (const field of meta.Fields) {
if (field.type === 'number' && payload[field.name] !== '' && payload[field.name] != null) {
payload[field.name] = Number(payload[field.name])
}
}
}
payload.connectors = connectors
payload.actions = actions
payload.filters = filters
payload.dynamic_prompts = dynamicPrompts
payload.mcp_servers = mcpHttpServers.filter(s => s.url)
// Send STDIO servers as JSON string in expected format
if (stdioServers.length > 0) {
payload.mcp_stdio_servers = buildStdioJson(stdioServers)
}
if (isEdit) {
await agentsApi.update(name, payload)
addToast(`Agent "${form.name}" updated`, 'success')
} else {
await agentsApi.create(payload)
addToast(`Agent "${form.name}" created`, 'success')
}
navigate('/agents')
} catch (err) {
addToast(`Save failed: ${err.message}`, 'error')
} finally {
setSaving(false)
}
}
// --- STDIO server handlers ---
const addStdioServer = () => setStdioServers(prev => [...prev, { name: '', command: '', args: [], env: [] }])
const removeStdioServer = (idx) => setStdioServers(prev => prev.filter((_, i) => i !== idx))
const updateStdio = (idx, key, val) => setStdioServers(prev => { const n = [...prev]; n[idx] = { ...n[idx], [key]: val }; return n })
const addArg = (si) => setStdioServers(prev => { const n = [...prev]; n[si] = { ...n[si], args: [...(n[si].args || []), ''] }; return n })
const updateArg = (si, ai, val) => setStdioServers(prev => { const n = [...prev]; const a = [...(n[si].args || [])]; a[ai] = val; n[si] = { ...n[si], args: a }; return n })
const removeArg = (si, ai) => setStdioServers(prev => { const n = [...prev]; n[si] = { ...n[si], args: n[si].args.filter((_, i) => i !== ai) }; return n })
const addEnv = (si) => setStdioServers(prev => { const n = [...prev]; n[si] = { ...n[si], env: [...(n[si].env || []), ''] }; return n })
const updateEnv = (si, ei, val) => setStdioServers(prev => { const n = [...prev]; const e = [...(n[si].env || [])]; e[ei] = val; n[si] = { ...n[si], env: e }; return n })
const removeEnv = (si, ei) => setStdioServers(prev => { const n = [...prev]; n[si] = { ...n[si], env: n[si].env.filter((_, i) => i !== ei) }; return n })
// --- HTTP MCP server handlers ---
const addMcpHttp = () => setMcpHttpServers(prev => [...prev, { url: '', token: '' }])
const removeMcpHttp = (idx) => setMcpHttpServers(prev => prev.filter((_, i) => i !== idx))
const updateMcpHttp = (idx, key, val) => setMcpHttpServers(prev => { const n = [...prev]; n[idx] = { ...n[idx], [key]: val }; return n })
// --- Render helpers ---
const renderFieldSection = (sectionId) => {
const fields = fieldsBySection[sectionId] || []
if (!fields.length) {
return <p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>No fields available for this section.</p>
}
return fields.map(field => (
<FormField
key={field.name}
field={field.name === 'name' && isEdit ? { ...field, disabled: true, helpText: 'Agent name cannot be changed after creation' } : field}
value={form[field.name]}
onChange={updateField}
disabled={field.name === 'name' && isEdit}
/>
))
}
const renderSection = () => {
switch (activeSection) {
case 'BasicInfo':
case 'ModelSettings':
case 'MemorySettings':
case 'PromptsGoals':
case 'AdvancedSettings':
return renderFieldSection(activeSection)
case 'MCP':
return (
<>
{/* Other MCP metadata fields (mcp_prepare_script, etc.) */}
{renderFieldSection('MCP')}
{/* STDIO Servers */}
<div style={{ marginTop: 'var(--spacing-lg)' }}>
<h4 className="agent-subsection-title">
<i className="fas fa-terminal" style={{ color: 'var(--color-primary)', marginRight: 'var(--spacing-xs)' }} />
STDIO Servers
</h4>
<p className="agent-section-desc">Local command-based MCP servers (e.g. docker run).</p>
{stdioServers.map((server, idx) => (
<div key={idx} className="card" style={{ marginBottom: 'var(--spacing-md)', padding: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-sm)' }}>
<span style={{ fontWeight: 600, fontSize: '0.85rem' }}>Server #{idx + 1}</span>
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeStdioServer(idx)}>
<i className="fas fa-times" />
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-sm)' }}>
<div className="form-group">
<label className="form-label">Name</label>
<input className="input" value={server.name || ''} onChange={(e) => updateStdio(idx, 'name', e.target.value)} placeholder="server-name" />
</div>
<div className="form-group">
<label className="form-label">Command</label>
<input className="input" value={server.command || ''} onChange={(e) => updateStdio(idx, 'command', e.target.value)} placeholder="/usr/bin/node" />
</div>
</div>
<div className="form-group">
<label className="form-label" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
Arguments
<button type="button" className="btn btn-secondary btn-sm" onClick={() => addArg(idx)} style={{ fontSize: '0.7rem', padding: '2px 8px' }}>
<i className="fas fa-plus" /> Add
</button>
</label>
{(server.args || []).length === 0 && <p style={{ fontSize: '0.8rem', color: 'var(--color-text-muted)' }}>No arguments.</p>}
{(server.args || []).map((arg, ai) => (
<div key={ai} style={{ display: 'flex', gap: 'var(--spacing-xs)', marginBottom: 'var(--spacing-xs)' }}>
<input className="input" value={arg} onChange={(e) => updateArg(idx, ai, e.target.value)} placeholder="argument" style={{ flex: 1 }} />
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeArg(idx, ai)}><i className="fas fa-times" /></button>
</div>
))}
</div>
<div className="form-group">
<label className="form-label" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
Environment Variables
<button type="button" className="btn btn-secondary btn-sm" onClick={() => addEnv(idx)} style={{ fontSize: '0.7rem', padding: '2px 8px' }}>
<i className="fas fa-plus" /> Add
</button>
</label>
{(server.env || []).length === 0 && <p style={{ fontSize: '0.8rem', color: 'var(--color-text-muted)' }}>No environment variables.</p>}
{(server.env || []).map((env, ei) => (
<div key={ei} style={{ display: 'flex', gap: 'var(--spacing-xs)', marginBottom: 'var(--spacing-xs)' }}>
<input className="input" value={env} onChange={(e) => updateEnv(idx, ei, e.target.value)} placeholder="KEY=VALUE" style={{ flex: 1 }} />
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeEnv(idx, ei)}><i className="fas fa-times" /></button>
</div>
))}
</div>
</div>
))}
<button type="button" className="btn btn-secondary" onClick={addStdioServer}>
<i className="fas fa-plus" /> Add STDIO Server
</button>
</div>
{/* HTTP Servers */}
<div style={{ marginTop: 'var(--spacing-lg)' }}>
<h4 className="agent-subsection-title">
<i className="fas fa-globe" style={{ color: 'var(--color-primary)', marginRight: 'var(--spacing-xs)' }} />
HTTP Servers
</h4>
<p className="agent-section-desc">MCP servers connected over HTTP.</p>
{mcpHttpServers.map((server, idx) => (
<div key={idx} className="card" style={{ marginBottom: 'var(--spacing-md)', padding: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-sm)' }}>
<span style={{ fontWeight: 600, fontSize: '0.85rem' }}>HTTP Server #{idx + 1}</span>
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeMcpHttp(idx)}>
<i className="fas fa-times" />
</button>
</div>
{(meta?.MCPServers || [{ name: 'url', label: 'URL', type: 'text' }, { name: 'token', label: 'API Key', type: 'password' }]).map(f => (
<div key={f.name} className="form-group">
<label className="form-label">{f.label}{f.required && <span style={{ color: 'var(--color-error)' }}> *</span>}</label>
<input
className="input" type={f.type === 'password' ? 'password' : 'text'}
value={server[f.name] || ''} onChange={(e) => updateMcpHttp(idx, f.name, e.target.value)}
placeholder={f.placeholder}
/>
</div>
))}
</div>
))}
<button type="button" className="btn btn-secondary" onClick={addMcpHttp}>
<i className="fas fa-plus" /> Add HTTP Server
</button>
</div>
</>
)
case 'connectors':
return (
<>
<p className="agent-section-desc">Configure connectors that this agent uses to communicate with external services.</p>
<ConfigForm
items={connectors}
fieldGroups={meta?.Connectors}
onChange={(idx, item) => { const n = [...connectors]; n[idx] = item; setConnectors(n) }}
onRemove={(idx) => setConnectors(connectors.filter((_, i) => i !== idx))}
onAdd={() => setConnectors([...connectors, { type: '', config: '{}' }])}
typeField="type" itemType="connector" addButtonText="Add Connector"
/>
</>
)
case 'actions':
return (
<>
<p className="agent-section-desc">Configure actions the agent can perform.</p>
<ConfigForm
items={actions}
fieldGroups={meta?.Actions}
onChange={(idx, item) => { const n = [...actions]; n[idx] = item; setActions(n) }}
onRemove={(idx) => setActions(actions.filter((_, i) => i !== idx))}
onAdd={() => setActions([...actions, { name: '', config: '{}' }])}
typeField="name" itemType="action" addButtonText="Add Action"
/>
</>
)
case 'filters':
return (
<>
<p className="agent-section-desc">Filters and triggers that control which messages the agent processes.</p>
<ConfigForm
items={filters}
fieldGroups={meta?.Filters}
onChange={(idx, item) => { const n = [...filters]; n[idx] = item; setFilters(n) }}
onRemove={(idx) => setFilters(filters.filter((_, i) => i !== idx))}
onAdd={() => setFilters([...filters, { type: '', config: '{}' }])}
typeField="type" itemType="filter" addButtonText="Add Filter"
/>
</>
)
case 'dynamic_prompts':
return (
<>
<p className="agent-section-desc">Dynamic prompts that augment agent context at runtime.</p>
<ConfigForm
items={dynamicPrompts}
fieldGroups={meta?.DynamicPrompts}
onChange={(idx, item) => { const n = [...dynamicPrompts]; n[idx] = item; setDynamicPrompts(n) }}
onRemove={(idx) => setDynamicPrompts(dynamicPrompts.filter((_, i) => i !== idx))}
onAdd={() => setDynamicPrompts([...dynamicPrompts, { type: '', config: '{}' }])}
typeField="type" itemType="dynamic prompt" addButtonText="Add Dynamic Prompt"
/>
</>
)
case 'export':
return (
<div>
<p className="agent-section-desc">Download the full agent configuration as a JSON file.</p>
<a
href={`/api/agents/${encodeURIComponent(name)}/export`}
className="btn btn-primary"
style={{ display: 'inline-flex', alignItems: 'center', textDecoration: 'none' }}
>
<i className="fas fa-download" style={{ marginRight: 'var(--spacing-xs)' }} /> Export Agent
</a>
</div>
)
default:
return null
}
}
if (loading) {
return (
<div className="page" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<i className="fas fa-spinner fa-spin" style={{ fontSize: '2rem', color: 'var(--color-primary)' }} />
</div>
)
}
return (
<div className="page">
<style>{`
.agent-form-container {
display: flex;
gap: var(--spacing-lg);
min-height: 500px;
}
.agent-wizard-sidebar {
width: 220px;
flex-shrink: 0;
}
.agent-wizard-nav {
list-style: none;
padding: 0;
margin: 0;
position: sticky;
top: var(--spacing-md);
}
.agent-wizard-nav-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-secondary);
transition: background 0.15s, color 0.15s;
user-select: none;
margin-bottom: 2px;
border-left: 3px solid transparent;
}
.agent-wizard-nav-item:hover {
background: var(--color-primary-light);
color: var(--color-text-primary);
}
.agent-wizard-nav-item.active {
background: var(--color-primary-light);
color: var(--color-primary);
border-left-color: var(--color-primary);
font-weight: 500;
}
.agent-wizard-nav-item i {
width: 18px;
text-align: center;
font-size: 0.8125rem;
}
.agent-wizard-badge {
margin-left: auto;
font-size: 0.7rem;
background: var(--color-primary);
color: white;
border-radius: 999px;
padding: 1px 6px;
min-width: 18px;
text-align: center;
}
.agent-form-content {
flex: 1;
min-width: 0;
}
.agent-section-title {
font-weight: 600;
font-size: 1.1rem;
margin-bottom: var(--spacing-md);
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.agent-subsection-title {
font-weight: 600;
font-size: 0.95rem;
margin-bottom: var(--spacing-sm);
}
.agent-section-desc {
font-size: 0.8125rem;
color: var(--color-text-muted);
margin-bottom: var(--spacing-md);
}
.agent-form-help-text {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: var(--spacing-xs);
margin-bottom: 0;
}
@media (max-width: 768px) {
.agent-form-container {
flex-direction: column;
}
.agent-wizard-sidebar {
width: 100%;
}
.agent-wizard-nav {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
position: static;
}
.agent-wizard-nav-item {
font-size: 0.8125rem;
padding: var(--spacing-xs) var(--spacing-sm);
border-left: none;
border-bottom: 3px solid transparent;
}
.agent-wizard-nav-item.active {
border-left-color: transparent;
border-bottom-color: var(--color-primary);
}
}
`}</style>
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 className="page-title">{isEdit ? `Edit Agent: ${name}` : importedConfig ? 'Import Agent' : 'Create Agent'}</h1>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/agents')}>
<i className="fas fa-arrow-left" /> Back
</button>
</div>
<form onSubmit={handleSubmit} noValidate>
<div className="agent-form-container">
<div className="agent-wizard-sidebar">
<div className="card" style={{ padding: 'var(--spacing-sm)' }}>
<ul className="agent-wizard-nav">
{visibleSections.map(s => {
let count = 0
if (s.id === 'connectors') count = connectors.length
else if (s.id === 'actions') count = actions.length
else if (s.id === 'filters') count = filters.length
else if (s.id === 'dynamic_prompts') count = dynamicPrompts.length
return (
<li
key={s.id}
className={`agent-wizard-nav-item ${activeSection === s.id ? 'active' : ''}`}
onClick={() => setActiveSection(s.id)}
>
<i className={`fas ${s.icon}`} />
{s.label}
{count > 0 && <span className="agent-wizard-badge">{count}</span>}
</li>
)
})}
</ul>
</div>
</div>
<div className="agent-form-content">
<div className="card" style={{ padding: 'var(--spacing-lg)' }}>
<h3 className="agent-section-title">
<i className={`fas ${visibleSections.find(s => s.id === activeSection)?.icon || 'fa-cog'}`} style={{ color: 'var(--color-primary)' }} />
{visibleSections.find(s => s.id === activeSection)?.label || activeSection}
</h3>
{renderSection()}
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'flex-end', marginTop: 'var(--spacing-md)' }}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/agents')}>
<i className="fas fa-times" /> Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving
? <><i className="fas fa-spinner fa-spin" /> Saving...</>
: <><i className="fas fa-save" /> {isEdit ? 'Save Changes' : importedConfig ? 'Import Agent' : 'Create Agent'}</>
}
</button>
</div>
</div>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,418 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate, useOutletContext } from 'react-router-dom'
import { agentsApi } from '../utils/api'
function ObservableSummary({ observable }) {
const creation = observable?.creation || {}
const completion = observable?.completion || {}
let creationMsg = ''
if (creation?.chat_completion_message?.content) {
creationMsg = creation.chat_completion_message.content
} else {
const messages = creation?.chat_completion_request?.messages
if (Array.isArray(messages) && messages.length > 0) {
creationMsg = messages[messages.length - 1]?.content || ''
}
}
if (typeof creationMsg === 'object') creationMsg = 'Multimedia message'
let funcDef = creation?.function_definition?.name ? `Function: ${creation.function_definition.name}` : ''
let funcParams = creation?.function_params && Object.keys(creation.function_params).length > 0
? `Params: ${JSON.stringify(creation.function_params)}` : ''
let completionMsg = ''
let toolCallSummary = ''
let chatCompletion = completion?.chat_completion_response
if (!chatCompletion && Array.isArray(completion?.conversation) && completion.conversation.length > 0) {
chatCompletion = { choices: completion.conversation.map(m => ({ message: m })) }
}
if (chatCompletion?.choices?.length > 0) {
const last = chatCompletion.choices[chatCompletion.choices.length - 1]
const toolCalls = last?.message?.tool_calls
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
toolCallSummary = toolCalls.map(tc => {
const args = tc.function?.arguments || ''
return `${tc.function?.name || 'unknown'}(${typeof args === 'string' ? args : JSON.stringify(args)})`
}).join(', ')
}
completionMsg = last?.message?.content || ''
}
let actionResult = completion?.action_result ? String(completion.action_result).slice(0, 100) : ''
let errorMsg = completion?.error || ''
let filterInfo = ''
if (completion?.filter_result) {
const fr = completion.filter_result
if (fr.has_triggers && !fr.triggered_by) filterInfo = 'Failed to match triggers'
else if (fr.triggered_by) filterInfo = `Triggered by ${fr.triggered_by}`
if (fr.failed_by) filterInfo += `${filterInfo ? ', ' : ''}Failed by ${fr.failed_by}`
}
const items = []
if (creationMsg) items.push({ icon: 'fa-comment-dots', text: creationMsg, cls: 'creation' })
if (funcDef) items.push({ icon: 'fa-code', text: funcDef, cls: 'creation' })
if (funcParams) items.push({ icon: 'fa-sliders-h', text: funcParams, cls: 'creation' })
if (toolCallSummary) items.push({ icon: 'fa-wrench', text: toolCallSummary, cls: 'tool-call' })
if (completionMsg) items.push({ icon: 'fa-robot', text: completionMsg, cls: 'completion' })
if (actionResult) items.push({ icon: 'fa-bolt', text: actionResult, cls: 'tool-call' })
if (errorMsg) items.push({ icon: 'fa-exclamation-triangle', text: errorMsg, cls: 'error' })
if (filterInfo) items.push({ icon: 'fa-shield-alt', text: filterInfo, cls: 'completion' })
if (items.length === 0) return null
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 2 }}>
{items.map((item, i) => (
<div key={i} className={`as-summary-item as-summary-${item.cls}`} title={item.text}>
<i className={`fas ${item.icon}`} />
<span>{item.text}</span>
</div>
))}
</div>
)
}
function ObservableCard({ observable, children: childNodes }) {
const [expanded, setExpanded] = useState(false)
const isComplete = !!observable.completion
const hasProgress = observable.progress?.length > 0
return (
<div className="as-card">
<div className="as-card-header" onClick={() => setExpanded(!expanded)}>
<div className="as-card-title">
<div className="as-obs-icon">
<i className={`fas fa-${observable.icon || 'robot'}`} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}>
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>{observable.name}</span>
<span className="as-id">#{observable.id}</span>
{!isComplete && <i className="fas fa-circle-notch fa-spin" style={{ fontSize: '0.7rem', color: 'var(--color-primary)' }} />}
</div>
<ObservableSummary observable={observable} />
</div>
</div>
<i className={`fas fa-chevron-${expanded ? 'up' : 'down'}`} style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem' }} />
</div>
{expanded && (
<div className="as-card-body">
{/* Children (nested observables) */}
{childNodes && childNodes.length > 0 && (
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', marginBottom: 'var(--spacing-xs)' }}>
Nested Observables
</div>
{childNodes}
</div>
)}
{/* Progress entries */}
{hasProgress && (
<div style={{ marginBottom: 'var(--spacing-sm)' }}>
<div className="as-section-label">Progress ({observable.progress.length})</div>
{observable.progress.map((p, i) => (
<div key={i} className="as-progress-entry">
{p.action_result && <div><span className="as-tag">Action Result</span> {p.action_result}</div>}
{p.error && <div className="as-error-text"><span className="as-tag as-tag-error">Error</span> {p.error}</div>}
{p.chat_completion_response?.choices?.length > 0 && (
<div>
<span className="as-tag">Response</span>{' '}
{p.chat_completion_response.choices.map((ch, ci) => (
<span key={ci}>{ch.message?.content || '(tool call)'}</span>
))}
</div>
)}
{p.agent_state && (
<div><span className="as-tag">State</span> {JSON.stringify(p.agent_state)}</div>
)}
</div>
))}
</div>
)}
{/* Completion */}
{observable.completion && (
<div style={{ marginBottom: 'var(--spacing-sm)' }}>
<div className="as-section-label">Completion</div>
{observable.completion.action_result && (
<div className="as-progress-entry"><span className="as-tag">Action Result</span> {observable.completion.action_result}</div>
)}
{observable.completion.error && (
<div className="as-progress-entry as-error-text"><span className="as-tag as-tag-error">Error</span> {observable.completion.error}</div>
)}
{observable.completion.filter_result && (
<div className="as-progress-entry"><span className="as-tag">Filter</span> {JSON.stringify(observable.completion.filter_result)}</div>
)}
</div>
)}
{/* Raw JSON */}
<details className="as-raw">
<summary>Raw JSON</summary>
<pre className="as-json">{JSON.stringify(observable, null, 2)}</pre>
</details>
</div>
)}
</div>
)
}
function buildTree(observables) {
const byId = {}
observables.forEach(obs => { byId[obs.id] = { ...obs, children: [] } })
const roots = []
observables.forEach(obs => {
if (obs.parent_id && byId[obs.parent_id]) {
byId[obs.parent_id].children.push(byId[obs.id])
} else {
roots.push(byId[obs.id])
}
})
return roots
}
function renderTree(nodes) {
return nodes.map(node => (
<ObservableCard key={node.id} observable={node}>
{node.children.length > 0 ? renderTree(node.children) : null}
</ObservableCard>
))
}
export default function AgentStatus() {
const { name } = useParams()
const navigate = useNavigate()
const { addToast } = useOutletContext()
const [observables, setObservables] = useState([])
const [status, setStatus] = useState(null)
const [loading, setLoading] = useState(true)
const fetchData = useCallback(async () => {
try {
const obsData = await agentsApi.observables(name)
const history = Array.isArray(obsData) ? obsData : (obsData?.History || [])
setObservables(history)
} catch (err) {
addToast(`Failed to load observables: ${err.message}`, 'error')
}
try {
const statusData = await agentsApi.status(name)
setStatus(statusData)
} catch (_) {
// status endpoint may fail if no actions have run yet
}
setLoading(false)
}, [name, addToast])
useEffect(() => {
fetchData()
const interval = setInterval(fetchData, 5000)
return () => clearInterval(interval)
}, [fetchData])
// SSE for real-time observable updates
useEffect(() => {
const url = `/api/agents/${encodeURIComponent(name)}/sse`
const es = new EventSource(url)
es.addEventListener('observable_update', (e) => {
try {
const data = JSON.parse(e.data)
setObservables(prev => {
const idx = prev.findIndex(o => o.id === data.id)
if (idx >= 0) {
const updated = [...prev]
const existing = updated[idx]
updated[idx] = {
...existing,
...data,
creation: data.creation || existing.creation,
completion: data.completion || existing.completion,
progress: (data.progress?.length ?? 0) > (existing.progress?.length ?? 0) ? data.progress : existing.progress,
}
return updated
}
return [...prev, data]
})
} catch (_) { /* ignore */ }
})
es.onerror = () => { /* reconnect handled by browser */ }
return () => es.close()
}, [name])
const handleClear = async () => {
try {
await agentsApi.clearObservables(name)
setObservables([])
addToast('Observables cleared', 'success')
} catch (err) {
addToast(`Failed to clear: ${err.message}`, 'error')
}
}
const tree = buildTree(observables)
return (
<div className="page">
<style>{`
.as-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-sm);
overflow: hidden;
}
.as-card .as-card {
border-left: 3px solid var(--color-primary);
margin-left: var(--spacing-md);
}
.as-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px var(--spacing-md);
cursor: pointer;
gap: var(--spacing-sm);
}
.as-card-header:hover { background: var(--color-bg-tertiary); }
.as-card-title { display: flex; align-items: flex-start; gap: var(--spacing-sm); flex: 1; min-width: 0; }
.as-obs-icon {
width: 28px; height: 28px;
border-radius: var(--radius-md);
background: var(--color-primary-light);
color: var(--color-primary);
display: flex; align-items: center; justify-content: center;
font-size: 0.75rem; flex-shrink: 0;
}
.as-id {
font-size: 0.6875rem;
color: var(--color-text-muted);
font-family: 'JetBrains Mono', monospace;
}
.as-summary-item {
display: flex; align-items: center; gap: 6px;
font-size: 0.75rem; color: var(--color-text-secondary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.as-summary-item i { font-size: 0.625rem; flex-shrink: 0; }
.as-summary-creation i { color: var(--color-primary); }
.as-summary-tool-call i { color: #f59e0b; }
.as-summary-completion i { color: var(--color-success); }
.as-summary-error i { color: var(--color-error); }
.as-card-body {
padding: var(--spacing-md);
border-top: 1px solid var(--color-border);
}
.as-section-label {
font-size: 0.6875rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.04em; color: var(--color-text-muted);
margin-bottom: var(--spacing-xs);
}
.as-progress-entry {
font-size: 0.8125rem; color: var(--color-text-primary);
padding: 4px 0; border-bottom: 1px solid var(--color-border-subtle);
word-break: break-word;
}
.as-progress-entry:last-child { border-bottom: none; }
.as-tag {
display: inline-block; padding: 1px 6px; border-radius: var(--radius-sm);
font-size: 0.625rem; font-weight: 600; text-transform: uppercase;
background: var(--color-bg-tertiary); color: var(--color-text-muted);
margin-right: 4px; vertical-align: middle;
}
.as-tag-error { background: var(--color-error); color: #fff; }
.as-error-text { color: var(--color-error); }
.as-raw { margin-top: var(--spacing-sm); }
.as-raw summary { font-size: 0.75rem; color: var(--color-text-muted); cursor: pointer; }
.as-json {
background: var(--color-bg-tertiary); border-radius: var(--radius-sm);
padding: var(--spacing-sm); font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem; overflow-x: auto; white-space: pre-wrap;
word-break: break-word; max-height: 300px; overflow-y: auto;
}
.as-status-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--spacing-sm); margin-bottom: var(--spacing-lg);
}
.as-status-item {
background: var(--color-bg-secondary); border: 1px solid var(--color-border);
border-radius: var(--radius-md); padding: var(--spacing-md);
}
.as-status-label {
font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--color-text-muted); margin-bottom: 4px;
}
.as-status-value { font-size: 1rem; font-weight: 600; color: var(--color-text-primary); }
`}</style>
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 className="page-title">
<i className="fas fa-chart-bar" style={{ marginRight: 'var(--spacing-xs)' }} />
{name} Status
</h1>
<p className="page-subtitle">Agent observables and activity history</p>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<button className="btn btn-secondary" onClick={() => navigate(`/agents/${encodeURIComponent(name)}/chat`)}>
<i className="fas fa-comment" /> Chat
</button>
<button className="btn btn-secondary" onClick={() => navigate(`/agents/${encodeURIComponent(name)}/edit`)}>
<i className="fas fa-edit" /> Edit
</button>
<button className="btn btn-secondary" onClick={fetchData}>
<i className="fas fa-sync" /> Refresh
</button>
<button className="btn btn-danger" onClick={handleClear} disabled={observables.length === 0}>
<i className="fas fa-trash" /> Clear
</button>
</div>
</div>
{/* Status summary */}
{status && (
<div className="as-status-grid">
{status.state && (
<div className="as-status-item">
<div className="as-status-label">State</div>
<div className="as-status-value">{status.state}</div>
</div>
)}
{status.current_task && (
<div className="as-status-item">
<div className="as-status-label">Current Task</div>
<div className="as-status-value" style={{ fontSize: '0.8125rem', fontWeight: 400 }}>{status.current_task}</div>
</div>
)}
<div className="as-status-item">
<div className="as-status-label">Observables</div>
<div className="as-status-value">{observables.length}</div>
</div>
</div>
)}
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<i className="fas fa-spinner fa-spin" style={{ fontSize: '2rem', color: 'var(--color-primary)' }} />
</div>
) : tree.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-chart-bar" /></div>
<h2 className="empty-state-title">No observables yet</h2>
<p className="empty-state-text">Send a message to the agent to see its activity here.</p>
<button className="btn btn-primary" onClick={() => navigate(`/agents/${encodeURIComponent(name)}/chat`)}>
<i className="fas fa-comment" /> Chat with {name}
</button>
</div>
) : (
<div>
{renderTree(tree)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,303 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { agentsApi } from '../utils/api'
export default function Agents() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
const [agents, setAgents] = useState([])
const [loading, setLoading] = useState(true)
const [agentHubURL, setAgentHubURL] = useState('')
const [search, setSearch] = useState('')
const fetchAgents = useCallback(async () => {
try {
const data = await agentsApi.list()
const names = Array.isArray(data.agents) ? data.agents : []
const statuses = data.statuses || {}
if (data.agent_hub_url) setAgentHubURL(data.agent_hub_url)
setAgents(names.map(name => ({
name,
status: statuses[name] ? 'active' : 'paused',
})))
} catch (err) {
addToast(`Failed to load agents: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}, [addToast])
useEffect(() => {
fetchAgents()
const interval = setInterval(fetchAgents, 5000)
return () => clearInterval(interval)
}, [fetchAgents])
const filtered = useMemo(() => {
if (!search.trim()) return agents
const q = search.toLowerCase()
return agents.filter(a => a.name.toLowerCase().includes(q))
}, [agents, search])
const handleDelete = async (name) => {
if (!window.confirm(`Delete agent "${name}"? This action cannot be undone.`)) return
try {
await agentsApi.delete(name)
addToast(`Agent "${name}" deleted`, 'success')
fetchAgents()
} catch (err) {
addToast(`Failed to delete agent: ${err.message}`, 'error')
}
}
const handlePauseResume = async (agent) => {
const name = agent.name || agent.id
const isActive = agent.status === 'active'
try {
if (isActive) {
await agentsApi.pause(name)
addToast(`Agent "${name}" paused`, 'success')
} else {
await agentsApi.resume(name)
addToast(`Agent "${name}" resumed`, 'success')
}
fetchAgents()
} catch (err) {
addToast(`Failed to ${isActive ? 'pause' : 'resume'} agent: ${err.message}`, 'error')
}
}
const handleExport = async (name) => {
try {
const data = await agentsApi.export(name)
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${name}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
addToast(`Agent "${name}" exported`, 'success')
} catch (err) {
addToast(`Failed to export agent: ${err.message}`, 'error')
}
}
const handleImport = async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
const text = await file.text()
const config = JSON.parse(text)
navigate('/agents/new', { state: { importedConfig: config } })
} catch (err) {
addToast(`Failed to parse agent file: ${err.message}`, 'error')
}
e.target.value = ''
}
const statusBadge = (status) => {
const cls = status === 'active' ? 'badge-success' : status === 'paused' ? 'badge-warning' : ''
return <span className={`badge ${cls}`}>{status || 'unknown'}</span>
}
return (
<div className="page">
<style>{`
.agents-import-input { display: none; }
.agents-toolbar {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
flex-wrap: wrap;
}
.agents-search {
flex: 1;
min-width: 180px;
max-width: 360px;
position: relative;
}
.agents-search i {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-muted);
font-size: 0.8125rem;
pointer-events: none;
}
.agents-search input {
padding-left: 32px;
}
.agents-action-group {
display: flex;
gap: var(--spacing-xs);
justify-content: flex-end;
}
.agents-name {
cursor: pointer;
color: var(--color-primary);
font-weight: 500;
}
.agents-name:hover {
text-decoration: underline;
}
`}</style>
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 className="page-title">Agents</h1>
<p className="page-subtitle">Manage autonomous AI agents</p>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', alignItems: 'center' }}>
{agentHubURL && (
<a className="btn btn-secondary" href={agentHubURL} target="_blank" rel="noopener noreferrer">
<i className="fas fa-store" /> Agent Hub
</a>
)}
<label className="btn btn-secondary">
<i className="fas fa-file-import" /> Import
<input type="file" accept=".json" className="agents-import-input" onChange={handleImport} />
</label>
<button className="btn btn-primary" onClick={() => navigate('/agents/new')}>
<i className="fas fa-plus" /> Create Agent
</button>
</div>
</div>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<i className="fas fa-spinner fa-spin" style={{ fontSize: '2rem', color: 'var(--color-primary)' }} />
</div>
) : agents.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-robot" /></div>
<h2 className="empty-state-title">No agents configured</h2>
<p className="empty-state-text">Create an agent to get started with autonomous AI workflows.</p>
{agentHubURL && (
<p className="empty-state-text">
Don't know where to start? Browse the <a href={agentHubURL} target="_blank" rel="noopener noreferrer">Agent Hub</a> to find ready-made agent configurations you can import.
</p>
)}
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'center', flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={() => navigate('/agents/new')}>
<i className="fas fa-plus" /> Create Agent
</button>
<label className="btn btn-secondary">
<i className="fas fa-file-import" /> Import
<input type="file" accept=".json" className="agents-import-input" onChange={handleImport} />
</label>
{agentHubURL && (
<a className="btn btn-secondary" href={agentHubURL} target="_blank" rel="noopener noreferrer">
<i className="fas fa-store" /> Agent Hub
</a>
)}
</div>
</div>
) : (
<>
<div className="agents-toolbar">
<div className="agents-search">
<i className="fas fa-search" />
<input
className="input"
type="text"
placeholder="Search agents..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
{filtered.length} of {agents.length} agent{agents.length !== 1 ? 's' : ''}
</span>
</div>
{filtered.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-search" /></div>
<h2 className="empty-state-title">No matching agents</h2>
<p className="empty-state-text">No agents match "{search}"</p>
</div>
) : (
<div className="table-container">
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{filtered.map(agent => {
const name = agent.name || agent.id
const isActive = agent.status === 'active'
return (
<tr key={name}>
<td>
<a className="agents-name" onClick={() => navigate(`/agents/${encodeURIComponent(name)}/chat`)}>
{name}
</a>
</td>
<td>{statusBadge(agent.status)}</td>
<td>
<div className="agents-action-group">
<button
className={`btn btn-sm ${isActive ? 'btn-warning' : 'btn-success'}`}
onClick={() => handlePauseResume(agent)}
title={isActive ? 'Pause' : 'Resume'}
>
<i className={`fas ${isActive ? 'fa-pause' : 'fa-play'}`} />
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate(`/agents/${encodeURIComponent(name)}/edit`)}
title="Edit"
>
<i className="fas fa-edit" />
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate(`/agents/${encodeURIComponent(name)}/chat`)}
title="Chat"
>
<i className="fas fa-comment" />
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate(`/agents/${encodeURIComponent(name)}/status`)}
title="Status & Observables"
>
<i className="fas fa-chart-bar" />
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleExport(name)}
title="Export"
>
<i className="fas fa-download" />
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => handleDelete(name)}
title="Delete"
>
<i className="fas fa-trash" />
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,509 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, useOutletContext } from 'react-router-dom'
import { agentCollectionsApi } from '../utils/api'
export default function CollectionDetails() {
const { name } = useParams()
const { addToast } = useOutletContext()
const [activeTab, setActiveTab] = useState('entries')
const [loading, setLoading] = useState(true)
// Entries tab state
const [entries, setEntries] = useState([])
const [uploadFile, setUploadFile] = useState(null)
const [uploading, setUploading] = useState(false)
// Search tab state
const [searchQuery, setSearchQuery] = useState('')
const [searchMaxResults, setSearchMaxResults] = useState(10)
const [searchResults, setSearchResults] = useState([])
const [searching, setSearching] = useState(false)
// Entry content modal state
const [viewEntry, setViewEntry] = useState(null)
const [viewContent, setViewContent] = useState(null)
const [viewLoading, setViewLoading] = useState(false)
// Sources tab state
const [sources, setSources] = useState([])
const [newSourceUrl, setNewSourceUrl] = useState('')
const [newSourceInterval, setNewSourceInterval] = useState('')
const [addingSource, setAddingSource] = useState(false)
const fetchEntries = useCallback(async () => {
try {
const data = await agentCollectionsApi.entries(name)
setEntries(Array.isArray(data.entries) ? data.entries : [])
} catch (err) {
addToast(`Failed to load entries: ${err.message}`, 'error')
}
}, [name, addToast])
const fetchSources = useCallback(async () => {
try {
const data = await agentCollectionsApi.sources(name)
setSources(Array.isArray(data.sources) ? data.sources : [])
} catch (err) {
addToast(`Failed to load sources: ${err.message}`, 'error')
}
}, [name, addToast])
useEffect(() => {
const load = async () => {
setLoading(true)
await Promise.allSettled([fetchEntries(), fetchSources()])
setLoading(false)
}
load()
}, [fetchEntries, fetchSources])
const handleViewContent = async (entry) => {
setViewEntry(entry)
setViewContent(null)
setViewLoading(true)
try {
const data = await agentCollectionsApi.entryContent(name, entry)
setViewContent(data)
} catch (err) {
addToast(`Failed to load entry content: ${err.message}`, 'error')
setViewEntry(null)
} finally {
setViewLoading(false)
}
}
const handleDeleteEntry = async (entry) => {
if (!window.confirm('Are you sure you want to delete this entry?')) return
try {
await agentCollectionsApi.deleteEntry(name, entry)
addToast('Entry deleted', 'success')
fetchEntries()
} catch (err) {
addToast(`Failed to delete entry: ${err.message}`, 'error')
}
}
const handleUpload = async (e) => {
e.preventDefault()
if (!uploadFile) return
setUploading(true)
try {
const formData = new FormData()
formData.append('file', uploadFile)
await agentCollectionsApi.upload(name, formData)
addToast('File uploaded successfully', 'success')
setUploadFile(null)
fetchEntries()
} catch (err) {
addToast(`Upload failed: ${err.message}`, 'error')
} finally {
setUploading(false)
}
}
const handleSearch = async (e) => {
e.preventDefault()
if (!searchQuery.trim()) return
setSearching(true)
try {
const data = await agentCollectionsApi.search(name, searchQuery, searchMaxResults)
setSearchResults(Array.isArray(data.results) ? data.results : [])
} catch (err) {
addToast(`Search failed: ${err.message}`, 'error')
} finally {
setSearching(false)
}
}
const handleAddSource = async (e) => {
e.preventDefault()
if (!newSourceUrl.trim()) return
setAddingSource(true)
try {
await agentCollectionsApi.addSource(name, newSourceUrl, newSourceInterval || undefined)
addToast('Source added', 'success')
setNewSourceUrl('')
setNewSourceInterval('')
fetchSources()
} catch (err) {
addToast(`Failed to add source: ${err.message}`, 'error')
} finally {
setAddingSource(false)
}
}
const handleRemoveSource = async (url) => {
if (!window.confirm('Are you sure you want to remove this source?')) return
try {
await agentCollectionsApi.removeSource(name, url)
addToast('Source removed', 'success')
fetchSources()
} catch (err) {
addToast(`Failed to remove source: ${err.message}`, 'error')
}
}
return (
<div className="page">
<style>{`
.collection-detail-upload-form {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
flex-wrap: wrap;
}
.collection-detail-search-form {
display: flex;
align-items: flex-end;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
flex-wrap: wrap;
}
.collection-detail-search-form .collection-detail-field {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.collection-detail-search-form .collection-detail-field label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-secondary);
}
.collection-detail-result-card {
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
padding: var(--spacing-md);
margin-bottom: var(--spacing-sm);
}
.collection-detail-result-score {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-primary);
margin-bottom: var(--spacing-xs);
}
.collection-detail-result-content {
font-size: 0.875rem;
color: var(--color-text-primary);
white-space: pre-wrap;
word-break: break-word;
}
.collection-detail-source-form {
display: flex;
align-items: flex-end;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
flex-wrap: wrap;
}
.collection-detail-source-form .collection-detail-field {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
flex: 1;
min-width: 200px;
}
.collection-detail-source-form .collection-detail-field label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-secondary);
}
.collection-detail-entry-content {
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.collection-detail-empty {
text-align: center;
padding: var(--spacing-xl);
color: var(--color-text-muted);
}
.collection-detail-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.collection-detail-modal {
background: var(--color-bg-primary);
border-radius: var(--radius-lg);
width: 90%;
max-width: 700px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.collection-detail-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md) var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
}
.collection-detail-modal-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collection-detail-modal-body {
padding: var(--spacing-lg);
overflow-y: auto;
flex: 1;
}
.collection-detail-modal-content {
white-space: pre-wrap;
word-break: break-word;
font-family: 'JetBrains Mono', monospace;
font-size: 0.8125rem;
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
padding: var(--spacing-md);
max-height: 50vh;
overflow-y: auto;
}
`}</style>
<div className="page-header">
<h1 className="page-title">{name}</h1>
<p className="page-subtitle">Collection details and management</p>
</div>
<div className="tabs">
<button className={`tab ${activeTab === 'entries' ? 'tab-active' : ''}`} onClick={() => setActiveTab('entries')}>
<i className="fas fa-list" /> Entries
</button>
<button className={`tab ${activeTab === 'search' ? 'tab-active' : ''}`} onClick={() => setActiveTab('search')}>
<i className="fas fa-search" /> Search
</button>
<button className={`tab ${activeTab === 'sources' ? 'tab-active' : ''}`} onClick={() => setActiveTab('sources')}>
<i className="fas fa-globe" /> Sources
</button>
</div>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<i className="fas fa-spinner fa-spin" style={{ fontSize: '2rem', color: 'var(--color-primary)' }} />
</div>
) : activeTab === 'entries' ? (
<>
<form className="collection-detail-upload-form" onSubmit={handleUpload}>
<input
className="input"
type="file"
onChange={(e) => setUploadFile(e.target.files[0] || null)}
style={{ flex: 1, minWidth: 200 }}
/>
<button className="btn btn-primary" type="submit" disabled={!uploadFile || uploading}>
{uploading ? <><i className="fas fa-spinner fa-spin" /> Uploading...</> : <><i className="fas fa-upload" /> Upload</>}
</button>
</form>
{entries.length === 0 ? (
<div className="collection-detail-empty">
<i className="fas fa-inbox" style={{ fontSize: '2rem', marginBottom: 'var(--spacing-sm)', display: 'block' }} />
<p>No entries in this collection. Upload a file to get started.</p>
</div>
) : (
<div className="table-container">
<table className="table">
<thead>
<tr>
<th>Entry</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{entries.map((entry, index) => (
<tr key={index}>
<td>
<div className="collection-detail-entry-content">
{typeof entry === 'string' ? entry : JSON.stringify(entry)}
</div>
</td>
<td>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-xs)' }}>
<button className="btn btn-secondary btn-sm" onClick={() => handleViewContent(entry)} title="View Content">
<i className="fas fa-eye" />
</button>
<button className="btn btn-danger btn-sm" onClick={() => handleDeleteEntry(entry)} title="Delete">
<i className="fas fa-trash" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
) : activeTab === 'search' ? (
<>
<form className="collection-detail-search-form" onSubmit={handleSearch}>
<div className="collection-detail-field" style={{ flex: 2, minWidth: 250 }}>
<label htmlFor="search-query">Query</label>
<input
id="search-query"
className="input"
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Enter search query..."
/>
</div>
<div className="collection-detail-field" style={{ flex: 0, minWidth: 100 }}>
<label htmlFor="search-max">Max Results</label>
<input
id="search-max"
className="input"
type="number"
min={1}
max={100}
value={searchMaxResults}
onChange={(e) => setSearchMaxResults(parseInt(e.target.value, 10) || 10)}
style={{ width: 100 }}
/>
</div>
<button className="btn btn-primary" type="submit" disabled={!searchQuery.trim() || searching}>
{searching ? <><i className="fas fa-spinner fa-spin" /> Searching...</> : <><i className="fas fa-search" /> Search</>}
</button>
</form>
{searchResults.length === 0 ? (
<div className="collection-detail-empty">
<i className="fas fa-search" style={{ fontSize: '2rem', marginBottom: 'var(--spacing-sm)', display: 'block' }} />
<p>No results. Enter a query and click Search.</p>
</div>
) : (
searchResults.map((result, index) => (
<div className="collection-detail-result-card" key={index}>
<div className="collection-detail-result-score">
Similarity: {typeof result.similarity === 'number' ? result.similarity.toFixed(4) : (result.score != null ? Number(result.score).toFixed(4) : 'N/A')}
</div>
<div className="collection-detail-result-content">
{result.content || result.text || (typeof result === 'string' ? result : JSON.stringify(result))}
</div>
</div>
))
)}
</>
) : (
<>
<form className="collection-detail-source-form" onSubmit={handleAddSource}>
<div className="collection-detail-field">
<label htmlFor="source-url">URL</label>
<input
id="source-url"
className="input"
type="text"
value={newSourceUrl}
onChange={(e) => setNewSourceUrl(e.target.value)}
placeholder="https://example.com/data"
/>
</div>
<div className="collection-detail-field" style={{ flex: 0, minWidth: 160 }}>
<label htmlFor="source-interval">Update Interval</label>
<input
id="source-interval"
className="input"
type="text"
value={newSourceInterval}
onChange={(e) => setNewSourceInterval(e.target.value)}
placeholder="e.g. 1h, 30m"
/>
</div>
<button className="btn btn-primary" type="submit" disabled={!newSourceUrl.trim() || addingSource}>
{addingSource ? <><i className="fas fa-spinner fa-spin" /> Adding...</> : <><i className="fas fa-plus" /> Add Source</>}
</button>
</form>
{sources.length === 0 ? (
<div className="collection-detail-empty">
<i className="fas fa-globe" style={{ fontSize: '2rem', marginBottom: 'var(--spacing-sm)', display: 'block' }} />
<p>No external sources configured. Add a URL to start ingesting data.</p>
</div>
) : (
<div className="table-container">
<table className="table">
<thead>
<tr>
<th>URL</th>
<th>Interval</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{sources.map((source, index) => (
<tr key={index}>
<td style={{ fontSize: '0.8125rem', wordBreak: 'break-all' }}>
{typeof source === 'string' ? source : (source.url || JSON.stringify(source))}
</td>
<td style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>
{(typeof source === 'object' && source.update_interval) ? source.update_interval : '-'}
</td>
<td>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
className="btn btn-danger btn-sm"
onClick={() => handleRemoveSource(typeof source === 'string' ? source : source.url)}
title="Remove"
>
<i className="fas fa-trash" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
{/* Entry content modal */}
{viewEntry && (
<div className="collection-detail-modal-overlay" onClick={() => setViewEntry(null)}>
<div className="collection-detail-modal" onClick={(e) => e.stopPropagation()}>
<div className="collection-detail-modal-header">
<h3 title={viewEntry}><i className="fas fa-file-alt" style={{ marginRight: 'var(--spacing-xs)' }} />{viewEntry}</h3>
<button className="btn btn-secondary btn-sm" onClick={() => setViewEntry(null)}>
<i className="fas fa-times" />
</button>
</div>
<div className="collection-detail-modal-body">
{viewLoading ? (
<div style={{ textAlign: 'center', padding: 'var(--spacing-lg)' }}>
<i className="fas fa-spinner fa-spin" style={{ fontSize: '1.5rem', color: 'var(--color-primary)' }} />
</div>
) : viewContent ? (
<>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', marginBottom: 'var(--spacing-md)' }}>
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
<i className="fas fa-puzzle-piece" style={{ marginRight: 4 }} />
Chunks: <strong style={{ color: 'var(--color-text-primary)' }}>{viewContent.chunk_count ?? '-'}</strong>
</div>
</div>
<div className="collection-detail-modal-content">
{viewContent.content || '(empty)'}
</div>
</>
) : (
<p style={{ color: 'var(--color-text-muted)' }}>No content available.</p>
)}
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,152 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { agentCollectionsApi } from '../utils/api'
export default function Collections() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
const [collections, setCollections] = useState([])
const [loading, setLoading] = useState(true)
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
const fetchCollections = useCallback(async () => {
try {
const data = await agentCollectionsApi.list()
setCollections(Array.isArray(data.collections) ? data.collections : [])
} catch (err) {
addToast(`Failed to load collections: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}, [addToast])
useEffect(() => {
fetchCollections()
}, [fetchCollections])
const handleCreate = async () => {
const name = newName.trim()
if (!name) return
setCreating(true)
try {
await agentCollectionsApi.create(name)
addToast(`Collection "${name}" created`, 'success')
setNewName('')
fetchCollections()
} catch (err) {
addToast(`Failed to create collection: ${err.message}`, 'error')
} finally {
setCreating(false)
}
}
const handleDelete = async (name) => {
if (!window.confirm(`Delete collection "${name}"? This will remove all entries and cannot be undone.`)) return
try {
await agentCollectionsApi.reset(name)
addToast(`Collection "${name}" deleted`, 'success')
fetchCollections()
} catch (err) {
addToast(`Failed to delete collection: ${err.message}`, 'error')
}
}
const handleReset = async (name) => {
if (!window.confirm(`Reset collection "${name}"? This will remove all entries but keep the collection.`)) return
try {
await agentCollectionsApi.reset(name)
addToast(`Collection "${name}" reset`, 'success')
fetchCollections()
} catch (err) {
addToast(`Failed to reset collection: ${err.message}`, 'error')
}
}
return (
<div className="page">
<style>{`
.collections-create-bar {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
}
.collections-create-bar .input {
flex: 1;
}
.collections-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--spacing-md);
}
.collections-card-name {
font-size: 1rem;
font-weight: 600;
margin-bottom: var(--spacing-sm);
word-break: break-word;
}
.collections-card-actions {
display: flex;
gap: var(--spacing-xs);
margin-top: var(--spacing-md);
}
`}</style>
<div className="page-header">
<h1 className="page-title">Knowledge Base</h1>
<p className="page-subtitle">Manage document collections for agent RAG</p>
</div>
<div className="collections-create-bar">
<input
className="input"
type="text"
placeholder="New collection name..."
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate() }}
/>
<button className="btn btn-primary" onClick={handleCreate} disabled={creating || !newName.trim()}>
{creating ? <><i className="fas fa-spinner fa-spin" /> Creating...</> : <><i className="fas fa-plus" /> Create</>}
</button>
</div>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<i className="fas fa-spinner fa-spin" style={{ fontSize: '2rem', color: 'var(--color-text-muted)' }} />
</div>
) : collections.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-database" /></div>
<h2 className="empty-state-title">No collections yet</h2>
<p className="empty-state-text">Create a collection above to start building your knowledge base.</p>
</div>
) : (
<div className="collections-grid">
{collections.map((collection) => {
const name = typeof collection === 'string' ? collection : collection.name
return (
<div className="card" key={name} style={{ cursor: 'pointer' }} onClick={() => navigate(`/collections/${encodeURIComponent(name)}`)}>
<div className="collections-card-name">
<i className="fas fa-folder" style={{ marginRight: 'var(--spacing-xs)', color: 'var(--color-primary)' }} />
{name}
</div>
<div className="collections-card-actions" onClick={(e) => e.stopPropagation()}>
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/collections/${encodeURIComponent(name)}`)} title="View details">
<i className="fas fa-eye" /> Details
</button>
<button className="btn btn-secondary btn-sm" onClick={() => handleReset(name)} title="Reset collection">
<i className="fas fa-rotate" /> Reset
</button>
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(name)} title="Delete collection">
<i className="fas fa-trash" />
</button>
</div>
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import { useOutletContext } from 'react-router-dom'
import { settingsApi, resourcesApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import SearchableModelSelect from '../components/SearchableModelSelect'
import { formatBytes, percentColor } from '../utils/format'
function Toggle({ checked, onChange, disabled }) {
@@ -59,6 +60,7 @@ const SECTIONS = [
{ id: 'galleries', icon: 'fa-images', color: 'var(--color-accent)', label: 'Galleries' },
{ id: 'apikeys', icon: 'fa-key', color: 'var(--color-error)', label: 'API Keys' },
{ id: 'agents', icon: 'fa-tasks', color: 'var(--color-primary)', label: 'Agent Jobs' },
{ id: 'agentpool', icon: 'fa-robot', color: 'var(--color-primary)', label: 'Agent Pool' },
{ id: 'responses', icon: 'fa-database', color: 'var(--color-accent)', label: 'Responses' },
]
@@ -450,6 +452,36 @@ export default function Settings() {
</div>
</div>
{/* Agent Pool */}
<div ref={el => sectionRefs.current.agentpool = el} style={{ marginBottom: 'var(--spacing-xl)' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-robot" style={{ color: 'var(--color-primary)' }} /> Agent Pool
</h3>
<div className="card">
<SettingRow label="Enabled" description="Enable or disable the agent pool feature (requires restart)">
<Toggle checked={settings.agent_pool_enabled ?? true} onChange={(v) => update('agent_pool_enabled', v)} />
</SettingRow>
<SettingRow label="Default Model" description="Default LLM model for agents">
<SearchableModelSelect value={settings.agent_pool_default_model || ''} onChange={(v) => update('agent_pool_default_model', v)} capability="FLAG_CHAT" placeholder="e.g. gpt-4" />
</SettingRow>
<SettingRow label="Embedding Model" description="Model used for knowledge base embeddings">
<SearchableModelSelect value={settings.agent_pool_embedding_model || ''} onChange={(v) => update('agent_pool_embedding_model', v)} placeholder="granite-embedding-107m-multilingual" />
</SettingRow>
<SettingRow label="Max Chunking Size" description="Maximum chunk size for knowledge base documents (default: 400)">
<input className="input" type="number" style={{ width: 120 }} value={settings.agent_pool_max_chunking_size ?? 400} onChange={(e) => update('agent_pool_max_chunking_size', parseInt(e.target.value, 10) || 0)} min={0} />
</SettingRow>
<SettingRow label="Chunk Overlap" description="Overlap between chunks for knowledge base documents (default: 0)">
<input className="input" type="number" style={{ width: 120 }} value={settings.agent_pool_chunk_overlap ?? 0} onChange={(e) => update('agent_pool_chunk_overlap', parseInt(e.target.value, 10) || 0)} min={0} />
</SettingRow>
<SettingRow label="Enable Logs" description="Enable agent logging (requires restart)">
<Toggle checked={settings.agent_pool_enable_logs ?? false} onChange={(v) => update('agent_pool_enable_logs', v)} />
</SettingRow>
<SettingRow label="Collection DB Path" description="Database path for agent collections">
<input className="input" style={{ width: 280 }} value={settings.agent_pool_collection_db_path || ''} onChange={(e) => update('agent_pool_collection_db_path', e.target.value)} placeholder="Leave empty for default" />
</SettingRow>
</div>
</div>
{/* Open Responses */}
<div ref={el => sectionRefs.current.responses = el} style={{ marginBottom: 'var(--spacing-xl)' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>

View File

@@ -0,0 +1,629 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, useLocation, useOutletContext } from 'react-router-dom'
import { skillsApi } from '../utils/api'
const RESOURCE_PREFIXES = ['scripts/', 'references/', 'assets/']
function isValidResourcePath(path) {
return RESOURCE_PREFIXES.some((p) => path.startsWith(p)) && !path.includes('..')
}
function ResourceGroup({ title, icon, items, readOnly, pathPrefix, onView, onDelete, onUpload }) {
const [collapsed, setCollapsed] = useState(false)
return (
<div style={{ marginBottom: 'var(--spacing-lg)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-sm)' }}>
<h3
style={{ margin: 0, fontWeight: 600, fontSize: '0.95rem', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}
onClick={() => setCollapsed((v) => !v)}
>
<i className={`fas fa-chevron-${collapsed ? 'right' : 'down'}`} style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }} />
<i className={`fas fa-${icon}`} style={{ color: 'var(--color-primary)' }} /> {title}
<span className="badge" style={{ marginLeft: 'var(--spacing-xs)' }}>{items.length}</span>
</h3>
{!readOnly && (
<button className="btn btn-primary btn-sm" onClick={() => onUpload(pathPrefix)}>
<i className="fas fa-upload" /> Upload
</button>
)}
</div>
{!collapsed && (
items.length === 0 ? (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem', padding: 'var(--spacing-sm)' }}>
No {title.toLowerCase()} yet.
</p>
) : (
<div>
{items.map((res) => (
<div
key={res.path}
className="skilledit-resource-item"
>
<div style={{ minWidth: 0 }}>
<span style={{ fontWeight: 500 }}>{res.name}</span>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '0.8rem', marginLeft: 'var(--spacing-sm)' }}>
{res.mime_type} &middot; {(res.size || 0).toLocaleString()} B
</span>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)' }}>
<button className="btn btn-secondary btn-sm" onClick={() => onView(res)} title="View/Edit">
<i className="fas fa-edit" /> View/Edit
</button>
{!readOnly && (
<button className="btn btn-danger btn-sm" onClick={() => onDelete(res.path)} title="Delete">
<i className="fas fa-trash" />
</button>
)}
</div>
</div>
))}
</div>
)
)}
</div>
)
}
function ResourcesSection({ skillName, addToast }) {
const [data, setData] = useState({ scripts: [], references: [], assets: [], readOnly: false })
const [loading, setLoading] = useState(true)
const [editor, setEditor] = useState({ open: false, path: '', name: '', content: '', readable: true, saving: false })
const [upload, setUpload] = useState({ open: false, pathPrefix: 'assets/', file: null, pathInput: '', uploading: false })
const [deletePath, setDeletePath] = useState(null)
const load = async () => {
setLoading(true)
try {
const res = await skillsApi.listResources(skillName)
setData({
scripts: res.scripts || [],
references: res.references || [],
assets: res.assets || [],
readOnly: res.readOnly === true,
})
} catch (err) {
addToast(err.message || 'Failed to load resources', 'error')
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [skillName])
const handleView = async (res) => {
setEditor({ open: true, path: res.path, name: res.name, content: '', readable: res.readable !== false, saving: false })
if (res.readable !== false) {
try {
const json = await skillsApi.getResource(skillName, res.path, { json: true })
const content = json.encoding === 'base64' && json.content ? atob(json.content) : (json.content || '')
setEditor((e) => ({ ...e, content }))
} catch (err) {
addToast(err.message || 'Failed to load file', 'error')
}
}
}
const handleEditorSave = async () => {
setEditor((e) => ({ ...e, saving: true }))
try {
await skillsApi.updateResource(skillName, editor.path, editor.content)
addToast('Resource updated', 'success')
setEditor((e) => ({ ...e, open: false }))
load()
} catch (err) {
addToast(err.message || 'Update failed', 'error')
} finally {
setEditor((e) => ({ ...e, saving: false }))
}
}
const handleUploadOpen = (pathPrefix) => {
setUpload({ open: true, pathPrefix, file: null, pathInput: '', uploading: false })
}
const handleUploadSubmit = async () => {
const path = upload.pathInput.trim() || (upload.file ? upload.pathPrefix + upload.file.name : '')
if (!path || !upload.file) {
addToast('Select a file and ensure path is set', 'error')
return
}
if (!isValidResourcePath(path)) {
addToast('Path must start with scripts/, references/, or assets/', 'error')
return
}
setUpload((u) => ({ ...u, uploading: true }))
try {
await skillsApi.createResource(skillName, path, upload.file)
addToast('Resource added', 'success')
setUpload((u) => ({ ...u, open: false }))
load()
} catch (err) {
addToast(err.message || 'Upload failed', 'error')
} finally {
setUpload((u) => ({ ...u, uploading: false }))
}
}
const handleDeleteConfirm = async () => {
if (!deletePath) return
try {
await skillsApi.deleteResource(skillName, deletePath)
addToast('Resource deleted', 'success')
setDeletePath(null)
load()
} catch (err) {
addToast(err.message || 'Delete failed', 'error')
}
}
return (
<>
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-sm)' }}>
<i className="fas fa-folder" style={{ color: 'var(--color-primary)', marginRight: 'var(--spacing-xs)' }} /> Resources
</h3>
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem', marginBottom: 'var(--spacing-md)' }}>
Scripts, references, and assets for this skill. Paths must start with scripts/, references/, or assets/.
</p>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-md)' }}>
<i className="fas fa-spinner fa-spin" style={{ fontSize: '1.5rem', color: 'var(--color-text-muted)' }} />
</div>
) : (
<>
<ResourceGroup title="Scripts" icon="code" pathPrefix="scripts/" items={data.scripts} readOnly={data.readOnly} onView={handleView} onDelete={setDeletePath} onUpload={handleUploadOpen} />
<ResourceGroup title="References" icon="book" pathPrefix="references/" items={data.references} readOnly={data.readOnly} onView={handleView} onDelete={setDeletePath} onUpload={handleUploadOpen} />
<ResourceGroup title="Assets" icon="image" pathPrefix="assets/" items={data.assets} readOnly={data.readOnly} onView={handleView} onDelete={setDeletePath} onUpload={handleUploadOpen} />
</>
)}
{editor.open && (
<div className="skilledit-modal-overlay" onClick={() => !editor.saving && setEditor((e) => ({ ...e, open: false }))}>
<div className="card skilledit-modal-card" style={{ maxWidth: '700px' }} onClick={(e) => e.stopPropagation()}>
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-edit" style={{ color: 'var(--color-primary)', marginRight: 'var(--spacing-xs)' }} /> Edit {editor.name}
</h3>
{editor.readable ? (
<>
<textarea
className="input"
value={editor.content}
onChange={(e) => setEditor((x) => ({ ...x, content: e.target.value }))}
rows={14}
style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.875rem', marginBottom: 'var(--spacing-md)', width: '100%' }}
/>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'flex-end' }}>
<button className="btn btn-secondary" onClick={() => setEditor((e) => ({ ...e, open: false }))}>Cancel</button>
<button className="btn btn-primary" disabled={editor.saving} onClick={handleEditorSave}>
{editor.saving ? <><i className="fas fa-spinner fa-spin" /> Saving...</> : <><i className="fas fa-save" /> Save</>}
</button>
</div>
</>
) : (
<p style={{ color: 'var(--color-text-secondary)' }}>Binary file. Download via API or export skill.</p>
)}
</div>
</div>
)}
{upload.open && (
<div className="skilledit-modal-overlay" onClick={() => !upload.uploading && setUpload((u) => ({ ...u, open: false }))}>
<div className="card skilledit-modal-card" style={{ maxWidth: '400px' }} onClick={(e) => e.stopPropagation()}>
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-upload" style={{ color: 'var(--color-primary)', marginRight: 'var(--spacing-xs)' }} /> Upload to {upload.pathPrefix}
</h3>
<div className="form-group">
<label className="form-label">File</label>
<input
type="file"
className="input"
onChange={(e) => setUpload((u) => ({ ...u, file: e.target.files?.[0] || null }))}
/>
</div>
<div className="form-group">
<label className="form-label">Path (default: {upload.pathPrefix} + filename)</label>
<input
type="text"
className="input"
placeholder={`${upload.pathPrefix}filename`}
value={upload.pathInput}
onChange={(e) => setUpload((u) => ({ ...u, pathInput: e.target.value }))}
/>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'flex-end', marginTop: 'var(--spacing-md)' }}>
<button className="btn btn-secondary" onClick={() => setUpload((u) => ({ ...u, open: false }))}>Cancel</button>
<button className="btn btn-primary" disabled={upload.uploading || !upload.file} onClick={handleUploadSubmit}>
{upload.uploading ? <><i className="fas fa-spinner fa-spin" /> Uploading...</> : <><i className="fas fa-upload" /> Upload</>}
</button>
</div>
</div>
</div>
)}
{deletePath && (
<div className="skilledit-modal-overlay" onClick={() => setDeletePath(null)}>
<div className="card skilledit-modal-card" style={{ maxWidth: '360px' }} onClick={(e) => e.stopPropagation()}>
<p style={{ marginBottom: 'var(--spacing-md)' }}>Delete resource <strong>{deletePath}</strong>?</p>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'flex-end' }}>
<button className="btn btn-secondary" onClick={() => setDeletePath(null)}>Cancel</button>
<button className="btn btn-danger" onClick={handleDeleteConfirm}>
<i className="fas fa-trash" /> Delete
</button>
</div>
</div>
</div>
)}
</>
)
}
export default function SkillEdit() {
const { name: nameParam } = useParams()
const location = useLocation()
const isNew = location.pathname.endsWith('/new')
const name = nameParam ? decodeURIComponent(nameParam) : undefined
const navigate = useNavigate()
const { addToast } = useOutletContext()
const [loading, setLoading] = useState(!isNew)
const [saving, setSaving] = useState(false)
const [activeSection, setActiveSection] = useState('basic')
const [form, setForm] = useState({
name: '',
description: '',
content: '',
license: '',
compatibility: '',
metadata: {},
allowedTools: '',
})
useEffect(() => {
if (isNew) {
setLoading(false)
return
}
if (name) {
skillsApi.get(name)
.then((data) => {
setForm({
name: data.name || '',
description: data.description || '',
content: data.content || '',
license: data.license || '',
compatibility: data.compatibility || '',
metadata: data.metadata || {},
allowedTools: data['allowed-tools'] || '',
})
})
.catch((err) => {
addToast(err.message || 'Failed to load skill', 'error')
navigate('/skills')
})
.finally(() => setLoading(false))
}
}, [isNew, name, navigate, addToast])
const handleSubmit = async (e) => {
e.preventDefault()
if (!form.name.trim()) {
addToast('Skill name is required', 'warning')
return
}
if (!form.description.trim()) {
addToast('Skill description is required', 'warning')
return
}
setSaving(true)
try {
const payload = {
name: form.name,
description: form.description,
content: form.content,
license: form.license || undefined,
compatibility: form.compatibility || undefined,
metadata: Object.keys(form.metadata).length ? form.metadata : undefined,
'allowed-tools': form.allowedTools || undefined,
}
if (isNew) {
await skillsApi.create(payload)
addToast('Skill created', 'success')
} else {
await skillsApi.update(name, { ...payload, name: undefined })
addToast('Skill updated', 'success')
}
navigate('/skills')
} catch (err) {
addToast(err.message || 'Save failed', 'error')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="page" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<i className="fas fa-spinner fa-spin" style={{ fontSize: '2rem', color: 'var(--color-primary)' }} />
</div>
)
}
const sections = [
{ id: 'basic', label: 'Basic information', icon: 'fa-info-circle' },
{ id: 'content', label: 'Content', icon: 'fa-file-alt' },
...(!isNew && name ? [{ id: 'resources', label: 'Resources', icon: 'fa-folder' }] : []),
]
return (
<div className="page" style={{ maxWidth: 960 }}>
<style>{`
.skilledit-back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--color-text-secondary);
font-size: 0.875rem;
margin-bottom: var(--spacing-sm);
cursor: pointer;
text-decoration: none;
}
.skilledit-back-link:hover {
color: var(--color-primary);
}
.skilledit-layout {
display: flex;
gap: var(--spacing-lg);
}
.skilledit-sidebar {
flex-shrink: 0;
width: 200px;
}
.skilledit-sidebar-nav {
list-style: none;
padding: 0;
margin: 0;
}
.skilledit-sidebar-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
font-size: 0.875rem;
color: var(--color-text-secondary);
cursor: pointer;
border-radius: var(--radius-md);
border-left: 3px solid transparent;
transition: all var(--duration-fast) var(--ease-default);
}
.skilledit-sidebar-item:hover {
color: var(--color-text-primary);
background: var(--color-primary-light);
}
.skilledit-sidebar-item.active {
color: var(--color-primary);
background: var(--color-primary-light);
border-left-color: var(--color-primary);
font-weight: 500;
}
.skilledit-form-area {
flex: 1;
min-width: 0;
}
.skilledit-section-title {
font-weight: 600;
font-size: 1rem;
margin-bottom: var(--spacing-md);
}
.skilledit-field {
margin-bottom: var(--spacing-md);
}
.skilledit-field label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: var(--spacing-xs);
color: var(--color-text-primary);
}
.skilledit-field .required {
color: var(--color-error);
}
.skilledit-field .help-text {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: var(--spacing-xs);
}
.skilledit-form-actions {
display: flex;
gap: var(--spacing-sm);
justify-content: flex-end;
margin-top: var(--spacing-lg);
padding-top: var(--spacing-md);
border-top: 1px solid var(--color-border-subtle);
}
.skilledit-modal-overlay {
position: fixed;
inset: 0;
background: var(--color-bg-overlay);
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md);
}
.skilledit-modal-card {
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: auto;
}
.skilledit-resource-item {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
margin-bottom: var(--spacing-xs);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
}
@media (max-width: 700px) {
.skilledit-layout {
flex-direction: column;
}
.skilledit-sidebar {
width: 100%;
}
.skilledit-sidebar-nav {
display: flex;
gap: var(--spacing-xs);
overflow-x: auto;
}
.skilledit-sidebar-item {
border-left: none;
border-bottom: 3px solid transparent;
white-space: nowrap;
}
.skilledit-sidebar-item.active {
border-left-color: transparent;
border-bottom-color: var(--color-primary);
}
}
`}</style>
<a className="skilledit-back-link" onClick={() => navigate('/skills')}>
<i className="fas fa-arrow-left" /> Back to skills
</a>
<div className="page-header">
<h1 className="page-title">
<i className="fas fa-book" style={{ marginRight: 'var(--spacing-xs)' }} /> {isNew ? 'New skill' : `Edit: ${name}`}
</h1>
</div>
<div className="card" style={{ marginTop: 'var(--spacing-md)' }}>
<div className="skilledit-layout">
<div className="skilledit-sidebar">
<ul className="skilledit-sidebar-nav">
{sections.map((s) => (
<li
key={s.id}
className={`skilledit-sidebar-item ${activeSection === s.id ? 'active' : ''}`}
onClick={() => setActiveSection(s.id)}
>
<i className={`fas ${s.icon}`} /> {s.label}
</li>
))}
</ul>
</div>
<div className="skilledit-form-area">
<form onSubmit={handleSubmit} noValidate>
<div style={{ display: activeSection === 'basic' ? 'block' : 'none' }}>
<h3 className="skilledit-section-title">Basic information</h3>
<div className="skilledit-field">
<label htmlFor="skill-name">Name (lowercase, hyphens only) <span className="required">*</span></label>
<input
id="skill-name"
type="text"
className="input"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
disabled={!isNew}
placeholder="my-skill"
/>
{!isNew && <p className="help-text">Name cannot be changed after creation.</p>}
</div>
<div className="skilledit-field">
<label htmlFor="skill-desc">Description (required, max 1024 chars) <span className="required">*</span></label>
<textarea
id="skill-desc"
className="input"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
required
maxLength={1024}
rows={2}
/>
</div>
<div className="skilledit-field">
<label htmlFor="skill-license">License (optional)</label>
<input
id="skill-license"
type="text"
className="input"
value={form.license}
onChange={(e) => setForm((f) => ({ ...f, license: e.target.value }))}
/>
</div>
<div className="skilledit-field">
<label htmlFor="skill-compat">Compatibility (optional, max 500 chars)</label>
<input
id="skill-compat"
type="text"
className="input"
value={form.compatibility}
onChange={(e) => setForm((f) => ({ ...f, compatibility: e.target.value }))}
maxLength={500}
/>
</div>
<div className="skilledit-field">
<label htmlFor="skill-allowed-tools">Allowed tools (optional)</label>
<input
id="skill-allowed-tools"
type="text"
className="input"
value={form.allowedTools}
onChange={(e) => setForm((f) => ({ ...f, allowedTools: e.target.value }))}
placeholder="tool1, tool2"
/>
</div>
</div>
<div style={{ display: activeSection === 'content' ? 'block' : 'none' }}>
<h3 className="skilledit-section-title">Content</h3>
<div className="skilledit-field">
<label htmlFor="skill-content">Skill content (markdown)</label>
<textarea
id="skill-content"
className="input"
value={form.content}
onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}
rows={14}
style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.875rem' }}
/>
</div>
</div>
{activeSection === 'resources' && (
<div>
{isNew || !name ? (
<div>
<h3 className="skilledit-section-title">Resources</h3>
<p style={{ color: 'var(--color-text-secondary)' }}>
Save the skill first to add scripts, references, and assets. After creating the skill, use this tab to upload files and manage resources.
</p>
</div>
) : (
<ResourcesSection skillName={name} addToast={addToast} />
)}
</div>
)}
<div className="skilledit-form-actions">
<button type="button" className="btn btn-secondary" onClick={() => navigate('/skills')}>
<i className="fas fa-times" /> Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
<i className="fas fa-save" /> {saving ? 'Saving...' : (isNew ? 'Create skill' : 'Save changes')}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,452 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { skillsApi } from '../utils/api'
export default function Skills() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
const [skills, setSkills] = useState([])
const [searchQuery, setSearchQuery] = useState('')
const [loading, setLoading] = useState(true)
const [importing, setImporting] = useState(false)
const [unavailable, setUnavailable] = useState(false)
const [showGitRepos, setShowGitRepos] = useState(false)
const [gitRepos, setGitRepos] = useState([])
const [gitRepoUrl, setGitRepoUrl] = useState('')
const [gitReposLoading, setGitReposLoading] = useState(false)
const [gitReposAction, setGitReposAction] = useState(null)
const fetchSkills = useCallback(async () => {
setLoading(true)
setUnavailable(false)
const timeoutMs = 15000
const withTimeout = (p) =>
Promise.race([
p,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), timeoutMs)
),
])
try {
if (searchQuery.trim()) {
const data = await withTimeout(skillsApi.search(searchQuery.trim()))
setSkills(Array.isArray(data) ? data : [])
} else {
const data = await withTimeout(skillsApi.list())
setSkills(Array.isArray(data) ? data : [])
}
} catch (err) {
if (err.message?.includes('503') || err.message?.includes('skills')) {
setUnavailable(true)
setSkills([])
} else {
addToast(err.message || 'Failed to load skills', 'error')
setSkills([])
}
} finally {
setLoading(false)
}
}, [searchQuery, addToast])
useEffect(() => {
fetchSkills()
}, [fetchSkills])
const deleteSkill = async (name) => {
if (!window.confirm(`Delete skill "${name}"? This action cannot be undone.`)) return
try {
await skillsApi.delete(name)
addToast(`Skill "${name}" deleted`, 'success')
fetchSkills()
} catch (err) {
addToast(err.message || 'Failed to delete skill', 'error')
}
}
const exportSkill = async (name) => {
try {
const url = skillsApi.exportUrl(name)
const res = await fetch(url, { credentials: 'same-origin' })
if (!res.ok) throw new Error(res.statusText || 'Export failed')
const blob = await res.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `${name.replace(/\//g, '-')}.tar.gz`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(a.href)
addToast(`Skill "${name}" exported`, 'success')
} catch (err) {
addToast(err.message || 'Export failed', 'error')
}
}
const handleImport = async (e) => {
const file = e.target.files?.[0]
if (!file) return
setImporting(true)
try {
await skillsApi.import(file)
addToast(`Skill imported from "${file.name}"`, 'success')
fetchSkills()
} catch (err) {
addToast(err.message || 'Import failed', 'error')
} finally {
setImporting(false)
e.target.value = ''
}
}
const loadGitRepos = async () => {
setGitReposLoading(true)
try {
const list = await skillsApi.listGitRepos()
setGitRepos(Array.isArray(list) ? list : [])
} catch (err) {
addToast(err.message || 'Failed to load Git repos', 'error')
setGitRepos([])
} finally {
setGitReposLoading(false)
}
}
useEffect(() => {
if (showGitRepos) loadGitRepos()
}, [showGitRepos])
const addGitRepo = async (e) => {
e.preventDefault()
const url = gitRepoUrl.trim()
if (!url) return
setGitReposAction('add')
try {
await skillsApi.addGitRepo(url)
setGitRepoUrl('')
await loadGitRepos()
fetchSkills()
addToast('Git repo added and syncing', 'success')
} catch (err) {
addToast(err.message || 'Failed to add repo', 'error')
} finally {
setGitReposAction(null)
}
}
const syncGitRepo = async (id) => {
setGitReposAction(id)
try {
await skillsApi.syncGitRepo(id)
await loadGitRepos()
fetchSkills()
addToast('Repo synced', 'success')
} catch (err) {
addToast(err.message || 'Sync failed', 'error')
} finally {
setGitReposAction(null)
}
}
const toggleGitRepo = async (id) => {
try {
await skillsApi.toggleGitRepo(id)
await loadGitRepos()
fetchSkills()
addToast('Repo toggled', 'success')
} catch (err) {
addToast(err.message || 'Toggle failed', 'error')
}
}
const deleteGitRepo = async (id) => {
if (!window.confirm('Remove this Git repository? Skills from it will no longer be available.')) return
try {
await skillsApi.deleteGitRepo(id)
await loadGitRepos()
fetchSkills()
addToast('Repo removed', 'success')
} catch (err) {
addToast(err.message || 'Remove failed', 'error')
}
}
if (unavailable) {
return (
<div className="page">
<div className="page-header">
<h1 className="page-title">Skills</h1>
<p className="page-subtitle">Skills service is not available or the index is rebuilding. Try again in a moment.</p>
</div>
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<button className="btn btn-primary" onClick={() => { setUnavailable(false); fetchSkills() }}>
<i className="fas fa-redo" /> Retry
</button>
</div>
</div>
)
}
return (
<div className="page">
<style>{`
.skills-header-actions {
display: flex;
gap: var(--spacing-sm);
align-items: center;
flex-wrap: wrap;
}
.skills-import-input {
display: none;
}
.skills-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--spacing-md);
}
.skills-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-sm);
}
.skills-card-name {
font-size: 1.05rem;
font-weight: 600;
margin: 0;
word-break: break-word;
}
.skills-card-desc {
margin: 0 0 var(--spacing-md) 0;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.skills-card-actions {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
}
.skills-git-section {
margin-bottom: var(--spacing-lg);
padding: var(--spacing-md);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-lg);
}
.skills-git-title {
font-size: 1rem;
font-weight: 600;
margin: 0 0 var(--spacing-sm) 0;
}
.skills-git-desc {
color: var(--color-text-secondary);
font-size: 0.875rem;
margin-bottom: var(--spacing-md);
}
.skills-git-form {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
margin-bottom: var(--spacing-md);
}
.skills-git-form .input {
flex: 1;
min-width: 200px;
}
.skills-git-repo-item {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
margin-bottom: var(--spacing-xs);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
}
.skills-git-repo-name {
font-weight: 600;
}
.skills-git-repo-url {
color: var(--color-text-secondary);
font-size: 0.875rem;
margin-left: var(--spacing-sm);
}
.skills-git-repo-actions {
display: flex;
gap: var(--spacing-xs);
}
`}</style>
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h1 className="page-title">Skills</h1>
<p className="page-subtitle">Manage agent skills (reusable instructions and resources)</p>
</div>
<div className="skills-header-actions">
<input
type="text"
className="input"
placeholder="Search skills..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ width: '200px' }}
/>
<button className="btn btn-primary" onClick={() => navigate('/skills/new')}>
<i className="fas fa-plus" /> New skill
</button>
<label className="btn btn-secondary" style={{ cursor: 'pointer' }}>
<i className="fas fa-file-import" /> {importing ? 'Importing...' : 'Import'}
<input
type="file"
accept=".tar.gz"
className="skills-import-input"
onChange={handleImport}
disabled={importing}
/>
</label>
<button
className={`btn ${showGitRepos ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setShowGitRepos((v) => !v)}
>
<i className="fas fa-code-branch" /> Git Repos
</button>
</div>
</div>
{showGitRepos && (
<div className="skills-git-section">
<h2 className="skills-git-title">
<i className="fas fa-code-branch" style={{ marginRight: 'var(--spacing-xs)', color: 'var(--color-primary)' }} /> Git repositories
</h2>
<p className="skills-git-desc">
Add Git repositories to pull skills from. Skills will appear in the list after sync.
</p>
<form onSubmit={addGitRepo} className="skills-git-form">
<input
type="url"
className="input"
placeholder="https://github.com/user/repo or git@github.com:user/repo.git"
value={gitRepoUrl}
onChange={(e) => setGitRepoUrl(e.target.value)}
/>
<button type="submit" className="btn btn-primary" disabled={gitReposAction === 'add'}>
{gitReposAction === 'add' ? <><i className="fas fa-spinner fa-spin" /> Adding...</> : 'Add repo'}
</button>
</form>
{gitReposLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-md)' }}>
<i className="fas fa-spinner fa-spin" style={{ fontSize: '1.5rem', color: 'var(--color-text-muted)' }} />
</div>
) : gitRepos.length === 0 ? (
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>No Git repos configured. Add one above.</p>
) : (
<div>
{gitRepos.map((r) => (
<div key={r.id} className="skills-git-repo-item">
<div>
<span className="skills-git-repo-name">{r.name || r.url}</span>
<span className="skills-git-repo-url">{r.url}</span>
{!r.enabled && <span className="badge" style={{ marginLeft: 'var(--spacing-sm)' }}>Disabled</span>}
</div>
<div className="skills-git-repo-actions">
<button
className="btn btn-secondary btn-sm"
onClick={() => syncGitRepo(r.id)}
disabled={gitReposAction === r.id}
title="Sync"
>
{gitReposAction === r.id ? <i className="fas fa-spinner fa-spin" /> : <><i className="fas fa-sync-alt" /> Sync</>}
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => toggleGitRepo(r.id)}
title={r.enabled ? 'Disable' : 'Enable'}
>
<i className={`fas fa-toggle-${r.enabled ? 'on' : 'off'}`} />
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => deleteGitRepo(r.id)}
title="Remove repo"
>
<i className="fas fa-trash" />
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<i className="fas fa-spinner fa-spin" style={{ fontSize: '2rem', color: 'var(--color-primary)' }} />
</div>
) : skills.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-book" /></div>
<h2 className="empty-state-title">No skills found</h2>
<p className="empty-state-text">Create a skill or import one to get started.</p>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={() => navigate('/skills/new')}>
<i className="fas fa-plus" /> Create skill
</button>
<label className="btn btn-secondary" style={{ cursor: 'pointer' }}>
<i className="fas fa-file-import" /> Import
<input
type="file"
accept=".tar.gz"
className="skills-import-input"
onChange={handleImport}
disabled={importing}
/>
</label>
</div>
</div>
) : (
<div className="skills-grid">
{skills.map((s) => (
<div key={s.name} className="card">
<div className="skills-card-header">
<h3 className="skills-card-name">{s.name}</h3>
{s.readOnly && <span className="badge">Read-only</span>}
</div>
<p className="skills-card-desc">
{s.description || 'No description'}
</p>
<div className="skills-card-actions">
{!s.readOnly && (
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate(`/skills/edit/${encodeURIComponent(s.name)}`)}
title="Edit skill"
>
<i className="fas fa-edit" /> Edit
</button>
)}
{!s.readOnly && (
<button
className="btn btn-danger btn-sm"
onClick={() => deleteSkill(s.name)}
title="Delete skill"
>
<i className="fas fa-trash" /> Delete
</button>
)}
<button
className="btn btn-secondary btn-sm"
onClick={() => exportSkill(s.name)}
title="Export as .tar.gz"
>
<i className="fas fa-download" /> Export
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -13,6 +13,14 @@ import Backends from './pages/Backends'
import Settings from './pages/Settings'
import Traces from './pages/Traces'
import P2P from './pages/P2P'
import Agents from './pages/Agents'
import AgentCreate from './pages/AgentCreate'
import AgentChat from './pages/AgentChat'
import AgentStatus from './pages/AgentStatus'
import Collections from './pages/Collections'
import CollectionDetails from './pages/CollectionDetails'
import Skills from './pages/Skills'
import SkillEdit from './pages/SkillEdit'
import AgentJobs from './pages/AgentJobs'
import AgentTaskDetails from './pages/AgentTaskDetails'
import AgentJobDetails from './pages/AgentJobDetails'
@@ -53,6 +61,16 @@ export const router = createBrowserRouter([
{ path: 'settings', element: <Settings /> },
{ path: 'traces', element: <Traces /> },
{ path: 'p2p', element: <P2P /> },
{ path: 'agents', element: <Agents /> },
{ path: 'agents/new', element: <AgentCreate /> },
{ path: 'agents/:name/edit', element: <AgentCreate /> },
{ path: 'agents/:name/chat', element: <AgentChat /> },
{ path: 'agents/:name/status', element: <AgentStatus /> },
{ path: 'collections', element: <Collections /> },
{ path: 'collections/:name', element: <CollectionDetails /> },
{ path: 'skills', element: <Skills /> },
{ path: 'skills/new', element: <SkillEdit /> },
{ path: 'skills/edit/:name', element: <SkillEdit /> },
{ path: 'agent-jobs', element: <AgentJobs /> },
{ path: 'agent-jobs/tasks/new', element: <AgentTaskDetails /> },
{ path: 'agent-jobs/tasks/:id', element: <AgentTaskDetails /> },

View File

@@ -239,6 +239,60 @@ export const systemApi = {
info: () => fetchJSON(API_CONFIG.endpoints.system),
}
export const agentsApi = {
list: () => fetchJSON('/api/agents'),
create: (config) => postJSON('/api/agents', config),
get: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}`),
getConfig: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/config`),
update: (name, config) => fetchJSON(`/api/agents/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(config), headers: { 'Content-Type': 'application/json' } }),
delete: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}`, { method: 'DELETE' }),
pause: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/pause`, { method: 'PUT' }),
resume: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/resume`, { method: 'PUT' }),
status: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/status`),
observables: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/observables`),
clearObservables: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/observables`, { method: 'DELETE' }),
chat: (name, message) => postJSON(`/api/agents/${encodeURIComponent(name)}/chat`, { message }),
export: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/export`),
import: (formData) => fetch('/api/agents/import', { method: 'POST', body: formData }).then(handleResponse),
configMeta: () => fetchJSON('/api/agents/config/metadata'),
}
export const agentCollectionsApi = {
list: () => fetchJSON('/api/agents/collections'),
create: (name) => postJSON('/api/agents/collections', { name }),
upload: (name, formData) => fetch(`/api/agents/collections/${encodeURIComponent(name)}/upload`, { method: 'POST', body: formData }).then(handleResponse),
entries: (name) => fetchJSON(`/api/agents/collections/${encodeURIComponent(name)}/entries`),
entryContent: (name, entry) => fetchJSON(`/api/agents/collections/${encodeURIComponent(name)}/entries/${encodeURIComponent(entry)}`),
search: (name, query, maxResults) => postJSON(`/api/agents/collections/${encodeURIComponent(name)}/search`, { query, max_results: maxResults }),
reset: (name) => postJSON(`/api/agents/collections/${encodeURIComponent(name)}/reset`),
deleteEntry: (name, entry) => fetchJSON(`/api/agents/collections/${encodeURIComponent(name)}/entry/delete`, { method: 'DELETE', body: JSON.stringify({ entry }), headers: { 'Content-Type': 'application/json' } }),
sources: (name) => fetchJSON(`/api/agents/collections/${encodeURIComponent(name)}/sources`),
addSource: (name, url, interval) => postJSON(`/api/agents/collections/${encodeURIComponent(name)}/sources`, { url, update_interval: interval }),
removeSource: (name, url) => fetchJSON(`/api/agents/collections/${encodeURIComponent(name)}/sources`, { method: 'DELETE', body: JSON.stringify({ url }), headers: { 'Content-Type': 'application/json' } }),
}
// Skills API
export const skillsApi = {
list: () => fetchJSON('/api/agents/skills'),
search: (q) => fetchJSON(`/api/agents/skills/search?q=${encodeURIComponent(q)}`),
get: (name) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}`),
create: (data) => postJSON('/api/agents/skills', data),
update: (name, data) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data), headers: { 'Content-Type': 'application/json' } }),
delete: (name) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}`, { method: 'DELETE' }),
import: (file) => { const fd = new FormData(); fd.append('file', file); return fetch('/api/agents/skills/import', { method: 'POST', body: fd }).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }); },
exportUrl: (name) => `/api/agents/skills/export/${encodeURIComponent(name)}`,
listResources: (name) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}/resources`),
getResource: (name, path, opts) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}/resources/${path}${opts?.json ? '?encoding=base64' : ''}`),
createResource: (name, path, file) => { const fd = new FormData(); fd.append('file', file); fd.append('path', path); return fetch(`/api/agents/skills/${encodeURIComponent(name)}/resources`, { method: 'POST', body: fd }).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }); },
updateResource: (name, path, content) => postJSON(`/api/agents/skills/${encodeURIComponent(name)}/resources/${path}`, { content }),
deleteResource: (name, path) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}/resources/${path}`, { method: 'DELETE' }),
listGitRepos: () => fetchJSON('/api/agents/git-repos'),
addGitRepo: (url) => postJSON('/api/agents/git-repos', { url }),
syncGitRepo: (id) => postJSON(`/api/agents/git-repos/${encodeURIComponent(id)}/sync`, {}),
toggleGitRepo: (id) => postJSON(`/api/agents/git-repos/${encodeURIComponent(id)}/toggle`, {}),
deleteGitRepo: (id) => fetchJSON(`/api/agents/git-repos/${encodeURIComponent(id)}`, { method: 'DELETE' }),
}
// File to base64 helper
export function fileToBase64(file) {
return new Promise((resolve, reject) => {

View File

@@ -0,0 +1,73 @@
package routes
import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/http/endpoints/localai"
)
func RegisterAgentPoolRoutes(e *echo.Echo, app *application.Application) {
if app.AgentPoolService() == nil {
return
}
// Agent Management
e.GET("/api/agents", localai.ListAgentsEndpoint(app))
e.POST("/api/agents", localai.CreateAgentEndpoint(app))
e.GET("/api/agents/config/metadata", localai.GetAgentConfigMetaEndpoint(app))
e.POST("/api/agents/import", localai.ImportAgentEndpoint(app))
e.GET("/api/agents/:name", localai.GetAgentEndpoint(app))
e.PUT("/api/agents/:name", localai.UpdateAgentEndpoint(app))
e.DELETE("/api/agents/:name", localai.DeleteAgentEndpoint(app))
e.GET("/api/agents/:name/config", localai.GetAgentConfigEndpoint(app))
e.PUT("/api/agents/:name/pause", localai.PauseAgentEndpoint(app))
e.PUT("/api/agents/:name/resume", localai.ResumeAgentEndpoint(app))
e.GET("/api/agents/:name/status", localai.GetAgentStatusEndpoint(app))
e.GET("/api/agents/:name/observables", localai.GetAgentObservablesEndpoint(app))
e.DELETE("/api/agents/:name/observables", localai.ClearAgentObservablesEndpoint(app))
e.POST("/api/agents/:name/chat", localai.ChatWithAgentEndpoint(app))
e.GET("/api/agents/:name/sse", localai.AgentSSEEndpoint(app))
e.GET("/api/agents/:name/export", localai.ExportAgentEndpoint(app))
// Actions
e.GET("/api/agents/actions", localai.ListActionsEndpoint(app))
e.POST("/api/agents/actions/:name/definition", localai.GetActionDefinitionEndpoint(app))
e.POST("/api/agents/actions/:name/run", localai.ExecuteActionEndpoint(app))
// Skills
e.GET("/api/agents/skills", localai.ListSkillsEndpoint(app))
e.GET("/api/agents/skills/config", localai.GetSkillsConfigEndpoint(app))
e.GET("/api/agents/skills/search", localai.SearchSkillsEndpoint(app))
e.POST("/api/agents/skills", localai.CreateSkillEndpoint(app))
e.GET("/api/agents/skills/export/*", localai.ExportSkillEndpoint(app))
e.POST("/api/agents/skills/import", localai.ImportSkillEndpoint(app))
e.GET("/api/agents/skills/:name", localai.GetSkillEndpoint(app))
e.PUT("/api/agents/skills/:name", localai.UpdateSkillEndpoint(app))
e.DELETE("/api/agents/skills/:name", localai.DeleteSkillEndpoint(app))
e.GET("/api/agents/skills/:name/resources", localai.ListSkillResourcesEndpoint(app))
e.GET("/api/agents/skills/:name/resources/*", localai.GetSkillResourceEndpoint(app))
e.POST("/api/agents/skills/:name/resources", localai.CreateSkillResourceEndpoint(app))
e.PUT("/api/agents/skills/:name/resources/*", localai.UpdateSkillResourceEndpoint(app))
e.DELETE("/api/agents/skills/:name/resources/*", localai.DeleteSkillResourceEndpoint(app))
// Git Repos
e.GET("/api/agents/git-repos", localai.ListGitReposEndpoint(app))
e.POST("/api/agents/git-repos", localai.AddGitRepoEndpoint(app))
e.PUT("/api/agents/git-repos/:id", localai.UpdateGitRepoEndpoint(app))
e.DELETE("/api/agents/git-repos/:id", localai.DeleteGitRepoEndpoint(app))
e.POST("/api/agents/git-repos/:id/sync", localai.SyncGitRepoEndpoint(app))
e.POST("/api/agents/git-repos/:id/toggle", localai.ToggleGitRepoEndpoint(app))
// Collections / Knowledge Base
e.GET("/api/agents/collections", localai.ListCollectionsEndpoint(app))
e.POST("/api/agents/collections", localai.CreateCollectionEndpoint(app))
e.POST("/api/agents/collections/:name/upload", localai.UploadToCollectionEndpoint(app))
e.GET("/api/agents/collections/:name/entries", localai.ListCollectionEntriesEndpoint(app))
e.GET("/api/agents/collections/:name/entries/*", localai.GetCollectionEntryContentEndpoint(app))
e.POST("/api/agents/collections/:name/search", localai.SearchCollectionEndpoint(app))
e.POST("/api/agents/collections/:name/reset", localai.ResetCollectionEndpoint(app))
e.DELETE("/api/agents/collections/:name/entry/delete", localai.DeleteCollectionEntryEndpoint(app))
e.POST("/api/agents/collections/:name/sources", localai.AddCollectionSourceEndpoint(app))
e.DELETE("/api/agents/collections/:name/sources", localai.RemoveCollectionSourceEndpoint(app))
e.GET("/api/agents/collections/:name/sources", localai.ListCollectionSourcesEndpoint(app))
}

View File

@@ -129,6 +129,13 @@ func RegisterLocalAIRoutes(router *echo.Echo,
}{Version: internal.PrintableVersion()})
})
router.GET("/api/features", func(c echo.Context) error {
return c.JSON(200, map[string]bool{
"agents": app.AgentPoolService() != nil,
"mcp": !appConfig.DisableMCP,
})
})
router.GET("/system", localai.SystemInformations(ml, appConfig))
// misc
@@ -159,8 +166,8 @@ func RegisterLocalAIRoutes(router *echo.Echo,
router.POST("/mcp/chat/completions", mcpStreamHandler, mcpStreamMiddleware...)
}
// Agent job routes
if app != nil && app.AgentJobService() != nil {
// Agent job routes (MCP CI Jobs — requires MCP to be enabled)
if app != nil && app.AgentJobService() != nil && !appConfig.DisableMCP {
router.POST("/api/agent/tasks", localai.CreateTaskEndpoint(app))
router.PUT("/api/agent/tasks/:id", localai.UpdateTaskEndpoint(app))
router.DELETE("/api/agent/tasks/:id", localai.DeleteTaskEndpoint(app))

View File

@@ -4,6 +4,7 @@ import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
localai "github.com/mudler/LocalAI/core/http/endpoints/localai"
"github.com/mudler/LocalAI/core/http/endpoints/openresponses"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
@@ -22,6 +23,9 @@ func RegisterOpenResponsesRoutes(app *echo.Echo,
)
responsesMiddleware := []echo.MiddlewareFunc{
// Intercept requests where the model name matches an agent — route directly
// to the agent pool without going through the model config resolution pipeline.
localai.AgentResponsesInterceptor(application),
middleware.TraceMiddleware(application),
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenResponsesRequest) }),

View File

@@ -27,6 +27,7 @@ import (
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/xsync"
"github.com/mudler/cogito"
"github.com/mudler/cogito/clients"
"github.com/mudler/xlog"
"github.com/robfig/cron/v3"
)
@@ -806,7 +807,7 @@ func (s *AgentJobService) executeJobInternal(job schema.Job, task schema.Task, c
}
// Create LLM client
defaultLLM := cogito.NewOpenAILLM(modelConfig.Name, apiKey, "http://127.0.0.1:"+port)
defaultLLM := clients.NewLocalAILLM(modelConfig.Name, apiKey, "http://127.0.0.1:"+port)
// Initialize traces slice
job.Traces = []schema.JobTrace{}

983
core/services/agent_pool.go Normal file
View File

@@ -0,0 +1,983 @@
package services
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/sse"
"github.com/mudler/LocalAGI/core/state"
coreTypes "github.com/mudler/LocalAGI/core/types"
agiServices "github.com/mudler/LocalAGI/services"
"github.com/mudler/LocalAGI/services/skills"
"github.com/mudler/LocalAGI/webui/collections"
"github.com/mudler/xlog"
skilldomain "github.com/mudler/skillserver/pkg/domain"
skillgit "github.com/mudler/skillserver/pkg/git"
)
// AgentPoolService wraps LocalAGI's AgentPool, Skills service, and collections backend
// to provide agentic capabilities integrated directly into LocalAI.
type AgentPoolService struct {
appConfig *config.ApplicationConfig
pool *state.AgentPool
skillsService *skills.Service
collectionsBackend collections.Backend
configMeta state.AgentConfigMeta
actionsConfig map[string]string
sharedState *coreTypes.AgentSharedState
mu sync.Mutex
}
func NewAgentPoolService(appConfig *config.ApplicationConfig) (*AgentPoolService, error) {
return &AgentPoolService{
appConfig: appConfig,
}, nil
}
func (s *AgentPoolService) Start(ctx context.Context) error {
cfg := s.appConfig.AgentPool
// API URL: use configured value, or derive self-referencing URL from LocalAI's address
apiURL := cfg.APIURL
if apiURL == "" {
_, port, err := net.SplitHostPort(s.appConfig.APIAddress)
if err != nil {
port = strings.TrimPrefix(s.appConfig.APIAddress, ":")
}
apiURL = "http://127.0.0.1:" + port
}
apiKey := cfg.APIKey
if apiKey == "" && len(s.appConfig.ApiKeys) > 0 {
apiKey = s.appConfig.ApiKeys[0]
}
// State dir defaults to DynamicConfigsDir (LocalAI configuration folder)
stateDir := cfg.StateDir
if stateDir == "" {
stateDir = s.appConfig.DynamicConfigsDir
}
if stateDir == "" {
stateDir = "agents"
}
if err := os.MkdirAll(stateDir, 0750); err != nil {
return fmt.Errorf("failed to create agent pool state dir: %w", err)
}
// Collections paths
collectionDBPath := cfg.CollectionDBPath
if collectionDBPath == "" {
collectionDBPath = filepath.Join(stateDir, "collections")
}
fileAssets := filepath.Join(stateDir, "assets")
// Skills service — always created since the agent pool calls GetSkillsPrompt unconditionally.
// When EnableSkills is false, the service still exists but the skills directory will be empty.
skillsSvc, err := skills.NewService(stateDir)
if err != nil {
xlog.Error("Failed to create skills service", "error", err)
}
s.skillsService = skillsSvc
// Actions config map — only set CustomActionsDir if non-empty to avoid
// "open : no such file or directory" errors
actionsConfig := map[string]string{
agiServices.ConfigStateDir: stateDir,
}
if cfg.CustomActionsDir != "" {
actionsConfig[agiServices.CustomActionsDir] = cfg.CustomActionsDir
}
s.actionsConfig = actionsConfig
s.sharedState = coreTypes.NewAgentSharedState(5 * time.Minute)
// Create the agent pool
pool, err := state.NewAgentPool(
cfg.DefaultModel,
cfg.MultimodalModel,
cfg.TranscriptionModel,
cfg.TranscriptionLanguage,
cfg.TTSModel,
apiURL,
apiKey,
stateDir,
agiServices.Actions(actionsConfig),
agiServices.Connectors,
agiServices.DynamicPrompts(actionsConfig),
agiServices.Filters,
cfg.Timeout,
cfg.EnableLogs,
skillsSvc,
)
if err != nil {
return fmt.Errorf("failed to create agent pool: %w", err)
}
s.pool = pool
// Create in-process collections backend and RAG provider directly
collectionsCfg := &collections.Config{
LLMAPIURL: apiURL,
LLMAPIKey: apiKey,
LLMModel: cfg.DefaultModel,
CollectionDBPath: collectionDBPath,
FileAssets: fileAssets,
VectorEngine: cfg.VectorEngine,
EmbeddingModel: cfg.EmbeddingModel,
MaxChunkingSize: cfg.MaxChunkingSize,
ChunkOverlap: cfg.ChunkOverlap,
DatabaseURL: cfg.DatabaseURL,
}
collectionsBackend, collectionsState := collections.NewInProcessBackend(collectionsCfg)
s.collectionsBackend = collectionsBackend
// Set up in-process RAG provider from collections state
embedded := collections.RAGProviderFromState(collectionsState)
pool.SetRAGProvider(func(collectionName, _, _ string) (agent.RAGDB, state.KBCompactionClient, bool) {
return embedded(collectionName)
})
// Build config metadata for UI
s.configMeta = state.NewAgentConfigMeta(
agiServices.ActionsConfigMeta(cfg.CustomActionsDir),
agiServices.ConnectorsConfigMeta(),
agiServices.DynamicPromptsConfigMeta(cfg.CustomActionsDir),
agiServices.FiltersConfigMeta(),
)
// Start all agents
if err := pool.StartAll(); err != nil {
xlog.Error("Failed to start agent pool", "error", err)
}
xlog.Info("Agent pool started", "stateDir", stateDir, "apiURL", apiURL)
return nil
}
func (s *AgentPoolService) Stop() {
if s.pool != nil {
s.pool.StopAll()
}
}
// Pool returns the underlying AgentPool.
func (s *AgentPoolService) Pool() *state.AgentPool {
return s.pool
}
// --- Agent CRUD ---
func (s *AgentPoolService) ListAgents() map[string]bool {
statuses := map[string]bool{}
agents := s.pool.List()
for _, a := range agents {
ag := s.pool.GetAgent(a)
if ag == nil {
continue
}
statuses[a] = !ag.Paused()
}
return statuses
}
func (s *AgentPoolService) CreateAgent(config *state.AgentConfig) error {
if config.Name == "" {
return fmt.Errorf("name is required")
}
return s.pool.CreateAgent(config.Name, config)
}
func (s *AgentPoolService) GetAgent(name string) *agent.Agent {
return s.pool.GetAgent(name)
}
func (s *AgentPoolService) GetAgentConfig(name string) *state.AgentConfig {
return s.pool.GetConfig(name)
}
func (s *AgentPoolService) UpdateAgent(name string, config *state.AgentConfig) error {
old := s.pool.GetConfig(name)
if old == nil {
return fmt.Errorf("agent not found: %s", name)
}
return s.pool.RecreateAgent(name, config)
}
func (s *AgentPoolService) DeleteAgent(name string) error {
return s.pool.Remove(name)
}
func (s *AgentPoolService) PauseAgent(name string) error {
ag := s.pool.GetAgent(name)
if ag == nil {
return fmt.Errorf("agent not found: %s", name)
}
ag.Pause()
return nil
}
func (s *AgentPoolService) ResumeAgent(name string) error {
ag := s.pool.GetAgent(name)
if ag == nil {
return fmt.Errorf("agent not found: %s", name)
}
ag.Resume()
return nil
}
func (s *AgentPoolService) GetAgentStatus(name string) *state.Status {
return s.pool.GetStatusHistory(name)
}
func (s *AgentPoolService) GetAgentObservables(name string) ([]coreTypes.Observable, error) {
ag := s.pool.GetAgent(name)
if ag == nil {
return nil, fmt.Errorf("agent not found: %s", name)
}
return ag.Observer().History(), nil
}
func (s *AgentPoolService) ClearAgentObservables(name string) error {
ag := s.pool.GetAgent(name)
if ag == nil {
return fmt.Errorf("agent not found: %s", name)
}
ag.Observer().ClearHistory()
return nil
}
// Chat sends a message to an agent and returns immediately. Responses come via SSE.
func (s *AgentPoolService) Chat(name, message string) (string, error) {
ag := s.pool.GetAgent(name)
if ag == nil {
return "", fmt.Errorf("agent not found: %s", name)
}
manager := s.pool.GetManager(name)
if manager == nil {
return "", fmt.Errorf("SSE manager not found for agent: %s", name)
}
messageID := fmt.Sprintf("%d", time.Now().UnixNano())
// Send user message via SSE
userMsg, _ := json.Marshal(map[string]any{
"id": messageID + "-user",
"sender": "user",
"content": message,
"timestamp": time.Now().Format(time.RFC3339),
})
manager.Send(sse.NewMessage(string(userMsg)).WithEvent("json_message"))
// Send processing status
statusMsg, _ := json.Marshal(map[string]any{
"status": "processing",
"timestamp": time.Now().Format(time.RFC3339),
})
manager.Send(sse.NewMessage(string(statusMsg)).WithEvent("json_message_status"))
// Process asynchronously
go func() {
response := ag.Ask(coreTypes.WithText(message))
if response == nil {
errMsg, _ := json.Marshal(map[string]any{
"error": "agent request failed or was cancelled",
"timestamp": time.Now().Format(time.RFC3339),
})
manager.Send(sse.NewMessage(string(errMsg)).WithEvent("json_error"))
} else if response.Error != nil {
errMsg, _ := json.Marshal(map[string]any{
"error": response.Error.Error(),
"timestamp": time.Now().Format(time.RFC3339),
})
manager.Send(sse.NewMessage(string(errMsg)).WithEvent("json_error"))
} else {
respMsg, _ := json.Marshal(map[string]any{
"id": messageID + "-agent",
"sender": "agent",
"content": response.Response,
"timestamp": time.Now().Format(time.RFC3339),
})
manager.Send(sse.NewMessage(string(respMsg)).WithEvent("json_message"))
}
completedMsg, _ := json.Marshal(map[string]any{
"status": "completed",
"timestamp": time.Now().Format(time.RFC3339),
})
manager.Send(sse.NewMessage(string(completedMsg)).WithEvent("json_message_status"))
}()
return messageID, nil
}
func (s *AgentPoolService) GetSSEManager(name string) sse.Manager {
return s.pool.GetManager(name)
}
func (s *AgentPoolService) GetConfigMeta() state.AgentConfigMeta {
return s.configMeta
}
func (s *AgentPoolService) AgentHubURL() string {
return s.appConfig.AgentPool.AgentHubURL
}
// ExportAgent returns the agent config as JSON bytes.
func (s *AgentPoolService) ExportAgent(name string) ([]byte, error) {
cfg := s.pool.GetConfig(name)
if cfg == nil {
return nil, fmt.Errorf("agent not found: %s", name)
}
return json.MarshalIndent(cfg, "", " ")
}
// ImportAgent creates an agent from JSON config data.
func (s *AgentPoolService) ImportAgent(data []byte) error {
var cfg state.AgentConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("invalid agent config: %w", err)
}
if cfg.Name == "" {
return fmt.Errorf("agent name is required")
}
return s.pool.CreateAgent(cfg.Name, &cfg)
}
// --- Skills ---
func (s *AgentPoolService) SkillsService() *skills.Service {
return s.skillsService
}
func (s *AgentPoolService) GetSkillsConfig() map[string]any {
if s.skillsService == nil {
return nil
}
return map[string]any{"skills_dir": s.skillsService.GetSkillsDir()}
}
func (s *AgentPoolService) ListSkills() ([]skilldomain.Skill, error) {
if s.skillsService == nil {
return nil, fmt.Errorf("skills service not available")
}
mgr, err := s.skillsService.GetManager()
if err != nil || mgr == nil {
if mgr == nil {
return []skilldomain.Skill{}, nil
}
return nil, err
}
return mgr.ListSkills()
}
func (s *AgentPoolService) GetSkill(name string) (*skilldomain.Skill, error) {
if s.skillsService == nil {
return nil, fmt.Errorf("skills service not available")
}
mgr, err := s.skillsService.GetManager()
if err != nil || mgr == nil {
return nil, fmt.Errorf("skills directory not configured")
}
return mgr.ReadSkill(name)
}
func (s *AgentPoolService) SearchSkills(query string) ([]skilldomain.Skill, error) {
if s.skillsService == nil {
return nil, fmt.Errorf("skills service not available")
}
mgr, err := s.skillsService.GetManager()
if err != nil || mgr == nil {
return nil, fmt.Errorf("skills directory not configured")
}
return mgr.SearchSkills(query)
}
func (s *AgentPoolService) CreateSkill(name, description, content, license, compatibility, allowedTools string, metadata map[string]string) (*skilldomain.Skill, error) {
if s.skillsService == nil {
return nil, fmt.Errorf("skills service not available")
}
mgr, err := s.skillsService.GetManager()
if err != nil || mgr == nil {
return nil, fmt.Errorf("skills directory not configured")
}
fsManager, ok := mgr.(*skilldomain.FileSystemManager)
if !ok {
return nil, fmt.Errorf("unsupported manager type")
}
if err := skilldomain.ValidateSkillName(name); err != nil {
return nil, err
}
skillsDir := fsManager.GetSkillsDir()
skillDir := filepath.Join(skillsDir, name)
if _, err := os.Stat(skillDir); err == nil {
return nil, fmt.Errorf("skill already exists")
}
if err := os.MkdirAll(skillDir, 0755); err != nil {
return nil, err
}
frontmatter := fmt.Sprintf("---\nname: %s\ndescription: %s\n", name, description)
if license != "" {
frontmatter += fmt.Sprintf("license: %s\n", license)
}
if compatibility != "" {
frontmatter += fmt.Sprintf("compatibility: %s\n", compatibility)
}
if len(metadata) > 0 {
frontmatter += "metadata:\n"
for k, v := range metadata {
frontmatter += fmt.Sprintf(" %s: %s\n", k, v)
}
}
if allowedTools != "" {
frontmatter += fmt.Sprintf("allowed-tools: %s\n", allowedTools)
}
frontmatter += "---\n\n"
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(frontmatter+content), 0644); err != nil {
os.RemoveAll(skillDir)
return nil, err
}
if err := mgr.RebuildIndex(); err != nil {
return nil, fmt.Errorf("failed to rebuild index: %w", err)
}
return mgr.ReadSkill(name)
}
func (s *AgentPoolService) UpdateSkill(name, description, content, license, compatibility, allowedTools string, metadata map[string]string) (*skilldomain.Skill, error) {
if s.skillsService == nil {
return nil, fmt.Errorf("skills service not available")
}
mgr, err := s.skillsService.GetManager()
if err != nil || mgr == nil {
return nil, fmt.Errorf("skills directory not configured")
}
fsManager, ok := mgr.(*skilldomain.FileSystemManager)
if !ok {
return nil, fmt.Errorf("unsupported manager type")
}
existing, err := mgr.ReadSkill(name)
if err != nil {
return nil, fmt.Errorf("skill not found")
}
if existing.ReadOnly {
return nil, fmt.Errorf("cannot update read-only skill from git repository")
}
skillDir := filepath.Join(fsManager.GetSkillsDir(), name)
frontmatter := fmt.Sprintf("---\nname: %s\ndescription: %s\n", name, description)
if license != "" {
frontmatter += fmt.Sprintf("license: %s\n", license)
}
if compatibility != "" {
frontmatter += fmt.Sprintf("compatibility: %s\n", compatibility)
}
if len(metadata) > 0 {
frontmatter += "metadata:\n"
for k, v := range metadata {
frontmatter += fmt.Sprintf(" %s: %s\n", k, v)
}
}
if allowedTools != "" {
frontmatter += fmt.Sprintf("allowed-tools: %s\n", allowedTools)
}
frontmatter += "---\n\n"
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(frontmatter+content), 0644); err != nil {
return nil, err
}
if err := mgr.RebuildIndex(); err != nil {
return nil, fmt.Errorf("failed to rebuild index: %w", err)
}
return mgr.ReadSkill(name)
}
func (s *AgentPoolService) DeleteSkill(name string) error {
if s.skillsService == nil {
return fmt.Errorf("skills service not available")
}
mgr, err := s.skillsService.GetManager()
if err != nil || mgr == nil {
return fmt.Errorf("skills directory not configured")
}
fsManager, ok := mgr.(*skilldomain.FileSystemManager)
if !ok {
return fmt.Errorf("unsupported manager type")
}
existing, err := mgr.ReadSkill(name)
if err != nil {
return fmt.Errorf("skill not found")
}
if existing.ReadOnly {
return fmt.Errorf("cannot delete read-only skill from git repository")
}
skillDir := filepath.Join(fsManager.GetSkillsDir(), name)
if err := os.RemoveAll(skillDir); err != nil {
return err
}
return mgr.RebuildIndex()
}
func (s *AgentPoolService) ExportSkill(name string) ([]byte, error) {
if s.skillsService == nil {
return nil, fmt.Errorf("skills service not available")
}
mgr, err := s.skillsService.GetManager()
if err != nil || mgr == nil {
return nil, fmt.Errorf("skills directory not configured")
}
fsManager, ok := mgr.(*skilldomain.FileSystemManager)
if !ok {
return nil, fmt.Errorf("unsupported manager type")
}
skill, err := mgr.ReadSkill(name)
if err != nil {
return nil, fmt.Errorf("skill not found")
}
return skilldomain.ExportSkill(skill.ID, fsManager.GetSkillsDir())
}
func (s *AgentPoolService) ImportSkill(archiveData []byte) (*skilldomain.Skill, error) {
if s.skillsService == nil {
return nil, fmt.Errorf("skills service not available")
}
mgr, err := s.skillsService.GetManager()
if err != nil || mgr == nil {
return nil, fmt.Errorf("skills directory not configured")
}
fsManager, ok := mgr.(*skilldomain.FileSystemManager)
if !ok {
return nil, fmt.Errorf("unsupported manager type")
}
skillName, err := skilldomain.ImportSkill(archiveData, fsManager.GetSkillsDir())
if err != nil {
return nil, err
}
if err := mgr.RebuildIndex(); err != nil {
return nil, fmt.Errorf("failed to rebuild index: %w", err)
}
return mgr.ReadSkill(skillName)
}
// --- Skill Resources ---
func (s *AgentPoolService) ListSkillResources(skillName string) ([]skilldomain.SkillResource, *skilldomain.Skill, error) {
if s.skillsService == nil {
return nil, nil, fmt.Errorf("skills service not available")
}
mgr, err := s.skillsService.GetManager()
if err != nil || mgr == nil {
return nil, nil, fmt.Errorf("skills directory not configured")
}
skill, err := mgr.ReadSkill(skillName)
if err != nil {
return nil, nil, fmt.Errorf("skill not found")
}
resources, err := mgr.ListSkillResources(skill.ID)
if err != nil {
return nil, nil, err
}
return resources, skill, nil
}
func (s *AgentPoolService) GetSkillResource(skillName, resourcePath string) (*skilldomain.ResourceContent, *skilldomain.SkillResource, error) {
if s.skillsService == nil {
return nil, nil, fmt.Errorf("skills service not available")
}
mgr, err := s.skillsService.GetManager()
if err != nil || mgr == nil {
return nil, nil, fmt.Errorf("skills directory not configured")
}
skill, err := mgr.ReadSkill(skillName)
if err != nil {
return nil, nil, fmt.Errorf("skill not found")
}
info, err := mgr.GetSkillResourceInfo(skill.ID, resourcePath)
if err != nil {
return nil, nil, fmt.Errorf("resource not found")
}
content, err := mgr.ReadSkillResource(skill.ID, resourcePath)
if err != nil {
return nil, nil, err
}
return content, info, nil
}
func (s *AgentPoolService) CreateSkillResource(skillName, path string, data []byte) error {
if s.skillsService == nil {
return fmt.Errorf("skills service not available")
}
mgr, err := s.skillsService.GetManager()
if err != nil || mgr == nil {
return fmt.Errorf("skills directory not configured")
}
skill, err := mgr.ReadSkill(skillName)
if err != nil {
return fmt.Errorf("skill not found")
}
if skill.ReadOnly {
return fmt.Errorf("cannot add resources to read-only skill")
}
if err := skilldomain.ValidateResourcePath(path); err != nil {
return err
}
fullPath := filepath.Join(skill.SourcePath, path)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return err
}
return os.WriteFile(fullPath, data, 0644)
}
func (s *AgentPoolService) UpdateSkillResource(skillName, resourcePath, content string) error {
if s.skillsService == nil {
return fmt.Errorf("skills service not available")
}
mgr, err := s.skillsService.GetManager()
if err != nil || mgr == nil {
return fmt.Errorf("skills directory not configured")
}
skill, err := mgr.ReadSkill(skillName)
if err != nil {
return fmt.Errorf("skill not found")
}
if skill.ReadOnly {
return fmt.Errorf("cannot update resources in read-only skill")
}
if err := skilldomain.ValidateResourcePath(resourcePath); err != nil {
return err
}
fullPath := filepath.Join(skill.SourcePath, resourcePath)
return os.WriteFile(fullPath, []byte(content), 0644)
}
func (s *AgentPoolService) DeleteSkillResource(skillName, resourcePath string) error {
if s.skillsService == nil {
return fmt.Errorf("skills service not available")
}
mgr, err := s.skillsService.GetManager()
if err != nil || mgr == nil {
return fmt.Errorf("skills directory not configured")
}
skill, err := mgr.ReadSkill(skillName)
if err != nil {
return fmt.Errorf("skill not found")
}
if skill.ReadOnly {
return fmt.Errorf("cannot delete resources from read-only skill")
}
if err := skilldomain.ValidateResourcePath(resourcePath); err != nil {
return err
}
fullPath := filepath.Join(skill.SourcePath, resourcePath)
return os.Remove(fullPath)
}
// --- Git Repos ---
func (s *AgentPoolService) getSkillsDir() string {
if s.skillsService == nil {
return ""
}
return s.skillsService.GetSkillsDir()
}
type GitRepoInfo struct {
ID string `json:"id"`
URL string `json:"url"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
}
func (s *AgentPoolService) ListGitRepos() ([]GitRepoInfo, error) {
dir := s.getSkillsDir()
if dir == "" {
return []GitRepoInfo{}, nil
}
cm := skillgit.NewConfigManager(dir)
repos, err := cm.LoadConfig()
if err != nil {
return nil, err
}
out := make([]GitRepoInfo, len(repos))
for i, r := range repos {
out[i] = GitRepoInfo{ID: r.ID, URL: r.URL, Name: r.Name, Enabled: r.Enabled}
}
return out, nil
}
func (s *AgentPoolService) AddGitRepo(repoURL string) (*GitRepoInfo, error) {
dir := s.getSkillsDir()
if dir == "" {
return nil, fmt.Errorf("skills directory not configured")
}
if !strings.HasPrefix(repoURL, "http://") && !strings.HasPrefix(repoURL, "https://") && !strings.HasPrefix(repoURL, "git@") {
return nil, fmt.Errorf("invalid URL format")
}
cm := skillgit.NewConfigManager(dir)
repos, err := cm.LoadConfig()
if err != nil {
return nil, err
}
for _, r := range repos {
if r.URL == repoURL {
return nil, fmt.Errorf("repository already exists")
}
}
newRepo := skillgit.GitRepoConfig{
ID: skillgit.GenerateID(repoURL),
URL: repoURL,
Name: skillgit.ExtractRepoName(repoURL),
Enabled: true,
}
repos = append(repos, newRepo)
if err := cm.SaveConfig(repos); err != nil {
return nil, err
}
// Background sync
go func() {
mgr, err := s.skillsService.GetManager()
if err != nil || mgr == nil {
return
}
syncer := skillgit.NewGitSyncer(dir, []string{repoURL}, mgr.RebuildIndex)
if err := syncer.Start(); err != nil {
xlog.Error("background sync failed", "url", repoURL, "error", err)
s.skillsService.RefreshManagerFromConfig()
return
}
syncer.Stop()
s.skillsService.RefreshManagerFromConfig()
}()
return &GitRepoInfo{ID: newRepo.ID, URL: newRepo.URL, Name: newRepo.Name, Enabled: newRepo.Enabled}, nil
}
func (s *AgentPoolService) UpdateGitRepo(id, repoURL string, enabled *bool) (*GitRepoInfo, error) {
dir := s.getSkillsDir()
if dir == "" {
return nil, fmt.Errorf("skills directory not configured")
}
cm := skillgit.NewConfigManager(dir)
repos, err := cm.LoadConfig()
if err != nil {
return nil, err
}
idx := -1
for i, r := range repos {
if r.ID == id {
idx = i
if repoURL != "" {
parsedURL, err := url.Parse(repoURL)
if err != nil || parsedURL.Scheme == "" {
return nil, fmt.Errorf("invalid repository URL")
}
repos[i].URL = repoURL
repos[i].Name = skillgit.ExtractRepoName(repoURL)
}
if enabled != nil {
repos[i].Enabled = *enabled
}
break
}
}
if idx < 0 {
return nil, fmt.Errorf("repository not found")
}
if err := cm.SaveConfig(repos); err != nil {
return nil, err
}
s.skillsService.RefreshManagerFromConfig()
r := repos[idx]
return &GitRepoInfo{ID: r.ID, URL: r.URL, Name: r.Name, Enabled: r.Enabled}, nil
}
func (s *AgentPoolService) DeleteGitRepo(id string) error {
dir := s.getSkillsDir()
if dir == "" {
return fmt.Errorf("skills directory not configured")
}
cm := skillgit.NewConfigManager(dir)
repos, err := cm.LoadConfig()
if err != nil {
return err
}
var newRepos []skillgit.GitRepoConfig
var repoName string
for _, r := range repos {
if r.ID == id {
repoName = r.Name
} else {
newRepos = append(newRepos, r)
}
}
if len(newRepos) == len(repos) {
return fmt.Errorf("repository not found")
}
if err := cm.SaveConfig(newRepos); err != nil {
return err
}
if repoName != "" {
os.RemoveAll(filepath.Join(dir, repoName))
}
s.skillsService.RefreshManagerFromConfig()
return nil
}
func (s *AgentPoolService) SyncGitRepo(id string) error {
dir := s.getSkillsDir()
if dir == "" {
return fmt.Errorf("skills directory not configured")
}
cm := skillgit.NewConfigManager(dir)
repos, err := cm.LoadConfig()
if err != nil {
return err
}
var repoURL string
for _, r := range repos {
if r.ID == id {
repoURL = r.URL
break
}
}
if repoURL == "" {
return fmt.Errorf("repository not found")
}
mgr, err := s.skillsService.GetManager()
if err != nil || mgr == nil {
return fmt.Errorf("manager not ready")
}
go func() {
syncer := skillgit.NewGitSyncer(dir, []string{repoURL}, mgr.RebuildIndex)
if err := syncer.Start(); err != nil {
xlog.Error("background sync failed", "id", id, "error", err)
s.skillsService.RefreshManagerFromConfig()
return
}
syncer.Stop()
s.skillsService.RefreshManagerFromConfig()
}()
return nil
}
func (s *AgentPoolService) ToggleGitRepo(id string) (*GitRepoInfo, error) {
dir := s.getSkillsDir()
if dir == "" {
return nil, fmt.Errorf("skills directory not configured")
}
cm := skillgit.NewConfigManager(dir)
repos, err := cm.LoadConfig()
if err != nil {
return nil, err
}
for i, r := range repos {
if r.ID == id {
repos[i].Enabled = !repos[i].Enabled
if err := cm.SaveConfig(repos); err != nil {
return nil, err
}
s.skillsService.RefreshManagerFromConfig()
return &GitRepoInfo{ID: repos[i].ID, URL: repos[i].URL, Name: repos[i].Name, Enabled: repos[i].Enabled}, nil
}
}
return nil, fmt.Errorf("repository not found")
}
// --- Collections ---
func (s *AgentPoolService) CollectionsBackend() collections.Backend {
return s.collectionsBackend
}
func (s *AgentPoolService) ListCollections() ([]string, error) {
return s.collectionsBackend.ListCollections()
}
func (s *AgentPoolService) CreateCollection(name string) error {
return s.collectionsBackend.CreateCollection(name)
}
func (s *AgentPoolService) UploadToCollection(collection, filename string, fileBody io.Reader) error {
return s.collectionsBackend.Upload(collection, filename, fileBody)
}
func (s *AgentPoolService) ListCollectionEntries(collection string) ([]string, error) {
return s.collectionsBackend.ListEntries(collection)
}
func (s *AgentPoolService) GetCollectionEntryContent(collection, entry string) (string, int, error) {
return s.collectionsBackend.GetEntryContent(collection, entry)
}
func (s *AgentPoolService) SearchCollection(collection, query string, maxResults int) ([]collections.SearchResult, error) {
return s.collectionsBackend.Search(collection, query, maxResults)
}
func (s *AgentPoolService) ResetCollection(collection string) error {
return s.collectionsBackend.Reset(collection)
}
func (s *AgentPoolService) DeleteCollectionEntry(collection, entry string) ([]string, error) {
return s.collectionsBackend.DeleteEntry(collection, entry)
}
func (s *AgentPoolService) AddCollectionSource(collection, sourceURL string, intervalMin int) error {
return s.collectionsBackend.AddSource(collection, sourceURL, intervalMin)
}
func (s *AgentPoolService) RemoveCollectionSource(collection, sourceURL string) error {
return s.collectionsBackend.RemoveSource(collection, sourceURL)
}
func (s *AgentPoolService) ListCollectionSources(collection string) ([]collections.SourceInfo, error) {
return s.collectionsBackend.ListSources(collection)
}
func (s *AgentPoolService) CollectionEntryExists(collection, entry string) bool {
return s.collectionsBackend.EntryExists(collection, entry)
}
// --- Actions ---
// ListAvailableActions returns the list of all available action type names.
func (s *AgentPoolService) ListAvailableActions() []string {
return agiServices.AvailableActions
}
// GetActionDefinition creates an action instance by name with the given config and returns its definition.
func (s *AgentPoolService) GetActionDefinition(actionName string, actionConfig map[string]string) (any, error) {
if actionConfig == nil {
actionConfig = map[string]string{}
}
a, err := agiServices.Action(actionName, "", actionConfig, s.pool, s.actionsConfig)
if err != nil {
return nil, err
}
return a.Definition(), nil
}
// ExecuteAction creates an action instance and runs it with the given params.
func (s *AgentPoolService) ExecuteAction(ctx context.Context, actionName string, actionConfig map[string]string, params coreTypes.ActionParams) (coreTypes.ActionResult, error) {
if actionConfig == nil {
actionConfig = map[string]string{}
}
a, err := agiServices.Action(actionName, "", actionConfig, s.pool, s.actionsConfig)
if err != nil {
return coreTypes.ActionResult{}, err
}
return a.Run(ctx, s.sharedState, params)
}

View File

@@ -0,0 +1,53 @@
package services
import (
"fmt"
"math/rand"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAGI/core/sse"
)
// HandleSSE bridges a LocalAGI SSE Manager to an Echo HTTP response.
// It registers a client with the manager, streams events, and cleans up on disconnect.
func HandleSSE(c echo.Context, manager sse.Manager) error {
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
c.Response().WriteHeader(200)
c.Response().Flush()
client := sse.NewClient(randString(10))
manager.Register(client)
defer func() {
manager.Unregister(client.ID())
}()
ch := client.Chan()
done := c.Request().Context().Done()
for {
select {
case <-done:
return nil
case msg, ok := <-ch:
if !ok {
return nil
}
if _, err := fmt.Fprint(c.Response(), msg.String()); err != nil {
return nil
}
c.Response().Flush()
}
}
}
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func randString(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
}
return string(b)
}

View File

@@ -19,6 +19,15 @@ services:
environment:
- MODELS_PATH=/models
# - DEBUG=true
## Agents (LocalAGI) - https://localai.io/features/agents/
# - LOCALAI_DISABLE_AGENTS=false
# - LOCALAI_AGENT_POOL_DEFAULT_MODEL=hermes-3-llama3.1-8b
# - LOCALAI_AGENT_POOL_ENABLE_SKILLS=true
# - LOCALAI_AGENT_POOL_ENABLE_LOGS=true
# - LOCALAI_AGENT_HUB_URL=https://agenthub.localai.io
## Uncomment to use PostgreSQL for the knowledge base (requires the postgres service below)
# - LOCALAI_AGENT_POOL_VECTOR_ENGINE=postgres
# - LOCALAI_AGENT_POOL_DATABASE_URL=postgresql://localrecall:localrecall@postgres:5432/localrecall?sslmode=disable
volumes:
- models:/models
- images:/tmp/generated/images/
@@ -46,6 +55,22 @@ services:
# count: 1
# capabilities: [gpu]
## Uncomment for PostgreSQL-backed knowledge base (see Agents docs)
# postgres:
# image: quay.io/mudler/localrecall:v0.5.2-postgresql
# environment:
# - POSTGRES_DB=localrecall
# - POSTGRES_USER=localrecall
# - POSTGRES_PASSWORD=localrecall
# volumes:
# - postgres_data:/var/lib/postgresql/data
# healthcheck:
# test: ["CMD-SHELL", "pg_isready -U localrecall"]
# interval: 10s
# timeout: 5s
# retries: 5
volumes:
models:
images:
# postgres_data:

View File

@@ -25,6 +25,7 @@ LocalAI provides a comprehensive set of features for running AI models locally.
- **[GPU Acceleration](GPU-acceleration/)** - Optimize performance with GPU support
- **[Distributed Inference](distributed_inferencing/)** - Scale inference across multiple nodes
- **[Model Context Protocol (MCP)](mcp/)** - Enable agentic capabilities with MCP integration
- **[Agents](agents/)** - Autonomous AI agents with tools, knowledge base, and skills
## Specialized Features

View File

@@ -0,0 +1,315 @@
+++
disableToc = false
title = "🤖 Agents"
weight = 21
url = '/features/agents'
+++
LocalAI includes a built-in agent platform powered by [LocalAGI](https://github.com/mudler/LocalAGI). Agents are autonomous AI entities that can reason, use tools, maintain memory, and interact with external services — all running locally as part of the LocalAI process.
## Overview
The agent system provides:
- **Autonomous agents** with configurable goals, personalities, and capabilities
- **Tool/Action support** — agents can execute actions (web search, code execution, API calls, etc.)
- **Knowledge base (RAG)** — per-agent collections with document upload, chunking, and semantic search
- **Skills system** — reusable skill definitions that agents can leverage, with git-based skill repositories
- **SSE streaming** — real-time chat with agents via Server-Sent Events
- **Import/Export** — share agent configurations as JSON files
- **Agent Hub** — browse and download ready-made agents from [agenthub.localai.io](https://agenthub.localai.io)
- **Web UI** — full management interface for creating, editing, chatting with, and monitoring agents
## Getting Started
Agents are enabled by default. To disable them, set:
```bash
LOCALAI_DISABLE_AGENTS=true
```
### Creating an Agent
1. Navigate to the **Agents** page in the web UI
2. Click **Create Agent** or import one from the [Agent Hub](https://agenthub.localai.io)
3. Configure the agent's name, model, system prompt, and actions
4. Save and start chatting
### Importing an Agent
You can import agent configurations from JSON files:
1. Download an agent configuration from the [Agent Hub](https://agenthub.localai.io) or export one from another LocalAI instance
2. On the **Agents** page, click **Import**
3. Select the JSON file — you'll be taken to the edit form to review and adjust the configuration before saving
4. Click **Create Agent** to finalize the import
## Configuration
### Environment Variables
All agent-related settings can be configured via environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `LOCALAI_DISABLE_AGENTS` | `false` | Disable the agent pool feature entirely |
| `LOCALAI_AGENT_POOL_API_URL` | _(self-referencing)_ | Default API URL for agents. By default, agents call back into LocalAI's own API (`http://127.0.0.1:<port>`). Set this to point agents to an external LLM provider. |
| `LOCALAI_AGENT_POOL_API_KEY` | _(LocalAI key)_ | Default API key for agents. Defaults to the first LocalAI API key. Set this when using an external provider. |
| `LOCALAI_AGENT_POOL_DEFAULT_MODEL` | _(empty)_ | Default LLM model for new agents |
| `LOCALAI_AGENT_POOL_MULTIMODAL_MODEL` | _(empty)_ | Default multimodal (vision) model for agents |
| `LOCALAI_AGENT_POOL_TRANSCRIPTION_MODEL` | _(empty)_ | Default transcription (speech-to-text) model for agents |
| `LOCALAI_AGENT_POOL_TRANSCRIPTION_LANGUAGE` | _(empty)_ | Default transcription language for agents |
| `LOCALAI_AGENT_POOL_TTS_MODEL` | _(empty)_ | Default TTS (text-to-speech) model for agents |
| `LOCALAI_AGENT_POOL_STATE_DIR` | _(config dir)_ | Directory for persisting agent state |
| `LOCALAI_AGENT_POOL_TIMEOUT` | `5m` | Default timeout for agent operations |
| `LOCALAI_AGENT_POOL_ENABLE_SKILLS` | `false` | Enable the skills service |
| `LOCALAI_AGENT_POOL_VECTOR_ENGINE` | `chromem` | Vector engine for knowledge base (`chromem` or `postgres`) |
| `LOCALAI_AGENT_POOL_EMBEDDING_MODEL` | `granite-embedding-107m-multilingual` | Embedding model for knowledge base |
| `LOCALAI_AGENT_POOL_CUSTOM_ACTIONS_DIR` | _(empty)_ | Directory for custom action plugins |
| `LOCALAI_AGENT_POOL_DATABASE_URL` | _(empty)_ | PostgreSQL connection string for collections (required when vector engine is `postgres`) |
| `LOCALAI_AGENT_POOL_MAX_CHUNKING_SIZE` | `400` | Maximum chunk size for document ingestion |
| `LOCALAI_AGENT_POOL_CHUNK_OVERLAP` | `0` | Overlap between document chunks |
| `LOCALAI_AGENT_POOL_ENABLE_LOGS` | `false` | Enable detailed agent logging |
| `LOCALAI_AGENT_POOL_COLLECTION_DB_PATH` | _(empty)_ | Custom path for the collections database |
| `LOCALAI_AGENT_HUB_URL` | `https://agenthub.localai.io` | URL for the Agent Hub (shown in the UI) |
### Knowledge Base Storage
By default, the knowledge base uses **chromem** — an in-process vector store that requires no external dependencies. For production deployments with larger knowledge bases, you can switch to **PostgreSQL** with pgvector support:
```bash
LOCALAI_AGENT_POOL_VECTOR_ENGINE=postgres
LOCALAI_AGENT_POOL_DATABASE_URL=postgresql://localrecall:localrecall@postgres:5432/localrecall?sslmode=disable
```
The PostgreSQL image `quay.io/mudler/localrecall:v0.5.2-postgresql` is pre-configured with pgvector and ready to use.
### Docker Compose Example
Basic setup with in-memory vector store:
```yaml
services:
localai:
image: localai/localai:latest
ports:
- 8080:8080
environment:
- MODELS_PATH=/models
- LOCALAI_AGENT_POOL_DEFAULT_MODEL=hermes-3-llama3.1-8b
- LOCALAI_AGENT_POOL_EMBEDDING_MODEL=granite-embedding-107m-multilingual
- LOCALAI_AGENT_POOL_ENABLE_SKILLS=true
- LOCALAI_AGENT_POOL_ENABLE_LOGS=true
volumes:
- models:/models
- localai_config:/etc/localai
volumes:
models:
localai_config:
```
Setup with PostgreSQL for persistent knowledge base:
```yaml
services:
localai:
image: localai/localai:latest
depends_on:
postgres:
condition: service_healthy
ports:
- 8080:8080
environment:
- MODELS_PATH=/models
- LOCALAI_AGENT_POOL_DEFAULT_MODEL=hermes-3-llama3.1-8b
- LOCALAI_AGENT_POOL_EMBEDDING_MODEL=granite-embedding-107m-multilingual
- LOCALAI_AGENT_POOL_ENABLE_SKILLS=true
- LOCALAI_AGENT_POOL_ENABLE_LOGS=true
# PostgreSQL-backed knowledge base
- LOCALAI_AGENT_POOL_VECTOR_ENGINE=postgres
- LOCALAI_AGENT_POOL_DATABASE_URL=postgresql://localrecall:localrecall@postgres:5432/localrecall?sslmode=disable
volumes:
- models:/models
- localai_config:/etc/localai
postgres:
image: quay.io/mudler/localrecall:v0.5.2-postgresql
environment:
- POSTGRES_DB=localrecall
- POSTGRES_USER=localrecall
- POSTGRES_PASSWORD=localrecall
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U localrecall"]
interval: 10s
timeout: 5s
retries: 5
volumes:
models:
localai_config:
postgres_data:
```
## Agent Configuration
Each agent has its own configuration that controls its behavior. Key settings include:
- **Name** — unique identifier for the agent
- **Model** — the LLM model the agent uses for reasoning
- **System Prompt** — defines the agent's personality and instructions
- **Actions** — tools the agent can use (web search, code execution, etc.)
- **Connectors** — external integrations (Slack, Discord, etc.)
- **Knowledge Base** — collections of documents for RAG
- **MCP Servers** — Model Context Protocol servers for additional tool access
The pool-level defaults (API URL, API key, models) can be set via environment variables. Individual agents can further override these in their configuration, allowing them to use different LLM providers (OpenAI, other LocalAI instances, etc.) on a per-agent basis.
## API Endpoints
All agent endpoints are grouped under `/api/agents/`:
### Agent Management
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/agents` | List all agents with status |
| `POST` | `/api/agents` | Create a new agent |
| `GET` | `/api/agents/:name` | Get agent info |
| `PUT` | `/api/agents/:name` | Update agent configuration |
| `DELETE` | `/api/agents/:name` | Delete an agent |
| `GET` | `/api/agents/:name/config` | Get agent configuration |
| `PUT` | `/api/agents/:name/pause` | Pause an agent |
| `PUT` | `/api/agents/:name/resume` | Resume a paused agent |
| `GET` | `/api/agents/:name/status` | Get agent status and observables |
| `POST` | `/api/agents/:name/chat` | Send a message to an agent |
| `GET` | `/api/agents/:name/sse` | SSE stream for real-time agent events |
| `GET` | `/api/agents/:name/export` | Export agent configuration as JSON |
| `POST` | `/api/agents/import` | Import an agent from JSON |
| `GET` | `/api/agents/config/metadata` | Get dynamic config form metadata |
### Skills
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/agents/skills` | List all skills |
| `POST` | `/api/agents/skills` | Create a new skill |
| `GET` | `/api/agents/skills/:name` | Get a skill |
| `PUT` | `/api/agents/skills/:name` | Update a skill |
| `DELETE` | `/api/agents/skills/:name` | Delete a skill |
| `GET` | `/api/agents/skills/search` | Search skills |
| `GET` | `/api/agents/skills/export/*` | Export a skill |
| `POST` | `/api/agents/skills/import` | Import a skill |
### Collections (Knowledge Base)
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/agents/collections` | List collections |
| `POST` | `/api/agents/collections` | Create a collection |
| `POST` | `/api/agents/collections/:name/upload` | Upload a document |
| `GET` | `/api/agents/collections/:name/entries` | List entries |
| `POST` | `/api/agents/collections/:name/search` | Search a collection |
| `POST` | `/api/agents/collections/:name/reset` | Reset a collection |
### Actions
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/agents/actions` | List available actions |
| `POST` | `/api/agents/actions/:name/definition` | Get action definition |
| `POST` | `/api/agents/actions/:name/run` | Execute an action |
## Using Agents via the Responses API
Agents can be used programmatically via the standard `/v1/responses` endpoint (OpenAI Responses API). Simply use the agent name as the `model` field:
```bash
curl -X POST http://localhost:8080/v1/responses \
-H "Content-Type: application/json" \
-d '{
"model": "my-agent",
"input": "What is the weather today?"
}'
```
This returns a standard Responses API response:
```json
{
"id": "resp_...",
"object": "response",
"status": "completed",
"model": "my-agent",
"output": [
{
"type": "message",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "The agent's response..."
}
]
}
]
}
```
You can also send structured message arrays as input:
```bash
curl -X POST http://localhost:8080/v1/responses \
-H "Content-Type: application/json" \
-d '{
"model": "my-agent",
"input": [
{"role": "user", "content": "Summarize the latest news about AI"}
]
}'
```
When the model name matches an agent, the request is routed to the agent pool. If no agent matches, it falls through to the normal model-based inference pipeline.
## Chat with SSE Streaming
For real-time streaming responses, use the chat endpoint with SSE:
Send a message to an agent:
```bash
curl -X POST http://localhost:8080/api/agents/my-agent/chat \
-H "Content-Type: application/json" \
-d '{"message": "What is the weather today?"}'
```
Listen to real-time events via SSE:
```bash
curl -N http://localhost:8080/api/agents/my-agent/sse
```
The SSE stream emits the following event types:
- `json_message` — agent/user messages
- `json_message_status` — processing status updates (`processing` / `completed`)
- `status` — system messages (reasoning steps, action results)
- `json_error` — error notifications
## Architecture
Agents run in-process within LocalAI. By default, each agent calls back into LocalAI's own API (`http://127.0.0.1:<port>/v1/chat/completions`) for LLM inference. This means:
- No external dependencies — everything runs in a single binary
- Agents use the same models loaded in LocalAI
- Per-agent overrides allow pointing individual agents to external providers
- Agent state is persisted to disk and restored on restart
```
User → POST /api/agents/:name/chat → LocalAI
→ AgentPool → Agent reasoning loop
→ POST /v1/chat/completions (self-referencing)
→ LocalAI model inference → response
→ SSE events → GET /api/agents/:name/sse → UI
```

View File

@@ -82,6 +82,20 @@ Manage model and backend galleries:
- **Autoload Galleries**: Automatically load model galleries on startup
- **Autoload Backend Galleries**: Automatically load backend galleries on startup
### Agent Pool Settings
Configure the built-in agent platform (see [Agents]({{%relref "features/agents" %}}) for full documentation):
- **Agent Pool Enabled**: Enable or disable the agent pool feature
- **Default Model**: Default LLM model for new agents
- **Embedding Model**: Model used for knowledge base embeddings (default: `granite-embedding-107m-multilingual`)
- **Max Chunking Size**: Maximum chunk size for document ingestion (default: `400`)
- **Chunk Overlap**: Overlap between document chunks (default: `0`)
- **Enable Logs**: Enable detailed agent logging
- **Collection DB Path**: Custom path for the collections database
> **Note:** Most agent pool settings require a restart to take effect.
## Configuration Persistence
All settings are automatically saved to `runtime_settings.json` in the `LOCALAI_CONFIG_DIR` directory (default: `BASEPATH/configuration`). This file is watched for changes, so modifications made directly to the file will also be applied at runtime.

99
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/mudler/LocalAI
go 1.25.6
go 1.26.0
require (
dario.cat/mergo v1.0.2
@@ -31,7 +31,7 @@ require (
github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.27
github.com/modelcontextprotocol/go-sdk v1.4.0
github.com/mudler/cogito v0.9.1
github.com/mudler/cogito v0.9.3-0.20260306202429-e073d115bd04
github.com/mudler/edgevpn v0.31.1
github.com/mudler/go-processmanager v0.1.0
github.com/mudler/memory v0.0.0-20251216220809-d1256471a6c2
@@ -59,23 +59,108 @@ require (
go.opentelemetry.io/otel/metric v1.41.0
go.opentelemetry.io/otel/sdk/metric v1.40.0
google.golang.org/grpc v1.79.1
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
oras.land/oras-go/v2 v2.6.0
)
require (
github.com/JohannesKaufmann/dom v0.2.0 // indirect
github.com/JohannesKaufmann/html-to-markdown/v2 v2.4.0 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/antchfx/htmlquery v1.3.4 // indirect
github.com/antchfx/xmlquery v1.4.4 // indirect
github.com/antchfx/xpath v1.3.4 // indirect
github.com/bits-and-blooms/bitset v1.22.0 // indirect
github.com/blevesearch/bleve/v2 v2.5.7 // indirect
github.com/blevesearch/bleve_index_api v1.2.11 // indirect
github.com/blevesearch/geo v0.2.4 // indirect
github.com/blevesearch/go-faiss v1.0.26 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/gtreap v0.1.1 // indirect
github.com/blevesearch/mmap-go v1.0.4 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 // indirect
github.com/blevesearch/segment v0.9.1 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
github.com/blevesearch/vellum v1.1.0 // indirect
github.com/blevesearch/zapx/v11 v11.4.2 // indirect
github.com/blevesearch/zapx/v12 v12.4.2 // indirect
github.com/blevesearch/zapx/v13 v13.4.2 // indirect
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
github.com/blevesearch/zapx/v16 v16.2.8 // indirect
github.com/bwmarrin/discordgo v0.29.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.5.1 // indirect
github.com/dslipak/pdf v0.0.2 // indirect
github.com/emersion/go-imap/v2 v2.0.0-beta.5 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/emersion/go-smtp v0.24.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/eritikass/githubmarkdownconvertergo v0.1.10 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-git/go-git/v5 v5.16.4 // indirect
github.com/go-telegram/bot v1.17.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gocolly/colly v1.2.0 // indirect
github.com/gofiber/fiber/v2 v2.52.9 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b // indirect
github.com/google/go-github/v69 v69.2.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jung-kurt/gofpdf v1.16.2 // indirect
github.com/kennygrant/sanitize v1.2.4 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/mudler/LocalAGI v0.0.0-20260306154948-5a27c471ca78
github.com/mudler/localrecall v0.5.4 // indirect
github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/oxffaa/gopher-parse-sitemap v0.0.0-20191021113419-005d2eb1def4 // indirect
github.com/philippgille/chromem-go v0.7.0 // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/rs/zerolog v1.31.0 // indirect
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.3 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/slack-go/slack v0.17.3 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/swaggo/files/v2 v2.0.2 // indirect
github.com/temoto/robotstxt v1.1.2 // indirect
github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/traefik/yaegi v0.16.1 // indirect
github.com/valyala/fasthttp v1.68.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
go.mau.fi/util v0.3.0 // indirect
go.starlark.net v0.0.0-20250417143717-f57e51f710eb // indirect
google.golang.org/appengine v1.6.8 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
maunium.net/go/maulogger/v2 v2.4.1 // indirect
maunium.net/go/mautrix v0.17.0 // indirect
mvdan.cc/xurls/v2 v2.6.0 // indirect
)
require (
@@ -187,7 +272,7 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/docker/cli v29.2.1+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker v28.5.2+incompatible
@@ -207,7 +292,7 @@ require (
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
@@ -285,7 +370,7 @@ require (
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pierrec/lz4/v4 v4.1.2 // indirect
github.com/pkg/errors v0.9.1
github.com/pkoukk/tiktoken-go v0.1.6 // indirect
github.com/pkoukk/tiktoken-go v0.1.7 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/polydawn/refmt v0.89.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
@@ -312,7 +397,7 @@ require (
github.com/vishvananda/netns v0.0.5 // indirect
github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opencensus.io v0.24.0 // indirect

265
go.sum
View File

@@ -20,6 +20,10 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg6
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/JohannesKaufmann/dom v0.2.0 h1:1bragmEb19K8lHAqgFgqCpiPCFEZMTXzOIEjuxkUfLQ=
github.com/JohannesKaufmann/dom v0.2.0/go.mod h1:57iSUl5RKric4bUkgos4zu6Xt5LMHUnw3TF1l5CbGZo=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.4.0 h1:C0/TerKdQX9Y9pbYi1EsLr5LDNANsqunyI/btpyfCg8=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.4.0/go.mod h1:OLaKh+giepO8j7teevrNwiy/fwf8LXgoc9g7rwaE1jk=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
@@ -28,10 +32,17 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Microsoft/hcsshim v0.11.7 h1:vl/nj3Bar/CvJSYo7gIQPyRWc9f3c6IeSNavBTSZNZQ=
github.com/Microsoft/hcsshim v0.11.7/go.mod h1:MV8xMfmECjl5HdO7U/3/hFVnkmSBjAjmA09d4bExKcU=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
@@ -43,9 +54,22 @@ github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW5
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/antchfx/htmlquery v1.3.4 h1:Isd0srPkni2iNTWCwVj/72t7uCphFeor5Q8nCzj1jdQ=
github.com/antchfx/htmlquery v1.3.4/go.mod h1:K9os0BwIEmLAvTqaNSua8tXLWRWZpocZIH73OzWQbwM=
github.com/antchfx/xmlquery v1.4.4 h1:mxMEkdYP3pjKSftxss4nUHfjBhnMk4imGoR96FRY2dg=
github.com/antchfx/xmlquery v1.4.4/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fusrx9b12fc=
github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antchfx/xpath v1.3.4 h1:1ixrW1VnXd4HurCj7qnqnR0jo14g8JMe20Fshg1Vgz4=
github.com/antchfx/xpath v1.3.4/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
@@ -57,8 +81,50 @@ github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZx
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8=
github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA=
github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s=
github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI=
github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY=
github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc=
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w=
github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI=
github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/c-robinson/iplib v1.0.8 h1:exDRViDyL9UBLcfmlxxkY5odWX5092nPsQIykHXhIn4=
github.com/c-robinson/iplib v1.0.8/go.mod h1:i3LuuFL1hRT5gFpBRnEydzw8R6yhGkF4szNDIbF8pgo=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
@@ -85,6 +151,8 @@ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNE
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM=
github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw=
@@ -103,6 +171,7 @@ github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7np
github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw=
github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY=
github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
@@ -112,6 +181,8 @@ github.com/creachadair/otp v0.5.0 h1:q3Th7CXm2zlmCdBjw5tEPFOj4oWJMnVL5HXlq0sNKS0
github.com/creachadair/otp v0.5.0/go.mod h1:0kceI87EnYFNYSTL121goJVAnk3eJhaed9H0nMuJUkA=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cyphar/filepath-securejoin v0.5.1 h1:eYgfMq5yryL4fbWfkLpFFy2ukSELzaJOTaUTuh+oF48=
github.com/cyphar/filepath-securejoin v0.5.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -126,8 +197,8 @@ github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4m
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/docker/cli v29.2.1+incompatible h1:n3Jt0QVCN65eiVBoUTZQM9mcQICCJt3akW4pKAbKdJg=
@@ -142,18 +213,34 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dslipak/pdf v0.0.2 h1:djAvcM5neg9Ush+zR6QXB+VMJzR6TdnX766HPIg1JmI=
github.com/dslipak/pdf v0.0.2/go.mod h1:2L3SnkI9cQwnAS9gfPz2iUoLC0rUZwbucpbKi5R1mUo=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emersion/go-imap/v2 v2.0.0-beta.5 h1:H3858DNmBuXyMK1++YrQIRdpKE1MwBc+ywBtg3n+0wA=
github.com/emersion/go-imap/v2 v2.0.0-beta.5/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU=
github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/eritikass/githubmarkdownconvertergo v0.1.10 h1:mL93ADvYMOeT15DcGtK9AaFFc+RcWcy6kQBC6yS/5f4=
github.com/eritikass/githubmarkdownconvertergo v0.1.10/go.mod h1:BdpHs6imOtzE5KorbUtKa6bZ0ZBh1yFcrTTAL8FwDKY=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -187,6 +274,8 @@ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6O
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4=
github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs=
github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA=
@@ -194,6 +283,14 @@ github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38r
github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g=
github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
@@ -218,6 +315,10 @@ github.com/go-skynet/go-llama.cpp v0.0.0-20240314183750-6a8041ef6b46 h1:lALhXzDk
github.com/go-skynet/go-llama.cpp v0.0.0-20240314183750-6a8041ef6b46/go.mod h1:iub0ugfTnflE3rcIuqV2pQSo15nEw3GLW/utm5gyERo=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-telegram/bot v1.17.0 h1:Hs0kGxSj97QFqOQP0zxduY/4tSx8QDzvNI9uVRS+zmY=
github.com/go-telegram/bot v1.17.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
github.com/go-text/typesetting v0.3.3 h1:ihGNJU9KzdK2QRDy1Bm7FT5RFQoYb+3n3EIhI/4eaQc=
@@ -225,10 +326,17 @@ github.com/go-text/typesetting v0.3.3/go.mod h1:vIRUT25mLQaSh4C8H/lIsKppQz/Gdb8P
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8 h1:4KCscI9qYWMGTuz6BpJtbUSRzcBrUSSE0ENMJbNSrFs=
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI=
github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -238,8 +346,9 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9v
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
@@ -253,11 +362,15 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
@@ -266,14 +379,20 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-containerregistry v0.21.1 h1:sOt/o9BS2b87FnR7wxXPvRKU1XVJn2QCwOS5g8zQXlc=
github.com/google/go-containerregistry v0.21.1/go.mod h1:ctO5aCaewH4AK1AumSF5DPW+0+R+d2FmylMJdp5G7p0=
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE=
github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
@@ -294,6 +413,7 @@ github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c h1:7lF+Vz0LqiRid
github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gpustack/gguf-parser-go v0.24.0 h1:tdJceXYp9e5RhE9RwVYIuUpir72Jz2D68NEtDXkKCKc=
@@ -342,12 +462,22 @@ github.com/ipfs/go-test v0.2.1 h1:/D/a8xZ2JzkYqcVcV/7HYlCnc7bv/pKHQiX5TdClkPE=
github.com/ipfs/go-test v0.2.1/go.mod h1:dzu+KB9cmWjuJnXFDYJwC25T3j1GcN57byN+ixmK39M=
github.com/ipld/go-ipld-prime v0.21.0 h1:n4JmcpOlPDIxBcY037SVfpd1G+Sj1nKZah0m6QH9C2E=
github.com/ipld/go-ipld-prime v0.21.0/go.mod h1:3RLqy//ERg/y5oShXXdx5YIp50cFGOanyMctpPjsvxQ=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/jaypipes/ghw v0.23.0 h1:WOL4hpLcIu1kIm+z5Oz19Tk1HNw/Sncrx/6GS8O0Kl0=
github.com/jaypipes/ghw v0.23.0/go.mod h1:fUNUjMZ0cjahKo+/u+32m9FutIx53Nkbi0Ti0m7j5HY=
github.com/jaypipes/pcidb v1.1.1 h1:QmPhpsbmmnCwZmHeYAATxEaoRuiMAJusKYkUncMC0ro=
github.com/jaypipes/pcidb v1.1.1/go.mod h1:x27LT2krrUgjf875KxQXKB0Ha/YXLdZRVmw6hH0G7g8=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk=
github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPwbGVtZVWC34vc5WLsDk=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
@@ -368,6 +498,13 @@ github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe9
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o=
github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
@@ -444,10 +581,14 @@ github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU=
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
@@ -511,16 +652,26 @@ github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7P
github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/mudler/cogito v0.9.1 h1:6y7VPHSS+Q+v4slV42XcjykN5wip4N7C/rXTwWPBVFM=
github.com/mudler/cogito v0.9.1/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/mudler/LocalAGI v0.0.0-20260306154948-5a27c471ca78 h1:B3FgipRORpDtDvNlCC/w4N6PPwIyn7M/mzeRiq0EV4o=
github.com/mudler/LocalAGI v0.0.0-20260306154948-5a27c471ca78/go.mod h1:e/00in01SHCpzUD/UyJMopn7P+vJMjsk6qkxZC1qPW0=
github.com/mudler/cogito v0.9.2 h1:KbzNpuJ782njeBKfg3q7kLIBHTCFi9DgXhPTXnZqu1Y=
github.com/mudler/cogito v0.9.2/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
github.com/mudler/cogito v0.9.3-0.20260306202429-e073d115bd04 h1:33Lqv8VBaV/AoaaVtZ5+Bcig4T9fvj0dQmKFCon5Xxo=
github.com/mudler/cogito v0.9.3-0.20260306202429-e073d115bd04/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
github.com/mudler/edgevpn v0.31.1 h1:7qegiDWd0kAg6ljhNHxqvp8hbo/6BbzSdbb7/2WZfiY=
github.com/mudler/edgevpn v0.31.1/go.mod h1:ftV5B0nKFzm4R8vR80UYnCb2nf7lxCRgAALxUEEgCf8=
github.com/mudler/go-piper v0.0.0-20241023091659-2494246fd9fc h1:RxwneJl1VgvikiX28EkpdAyL4yQVnJMrbquKospjHyA=
github.com/mudler/go-piper v0.0.0-20241023091659-2494246fd9fc/go.mod h1:O7SwdSWMilAWhBZMK9N9Y/oBDyMMzshE3ju8Xkexwig=
github.com/mudler/go-processmanager v0.1.0 h1:fcSKgF9U/a1Z7KofAFeZnke5YseadCI5GqL9oT0LS3E=
github.com/mudler/go-processmanager v0.1.0/go.mod h1:h6kmHUZeafr+k5hRYpGLMzJFH4hItHffgpRo2QIkP+o=
github.com/mudler/localrecall v0.5.4 h1:hVPGHRDBOkGUJYL6Zm37sG8uoZObc8jtIJlDY/+NVb4=
github.com/mudler/localrecall v0.5.4/go.mod h1:TZVXQI840MqjDtilBLc7kfmnctK4oNf1IR+cE68zno8=
github.com/mudler/memory v0.0.0-20251216220809-d1256471a6c2 h1:+WHsL/j6EWOMUiMVIOJNKOwSKiQt/qDPc9fePCf87fA=
github.com/mudler/memory v0.0.0-20251216220809-d1256471a6c2/go.mod h1:EA8Ashhd56o32qN7ouPKFSRUs/Z+LrRCF4v6R2Oarm8=
github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49 h1:dAF1ALXqqapRZo80x56BIBBcPrPbRNerbd66rdyO8J4=
github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49/go.mod h1:z3yFhcL9bSykmmh6xgGu0hyoItd4CnxgtWMEWw8uFJU=
github.com/mudler/water v0.0.0-20250808092830-dd90dcf09025 h1:WFLP5FHInarYGXi6B/Ze204x7Xy6q/I4nCZnWEyPHK0=
github.com/mudler/water v0.0.0-20250808092830-dd90dcf09025/go.mod h1:QuIFdRstyGJt+MTTkWY+mtD7U6xwjOR6SwKUjmLZtR4=
github.com/mudler/xlog v0.0.5 h1:2unBuVC5rNGhCC86UaA94TElWFml80NL5XLK+kAmNuU=
@@ -563,6 +714,8 @@ github.com/nwaples/rardecode v1.1.0 h1:vSxaY8vQhOcVr4mm5e8XllHWTiM4JF507A0Katqw7
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
@@ -584,12 +737,17 @@ github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs=
github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
github.com/otiai10/openaigo v1.7.0 h1:AOQcOjRRM57ABvz+aI2oJA/Qsz1AydKbdZAlGiKyCqg=
github.com/otiai10/openaigo v1.7.0/go.mod h1:kIaXc3V+Xy5JLplcBxehVyGYDtufHp3PFPy04jOwOAI=
github.com/oxffaa/gopher-parse-sitemap v0.0.0-20191021113419-005d2eb1def4 h1:2vmb32OdDhjZf2ETGDlr9n8RYXx7c+jXPxMiPbwnA+8=
github.com/oxffaa/gopher-parse-sitemap v0.0.0-20191021113419-005d2eb1def4/go.mod h1:2JQx4jDHmWrbABvpOayg/+OTU6ehN0IyK2EHzceXpJo=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI=
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE=
github.com/philippgille/chromem-go v0.7.0 h1:4jfvfyKymjKNfGxBUhHUcj1kp7B17NL/I1P+vGh1RvY=
github.com/philippgille/chromem-go v0.7.0/go.mod h1:hTd+wGEm/fFPQl7ilfCwQXkgEUxceYh86iIdoKMolPo=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pierrec/lz4/v4 v4.1.2 h1:qvY3YFXRQE/XB8MlLzJH7mSzBs74eA2gg52YTk6jUPM=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
@@ -634,13 +792,15 @@ github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54=
github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/pkoukk/tiktoken-go v0.1.6 h1:JF0TlJzhTbrI30wCvFuiw6FzP2+/bR+FIxUdgEAcUsw=
github.com/pkoukk/tiktoken-go v0.1.6/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw=
github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -678,21 +838,31 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww=
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/sebdah/goldie/v2 v2.7.1 h1:PkBHymaYdtvEkZV7TmyqKxdmn5/Vcj+8TpATWZjnG5E=
github.com/sebdah/goldie/v2 v2.7.1/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
@@ -726,8 +896,13 @@ github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4=
github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
github.com/smallnest/ringbuffer v0.0.0-20241116012123-461381446e3d h1:3VwvTjiRPA7cqtgOWddEL+JrcijMlXUmj99c/6YyZoY=
github.com/smallnest/ringbuffer v0.0.0-20241116012123-461381446e3d/go.mod h1:tAG61zBM1DYRaGIPloumExGvScf08oHuo0kFoOqdbT0=
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
@@ -747,6 +922,8 @@ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiY
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/streamer45/silero-vad-go v0.2.1 h1:Li1/tTC4H/3cyw6q4weX+U8GWwEL3lTekK/nYa1Cvuk=
github.com/streamer45/silero-vad-go v0.2.1/go.mod h1:B+2FXs/5fZ6pzl6unUZYhZqkYdOB+3saBVzjOzdZnUs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -772,8 +949,12 @@ github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64 h1:l/T7dYuJEQZOwVOpjIXr1180aM9PZL/d1MnMVIxefX4=
github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64/go.mod h1:Q1NAJOuRdQCqN/VIWdnaaEhV8LpeO2rtlBP7/iDJNII=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -791,6 +972,8 @@ github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfj
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/tmc/langchaingo v0.1.14 h1:o1qWBPigAIuFvrG6cjTFo0cZPFEZ47ZqpOYMjM15yZc=
github.com/tmc/langchaingo v0.1.14/go.mod h1:aKKYXYoqhIDEv7WKdpnnCLRaqXic69cX9MnDUk72378=
github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.14 h1:uv/0Bq533iFdnMHZdRBTOlaNMdb1+ZxXIlHDZHIHcvg=
@@ -798,6 +981,8 @@ github.com/ulikunitz/xz v0.5.14/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0o
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4=
@@ -816,6 +1001,8 @@ github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1/go.mod h
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
@@ -828,12 +1015,16 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
go.mau.fi/util v0.3.0 h1:Lt3lbRXP6ZBqTINK0EieRWor3zEwwwrDT14Z5N8RUCs=
go.mau.fi/util v0.3.0/go.mod h1:9dGsBCCbZJstx16YgnVMVi3O2bOizELoKpugLD4FoGs=
go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
@@ -859,6 +1050,8 @@ go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0=
go.starlark.net v0.0.0-20250417143717-f57e51f710eb h1:zOg9DxxrorEmgGUr5UPdCEwKqiqG0MlZciuCuA3XiDE=
go.starlark.net v0.0.0-20250417143717-f57e51f710eb/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
@@ -892,15 +1085,22 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -915,6 +1115,9 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -934,12 +1137,18 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -959,6 +1168,10 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -968,10 +1181,12 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210426080607-c94f62235c83/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -980,6 +1195,7 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -988,9 +1204,14 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -999,7 +1220,11 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1007,10 +1232,14 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1034,6 +1263,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1055,6 +1286,8 @@ google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9Ywl
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
@@ -1086,10 +1319,13 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
@@ -1098,11 +1334,14 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
@@ -1117,8 +1356,16 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 h1:eeH1AIcPvSc0Z25ThsYF+Xoqbn0CI/YnXVYoTLFdGQw=
howett.net/plist v1.0.2-0.20250314012144-ee69052608d9/go.mod h1:fyFX5Hj5tP1Mpk8obqA9MZgXT416Q5711SDT7dQLTLk=
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 h1:6YFJoB+0fUH6X3xU/G2tQqCYg+PkGtnZ5nMR5rpw72g=
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:OxvTsCwKosqQ1q7B+8FwXqg4rKZ/UG9dUW+g/VL2xH4=
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
maunium.net/go/mautrix v0.17.0 h1:scc1qlUbzPn+wc+3eAPquyD+3gZwwy/hBANBm+iGKK8=
maunium.net/go/mautrix v0.17.0/go.mod h1:j+puTEQCEydlVxhJ/dQP5chfa26TdvBO7X6F3Ataav8=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc=
oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=