diff --git a/README.md b/README.md index 4775a246..4ee5fe44 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ LocalAGI ensures your data stays exactly where you want itβ€”on your hardware. N - πŸ€– **Advanced Agent Teaming**: Instantly create cooperative agent teams from a single prompt. - πŸ“‘ **Connectors**: Built-in integrations with Discord, Slack, Telegram, GitHub Issues, and IRC. - πŸ›  **Comprehensive REST API**: Seamless integration into your workflows. Every agent created will support OpenAI Responses API out of the box. -- πŸ“š **Short & Long-Term Memory**: Powered by [LocalRecall](https://github.com/mudler/LocalRecall). +- πŸ“š **Short & Long-Term Memory**: Built-in knowledge base (RAG) for collections, file uploads, and semantic search. Manage collections in the Web UI under **Knowledge base**; agents with "Knowledge base" enabled use it automatically (implementation uses [LocalRecall](https://github.com/mudler/LocalRecall) libraries). - 🧠 **Planning & Reasoning**: Agents intelligently plan, reason, and adapt. - πŸ”„ **Periodic Tasks**: Schedule tasks with cron-like syntax. - πŸ’Ύ **Memory Management**: Control memory usage with options for long-term and summary memory. @@ -108,7 +108,7 @@ Still having issues? see this Youtube video: https://youtu.be/HtVwIxW3ePg

LocalRecall

-

A REST-ful API and knowledge base management system that provides persistent memory and storage capabilities for AI agents.

+

A REST-ful API and knowledge base management system. LocalAGI embeds this functionality: the Web UI includes a Knowledge base section and the same collections API, so you no longer need to run LocalRecall separately.

@@ -239,11 +239,13 @@ LocalAGI supports environment configurations. Note that these environment variab | `LOCALAGI_LLM_API_KEY` | API authentication | | `LOCALAGI_TIMEOUT` | Request timeout settings | | `LOCALAGI_STATE_DIR` | Where state gets stored | -| `LOCALAGI_LOCALRAG_URL` | LocalRecall connection | +| `LOCALAGI_BASE_URL` | Optional base URL for the app (only relevant when using an external LocalRAG URL; not used for built-in knowledge base) | | `LOCALAGI_ENABLE_CONVERSATIONS_LOGGING` | Toggle conversation logs | | `LOCALAGI_API_KEYS` | A comma separated list of api keys used for authentication | | `LOCALAGI_CUSTOM_ACTIONS_DIR` | Directory containing custom Go action files to be automatically loaded | +For the built-in knowledge base, optional env (defaults use `LOCALAGI_STATE_DIR`): `COLLECTION_DB_PATH`, `FILE_ASSETS`, `VECTOR_ENGINE` (e.g. `chromem`, `postgres`), `EMBEDDING_MODEL`, `DATABASE_URL` (when `VECTOR_ENGINE=postgres`). + Skills are stored in a fixed `skills` subdirectory under `LOCALAGI_STATE_DIR` (e.g. `/pool/skills` in Docker). Git repo config for skills lives in that directory. No extra environment variables are required. ## Installation Options @@ -339,15 +341,16 @@ import ( "github.com/mudler/LocalAGI/core/types" ) -// Create a new agent pool +// Create a new agent pool (call pool.SetRAGProvider(...) for knowledge base; see main.go) pool, err := state.NewAgentPool( "default-model", // default model name "default-multimodal-model", // default multimodal model - "image-model", // image generation model + "transcription-model", // default transcription model + "en", // default transcription language + "tts-model", // default TTS model "http://localhost:8080", // API URL - "your-api-key", // API key - "./state", // state directory - "http://localhost:8081", // LocalRAG API URL + "your-api-key", // API key + "./state", // state directory func(config *AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action { // Define available actions for agents return func(ctx context.Context, pool *AgentPool) []types.Action { @@ -374,8 +377,9 @@ pool, err := state.NewAgentPool( // Add your custom filters here } }, - "10m", // timeout - true, // enable conversation logs + "10m", // timeout + true, // enable conversation logs + nil, // skills service (optional) ) // Create a new agent in the pool @@ -741,7 +745,7 @@ export LOCALAGI_MODEL=gemma-3-4b-it-qat export LOCALAGI_MULTIMODAL_MODEL=moondream2-20250414 export LOCALAGI_IMAGE_MODEL=sd-1.5-ggml export LOCALAGI_LLM_API_URL=http://localai:8080 -export LOCALAGI_LOCALRAG_URL=http://localrecall:8080 +# Knowledge base is built-in; no separate LocalRecall service needed export LOCALAGI_STATE_DIR=./pool export LOCALAGI_TIMEOUT=5m export LOCALAGI_ENABLE_CONVERSATIONS_LOGGING=false @@ -1045,7 +1049,7 @@ LocalAGI supports environment configurations. Note that these environment variab | `LOCALAGI_LLM_API_KEY` | API authentication | | `LOCALAGI_TIMEOUT` | Request timeout settings | | `LOCALAGI_STATE_DIR` | Where state gets stored | -| `LOCALAGI_LOCALRAG_URL` | LocalRecall connection | +| `LOCALAGI_BASE_URL` | Optional base URL for built-in knowledge base (default `http://localhost:3000`) | | `LOCALAGI_SSHBOX_URL` | LocalAGI SSHBox URL, e.g. user:pass@ip:port | | `LOCALAGI_ENABLE_CONVERSATIONS_LOGGING` | Toggle conversation logs | | `LOCALAGI_API_KEYS` | A comma separated list of api keys used for authentication | diff --git a/core/state/compaction.go b/core/state/compaction.go index 547f9917..23c42e9f 100644 --- a/core/state/compaction.go +++ b/core/state/compaction.go @@ -15,6 +15,33 @@ import ( "github.com/sashabaranov/go-openai" ) +// KBCompactionClient is the interface used by compaction. It can be implemented by the HTTP RAG client adapter or by the in-process collection adapter. +type KBCompactionClient interface { + Collection() string + ListEntries() ([]string, error) + GetEntryContent(entry string) (content string, chunkCount int, err error) + Store(filePath string) error + DeleteEntry(entry string) error +} + +// wrappedClientCompactionAdapter adapts *localrag.WrappedClient to KBCompactionClient. +type wrappedClientCompactionAdapter struct { + *localrag.WrappedClient +} + +func (a *wrappedClientCompactionAdapter) ListEntries() ([]string, error) { + return a.Client.ListEntries(a.Collection()) +} + +func (a *wrappedClientCompactionAdapter) Store(filePath string) error { + return a.Client.Store(a.Collection(), filePath) +} + +func (a *wrappedClientCompactionAdapter) DeleteEntry(entry string) error { + _, err := a.Client.DeleteEntry(a.Collection(), entry) + return err +} + // datePrefixRegex matches YYYY-MM-DD at the start of a filename (e.g. 2006-01-02-15-04-05-hash.txt). var datePrefixRegex = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})`) @@ -102,9 +129,9 @@ func (s *openAISummarizer) Summarize(ctx context.Context, content string) (strin } // RunCompaction runs one compaction pass: list entries, group by period, for each group fetch content, optionally summarize, store result, delete originals. -func RunCompaction(ctx context.Context, client *localrag.WrappedClient, period string, summarize bool, apiURL, apiKey, model string) error { +func RunCompaction(ctx context.Context, client KBCompactionClient, period string, summarize bool, apiURL, apiKey, model string) error { collection := client.Collection() - entries, err := client.Client.ListEntries(collection) + entries, err := client.ListEntries() if err != nil { return fmt.Errorf("list entries: %w", err) } @@ -164,7 +191,7 @@ func RunCompaction(ctx context.Context, client *localrag.WrappedClient, period s xlog.Warn("compaction: write temp file failed", "error", err) continue } - if err := client.Client.Store(collection, tmpPath); err != nil { + if err := client.Store(tmpPath); err != nil { os.RemoveAll(tmpDir) xlog.Warn("compaction: store failed", "key", key, "error", err) continue @@ -172,7 +199,7 @@ func RunCompaction(ctx context.Context, client *localrag.WrappedClient, period s os.RemoveAll(tmpDir) for _, entry := range groupEntries { - if _, err := client.Client.DeleteEntry(collection, entry); err != nil { + if err := client.DeleteEntry(entry); err != nil { xlog.Warn("compaction: delete entry failed", "entry", entry, "error", err) } } @@ -182,7 +209,7 @@ func RunCompaction(ctx context.Context, client *localrag.WrappedClient, period s } // runCompactionTicker runs compaction on a schedule (daily/weekly/monthly). It stops when ctx is done. -func runCompactionTicker(ctx context.Context, client *localrag.WrappedClient, config *AgentConfig, apiURL, apiKey, model string) { +func runCompactionTicker(ctx context.Context, client KBCompactionClient, config *AgentConfig, apiURL, apiKey, model string) { // Run first compaction immediately on startup if err := RunCompaction(ctx, client, config.KBCompactionInterval, config.KBCompactionSummarize, apiURL, apiKey, model); err != nil { xlog.Warn("compaction ticker initial run failed", "collection", client.Collection(), "error", err) diff --git a/core/state/pool.go b/core/state/pool.go index 8984a8b1..095a7209 100644 --- a/core/state/pool.go +++ b/core/state/pool.go @@ -27,6 +27,26 @@ type SkillsProvider interface { GetMCPSession(ctx context.Context) (*mcp.ClientSession, error) } +// RAGProvider returns a RAGDB and optional compaction client for a collection (e.g. agent name). +// effectiveRAGURL/Key are pool/agent defaults; implementation may use them (HTTP) or ignore them (embedded). +type RAGProvider func(collectionName, effectiveRAGURL, effectiveRAGKey string) (RAGDB, KBCompactionClient, bool) + +// NewHTTPRAGProvider returns a RAGProvider that uses the LocalRAG HTTP API. When effective URL/key are empty, baseURL/baseKey are used. +func NewHTTPRAGProvider(baseURL, baseKey string) RAGProvider { + return func(collectionName, effectiveURL, effectiveKey string) (RAGDB, KBCompactionClient, bool) { + url := effectiveURL + if url == "" { + url = baseURL + } + key := effectiveKey + if key == "" { + key = baseKey + } + wc := localrag.NewWrappedClient(url, key, collectionName) + return wc, &wrappedClientCompactionAdapter{WrappedClient: wc}, true + } +} + type AgentPool struct { sync.Mutex file string @@ -37,7 +57,8 @@ type AgentPool struct { agentStatus map[string]*Status apiURL, defaultModel, defaultMultimodalModel, defaultTTSModel string defaultTranscriptionModel, defaultTranscriptionLanguage string - localRAGAPI, localRAGKey, apiKey string + apiKey string + ragProvider RAGProvider availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action connectors func(*AgentConfig) []Connector dynamicPrompt func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []DynamicPrompt @@ -47,6 +68,13 @@ type AgentPool struct { skillsService SkillsProvider } +// SetRAGProvider sets the single RAG provider (HTTP or embedded). Must be called after pool creation. +func (a *AgentPool) SetRAGProvider(fn RAGProvider) { + a.Lock() + defer a.Unlock() + a.ragProvider = fn +} + type Status struct { ActionResults []types.ActionState } @@ -79,7 +107,6 @@ func loadPoolFromFile(path string) (*AgentPoolData, error) { func NewAgentPool( defaultModel, defaultMultimodalModel, defaultTranscriptionModel, defaultTranscriptionLanguage, defaultTTSModel, apiURL, apiKey, directory string, - LocalRAGAPI string, availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action, connectors func(*AgentConfig) []Connector, promptBlocks func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []DynamicPrompt, @@ -108,7 +135,6 @@ func NewAgentPool( defaultTranscriptionModel: defaultTranscriptionModel, defaultTranscriptionLanguage: defaultTranscriptionLanguage, defaultTTSModel: defaultTTSModel, - localRAGAPI: LocalRAGAPI, apiKey: apiKey, agents: make(map[string]*Agent), pool: make(map[string]AgentConfig), @@ -143,7 +169,6 @@ func NewAgentPool( agentStatus: map[string]*Status{}, pool: *poolData, connectors: connectors, - localRAGAPI: LocalRAGAPI, dynamicPrompt: promptBlocks, filters: filters, availableActions: availableActions, @@ -303,14 +328,8 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf } else { config.APIKey = a.apiKey } - effectiveLocalRAGAPI := a.localRAGAPI - if config.LocalRAGURL != "" { - effectiveLocalRAGAPI = config.LocalRAGURL - } - effectiveLocalRAGKey := a.localRAGKey - if config.LocalRAGAPIKey != "" { - effectiveLocalRAGKey = config.LocalRAGAPIKey - } + effectiveLocalRAGAPI := config.LocalRAGURL + effectiveLocalRAGKey := config.LocalRAGAPIKey connectors := a.connectors(config) promptBlocks := a.dynamicPrompt(config)(ctx, a) @@ -510,26 +529,27 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf } } - var ragClient *localrag.WrappedClient - if config.EnableKnowledgeBase { - ragClient = localrag.NewWrappedClient(effectiveLocalRAGAPI, effectiveLocalRAGKey, name) - opts = append(opts, WithRAGDB(ragClient), EnableKnowledgeBase) - // Set KB auto search option (defaults to true for backward compatibility) - // For backward compatibility: if both new KB fields are false (zero values), - // assume this is an old config and default KBAutoSearch to true + var ragDB RAGDB + var compactionClient KBCompactionClient + if config.EnableKnowledgeBase && a.ragProvider != nil { + if db, comp, ok := a.ragProvider(name, effectiveLocalRAGAPI, effectiveLocalRAGKey); ok && db != nil { + ragDB = db + compactionClient = comp + } + } + if ragDB != nil { + opts = append(opts, WithRAGDB(ragDB), EnableKnowledgeBase) kbAutoSearch := config.KBAutoSearch if !config.KBAutoSearch && !config.KBAsTools { - // Both new fields are false, likely an old config - default to true for backward compatibility kbAutoSearch = true } opts = append(opts, WithKBAutoSearch(kbAutoSearch)) - // Inject KB wrapper actions if enabled - if config.KBAsTools && ragClient != nil { + if config.KBAsTools { kbResults := config.KnowledgeBaseResults if kbResults <= 0 { - kbResults = 5 // Default + kbResults = 5 } - searchAction, addAction := NewKBWrapperActions(ragClient, kbResults) + searchAction, addAction := NewKBWrapperActions(ragDB, kbResults) opts = append(opts, WithActions(searchAction, addAction)) } } @@ -582,8 +602,8 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf } }() - if config.EnableKnowledgeBase && config.EnableKBCompaction && ragClient != nil { - go runCompactionTicker(ctx, ragClient, config, effectiveAPIURL, effectiveAPIKey, model) + if config.EnableKnowledgeBase && config.EnableKBCompaction && compactionClient != nil { + go runCompactionTicker(ctx, compactionClient, config, effectiveAPIURL, effectiveAPIKey, model) } xlog.Info("Starting connectors", "name", name, "config", config) diff --git a/docker-compose.amd.yaml b/docker-compose.amd.yaml index 1bd42523..05a91e68 100644 --- a/docker-compose.amd.yaml +++ b/docker-compose.amd.yaml @@ -11,25 +11,15 @@ services: - /dev/dri - /dev/kfd - dind: - extends: - file: docker-compose.yaml - service: dind - - localrecall-postgres: + postgres: extends: file: docker-compose.yaml - service: localrecall-postgres + service: postgres - localrecall: - extends: - file: docker-compose.yaml - service: localrecall - - localrecall-healthcheck: + dind: extends: file: docker-compose.yaml - service: localrecall-healthcheck + service: dind localagi: extends: diff --git a/docker-compose.intel.yaml b/docker-compose.intel.yaml index ac1b8419..b3c1de3f 100644 --- a/docker-compose.intel.yaml +++ b/docker-compose.intel.yaml @@ -12,25 +12,15 @@ services: - /dev/dri/card1 - /dev/dri/renderD129 - dind: - extends: - file: docker-compose.yaml - service: dind - - localrecall-postgres: + postgres: extends: file: docker-compose.yaml - service: localrecall-postgres + service: postgres - localrecall: - extends: - file: docker-compose.yaml - service: localrecall - - localrecall-healthcheck: + dind: extends: file: docker-compose.yaml - service: localrecall-healthcheck + service: dind localagi: extends: diff --git a/docker-compose.nvidia.yaml b/docker-compose.nvidia.yaml index 8b21288d..e42b998c 100644 --- a/docker-compose.nvidia.yaml +++ b/docker-compose.nvidia.yaml @@ -17,25 +17,15 @@ services: count: 1 capabilities: [gpu] - dind: - extends: - file: docker-compose.yaml - service: dind - - localrecall-postgres: + postgres: extends: file: docker-compose.yaml - service: localrecall-postgres + service: postgres - localrecall: - extends: - file: docker-compose.yaml - service: localrecall - - localrecall-healthcheck: + dind: extends: file: docker-compose.yaml - service: localrecall-healthcheck + service: dind localagi: extends: diff --git a/docker-compose.yaml b/docker-compose.yaml index 6e8c218e..7e9c8fde 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -25,7 +25,7 @@ services: - backends:/backends - images:/tmp/generated/images - localrecall-postgres: + postgres: image: quay.io/mudler/localrecall:${LOCALRECALL_VERSION:-v0.5.2}-postgresql environment: - POSTGRES_DB=localrecall @@ -41,34 +41,6 @@ services: timeout: 5s retries: 5 - localrecall: - image: quay.io/mudler/localrecall:${LOCALRECALL_VERSION:-v0.5.4} - depends_on: - localrecall-postgres: - condition: service_healthy - localai: - condition: service_started - ports: - - 8080 - environment: - - DATABASE_URL=postgresql://localrecall:localrecall@localrecall-postgres:5432/localrecall?sslmode=disable - - VECTOR_ENGINE=postgres - - EMBEDDING_MODEL=granite-embedding-107m-multilingual - - FILE_ASSETS=/assets - - OPENAI_API_KEY=sk-1234567890 - - OPENAI_BASE_URL=http://localai:8080 - - HYBRID_SEARCH_BM25_WEIGHT=0.5 - - HYBRID_SEARCH_VECTOR_WEIGHT=0.5 - volumes: - - localrag_assets:/assets - - localrecall-healthcheck: - depends_on: - localrecall: - condition: service_started - image: busybox - command: ["sh", "-c", "until wget -q -O - http://localrecall:8080 > /dev/null 2>&1; do echo 'Waiting for localrecall...'; sleep 1; done; echo 'localrecall is up!'"] - sshbox: build: context: . @@ -101,8 +73,8 @@ services: depends_on: localai: condition: service_healthy - localrecall-healthcheck: - condition: service_completed_successfully + postgres: + condition: service_healthy dind: condition: service_healthy build: @@ -116,8 +88,11 @@ services: - LOCALAGI_MULTIMODAL_MODEL=${MULTIMODAL_MODEL:-moondream2-20250414} - LOCALAGI_LLM_API_URL=http://localai:8080 #- LOCALAGI_LLM_API_KEY=sk-1234567890 - - LOCALAGI_LOCALRAG_URL=http://localrecall:8080 - LOCALAGI_STATE_DIR=/pool + # Knowledge base (collections) with PostgreSQL by default + - VECTOR_ENGINE=postgres + - DATABASE_URL=postgresql://localrecall:localrecall@postgres:5432/localrecall?sslmode=disable + - EMBEDDING_MODEL=granite-embedding-107m-multilingual - LOCALAGI_TIMEOUT=5m - LOCALAGI_ENABLE_CONVERSATIONS_LOGGING=false - LOCALAGI_SSHBOX_URL=root:root@sshbox:22 @@ -134,5 +109,4 @@ volumes: models: backends: images: - localrag_assets: localagi_pool: diff --git a/go.mod b/go.mod index 3e8e720c..e022c057 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,9 @@ require ( github.com/jung-kurt/gofpdf v1.16.2 github.com/modelcontextprotocol/go-sdk v1.2.0 github.com/mudler/cogito v0.9.2-0.20260220220546-7e5c0264aac4 - github.com/mudler/xlog v0.0.1 + github.com/mudler/localrecall v0.5.4 + github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49 + github.com/mudler/xlog v0.0.5 github.com/onsi/ginkgo/v2 v2.27.5 github.com/onsi/gomega v1.39.0 github.com/philippgille/chromem-go v0.7.0 @@ -63,6 +65,7 @@ require ( github.com/blevesearch/zapx/v16 v16.2.8 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/dslipak/pdf v0.0.2 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect @@ -73,6 +76,10 @@ require ( github.com/google/jsonschema-go v0.3.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/huandu/xstrings v1.5.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/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect @@ -81,7 +88,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mschoch/smat v0.2.0 // indirect - github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49 // indirect + github.com/oxffaa/gopher-parse-sitemap v0.0.0-20191021113419-005d2eb1def4 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect diff --git a/go.sum b/go.sum index 7125a67b..60a84a07 100644 --- a/go.sum +++ b/go.sum @@ -116,6 +116,8 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= 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/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= @@ -215,6 +217,14 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +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/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/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= @@ -286,10 +296,12 @@ github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= github.com/mudler/cogito v0.9.2-0.20260220220546-7e5c0264aac4 h1:5fzvXeWsFebrlnG8fKSUR956hw4BuT3/s8azqzTxqBY= github.com/mudler/cogito v0.9.2-0.20260220220546-7e5c0264aac4/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4= +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/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/xlog v0.0.1 h1:yR3/wszd3ZM6u1n96YITJZ4yUcDgqHSwvQmzUJa+8vg= -github.com/mudler/xlog v0.0.1/go.mod h1:39f5vcd05Qd6GWKM8IjyHNQ7AmOx3ZM0YfhfIGhC18U= +github.com/mudler/xlog v0.0.5 h1:2unBuVC5rNGhCC86UaA94TElWFml80NL5XLK+kAmNuU= +github.com/mudler/xlog v0.0.5/go.mod h1:39f5vcd05Qd6GWKM8IjyHNQ7AmOx3ZM0YfhfIGhC18U= 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/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE= @@ -300,6 +312,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +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/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= diff --git a/main.go b/main.go index 6e9eb510..5bad0501 100644 --- a/main.go +++ b/main.go @@ -4,8 +4,10 @@ import ( "log" "os" "path/filepath" + "strconv" "strings" + "github.com/mudler/LocalAGI/core/agent" "github.com/mudler/LocalAGI/core/state" "github.com/mudler/LocalAGI/services" "github.com/mudler/LocalAGI/services/skills" @@ -28,6 +30,16 @@ var conversationDuration = os.Getenv("LOCALAGI_CONVERSATION_DURATION") var customActionsDir = os.Getenv("LOCALAGI_CUSTOM_ACTIONS_DIR") var sshBoxURL = os.Getenv("LOCALAGI_SSHBOX_URL") +// Collection / knowledge base env +var collectionDBPath = os.Getenv("COLLECTION_DB_PATH") +var fileAssets = os.Getenv("FILE_ASSETS") +var vectorEngine = os.Getenv("VECTOR_ENGINE") +var embeddingModel = os.Getenv("EMBEDDING_MODEL") +var maxChunkingSizeEnv = os.Getenv("MAX_CHUNKING_SIZE") +var chunkOverlapEnv = os.Getenv("CHUNK_OVERLAP") +var databaseURL = os.Getenv("DATABASE_URL") +var collectionAPIKeysEnv = os.Getenv("API_KEYS") + func init() { if baseModel == "" { panic("LOCALAGI_MODEL not set") @@ -52,10 +64,40 @@ func main() { // make sure state dir exists os.MkdirAll(stateDir, 0755) + // Collection defaults when env unset + if collectionDBPath == "" { + collectionDBPath = filepath.Join(stateDir, "collections") + } + if fileAssets == "" { + fileAssets = filepath.Join(stateDir, "assets") + } + if vectorEngine == "" { + vectorEngine = "chromem" + } + if embeddingModel == "" { + embeddingModel = "granite-embedding-107m-multilingual" + } + maxChunkingSize := 400 + if maxChunkingSizeEnv != "" { + if n, err := strconv.Atoi(maxChunkingSizeEnv); err == nil { + maxChunkingSize = n + } + } + chunkOverlap := 0 + if chunkOverlapEnv != "" { + if n, err := strconv.Atoi(chunkOverlapEnv); err == nil { + chunkOverlap = n + } + } + apiKeys := []string{} if apiKeysEnv != "" { apiKeys = strings.Split(apiKeysEnv, ",") } + collectionAPIKeys := []string{} + if collectionAPIKeysEnv != "" { + collectionAPIKeys = strings.Split(collectionAPIKeysEnv, ",") + } // Skills service (optional: provides skills prompt and MCP when agents have EnableSkills) skillsService, err := skills.NewService(stateDir) @@ -63,7 +105,7 @@ func main() { panic(err) } - // Create the agent pool + // Create the agent pool (RAG provider set below after app is created) pool, err := state.NewAgentPool( baseModel, multimodalModel, @@ -73,7 +115,6 @@ func main() { apiURL, apiKey, stateDir, - localRAG, services.Actions(map[string]string{ services.ActionConfigSSHBoxURL: sshBoxURL, services.ConfigStateDir: stateDir, @@ -93,7 +134,7 @@ func main() { panic(err) } - // Create the application + // Create the application (registers collection routes and sets up in-process RAG state) app := webui.NewApp( webui.WithPool(pool), webui.WithSkillsService(skillsService), @@ -104,8 +145,26 @@ func main() { webui.WithLLMModel(baseModel), webui.WithCustomActionsDir(customActionsDir), webui.WithStateDir(stateDir), + webui.WithCollectionDBPath(collectionDBPath), + webui.WithFileAssets(fileAssets), + webui.WithVectorEngine(vectorEngine), + webui.WithEmbeddingModel(embeddingModel), + webui.WithMaxChunkingSize(maxChunkingSize), + webui.WithChunkOverlap(chunkOverlap), + webui.WithDatabaseURL(databaseURL), + webui.WithCollectionAPIKeys(collectionAPIKeys...), ) + // Single RAG provider: HTTP client when URL set, in-process when not + if localRAG != "" { + pool.SetRAGProvider(state.NewHTTPRAGProvider(localRAG, apiKey)) + } else { + embedded := app.CollectionsRAGProvider() + pool.SetRAGProvider(func(collectionName, _, _ string) (agent.RAGDB, state.KBCompactionClient, bool) { + return embedded(collectionName) + }) + } + // Start the agents if err := pool.StartAll(); err != nil { panic(err) diff --git a/webui/app.go b/webui/app.go index 3d2dce7a..98d8444b 100644 --- a/webui/app.go +++ b/webui/app.go @@ -33,9 +33,10 @@ import ( type ( App struct { - config *Config + config *Config *fiber.App - sharedState *internalTypes.AgentSharedState + sharedState *internalTypes.AgentSharedState + collectionsState *collectionsState // set when RegisterCollectionRoutes runs; used for in-process RAG } ) diff --git a/webui/collections_handlers.go b/webui/collections_handlers.go new file mode 100644 index 00000000..87ceed54 --- /dev/null +++ b/webui/collections_handlers.go @@ -0,0 +1,504 @@ +package webui + +import ( + "crypto/subtle" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/mudler/localrecall/rag" + "github.com/mudler/localrecall/rag/sources" + "github.com/mudler/xlog" + "github.com/sashabaranov/go-openai" +) + +type collectionList map[string]*rag.PersistentKB + +// collectionsState holds in-memory state for the collections API. +type collectionsState struct { + mu sync.RWMutex + collections collectionList + sourceManager *rag.SourceManager + ensureCollection func(name string) (*rag.PersistentKB, bool) // get-or-create for internal RAG (agent name as collection) +} + +// APIResponse represents a standardized API response (LocalRecall contract). +type collectionsAPIResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Data interface{} `json:"data,omitempty"` + Error *collectionsAPIError `json:"error,omitempty"` +} + +type collectionsAPIError struct { + Code string `json:"code"` + Message string `json:"message"` + Details string `json:"details,omitempty"` +} + +const ( + errCodeNotFound = "NOT_FOUND" + errCodeInvalidRequest = "INVALID_REQUEST" + errCodeInternalError = "INTERNAL_ERROR" + errCodeUnauthorized = "UNAUTHORIZED" + errCodeConflict = "CONFLICT" +) + +func collectionsSuccessResponse(message string, data interface{}) collectionsAPIResponse { + return collectionsAPIResponse{ + Success: true, + Message: message, + Data: data, + } +} + +func collectionsErrorResponse(code, message, details string) collectionsAPIResponse { + return collectionsAPIResponse{ + Success: false, + Error: &collectionsAPIError{ + Code: code, + Message: message, + Details: details, + }, + } +} + +func newVectorEngine( + vectorEngineType string, + llmClient *openai.Client, + apiURL, apiKey, collectionName, dbPath, fileAssets, embeddingModel, databaseURL string, + maxChunkSize, chunkOverlap int, +) *rag.PersistentKB { + switch vectorEngineType { + case "chromem": + xlog.Info("Chromem collection", "collectionName", collectionName, "dbPath", dbPath) + return rag.NewPersistentChromeCollection(llmClient, collectionName, dbPath, fileAssets, embeddingModel, maxChunkSize, chunkOverlap) + case "localai": + xlog.Info("LocalAI collection", "collectionName", collectionName, "apiURL", apiURL) + return rag.NewPersistentLocalAICollection(llmClient, apiURL, apiKey, collectionName, dbPath, fileAssets, embeddingModel, maxChunkSize, chunkOverlap) + case "postgres": + if databaseURL == "" { + xlog.Error("DATABASE_URL is required for PostgreSQL engine") + return nil + } + xlog.Info("PostgreSQL collection", "collectionName", collectionName, "databaseURL", databaseURL) + return rag.NewPersistentPostgresCollection(llmClient, collectionName, dbPath, fileAssets, embeddingModel, maxChunkSize, chunkOverlap, databaseURL) + default: + xlog.Error("Unknown vector engine", "engine", vectorEngineType) + return nil + } +} + +// RegisterCollectionRoutes mounts /api/collections* routes and initializes collections state. +func (app *App) RegisterCollectionRoutes(webapp *fiber.App, cfg *Config) { + state := &collectionsState{ + collections: collectionList{}, + sourceManager: rag.NewSourceManager(&sources.Config{}), + } + + openaiConfig := openai.DefaultConfig(cfg.LLMAPIKey) + openaiConfig.BaseURL = cfg.LLMAPIURL + openAIClient := openai.NewClientWithConfig(openaiConfig) + + // Ensure dirs exist + os.MkdirAll(cfg.CollectionDBPath, 0755) + os.MkdirAll(cfg.FileAssets, 0755) + + // Load existing collections from disk + colls := rag.ListAllCollections(cfg.CollectionDBPath) + for _, c := range colls { + collection := newVectorEngine(cfg.VectorEngine, openAIClient, cfg.LLMAPIURL, cfg.LLMAPIKey, c, cfg.CollectionDBPath, cfg.FileAssets, cfg.EmbeddingModel, cfg.DatabaseURL, cfg.MaxChunkingSize, cfg.ChunkOverlap) + if collection != nil { + state.collections[c] = collection + state.sourceManager.RegisterCollection(c, collection) + } + } + + // Get-or-create for internal RAG (agents use collection name = agent name) + state.ensureCollection = func(name string) (*rag.PersistentKB, bool) { + state.mu.Lock() + defer state.mu.Unlock() + if kb, ok := state.collections[name]; ok && kb != nil { + return kb, true + } + collection := newVectorEngine(cfg.VectorEngine, openAIClient, cfg.LLMAPIURL, cfg.LLMAPIKey, name, cfg.CollectionDBPath, cfg.FileAssets, cfg.EmbeddingModel, cfg.DatabaseURL, cfg.MaxChunkingSize, cfg.ChunkOverlap) + if collection == nil { + return nil, false + } + state.collections[name] = collection + state.sourceManager.RegisterCollection(name, collection) + return collection, true + } + + state.sourceManager.Start() + + app.collectionsState = state + + // Optional API key middleware for /api/collections + apiKeys := cfg.CollectionAPIKeys + if len(apiKeys) == 0 { + apiKeys = cfg.ApiKeys + } + if len(apiKeys) > 0 { + webapp.Use("/api/collections", func(c *fiber.Ctx) error { + apiKey := c.Get("Authorization") + apiKey = strings.TrimPrefix(apiKey, "Bearer ") + for _, validKey := range apiKeys { + if subtle.ConstantTimeCompare([]byte(apiKey), []byte(validKey)) == 1 { + return c.Next() + } + } + return c.Status(fiber.StatusUnauthorized).JSON(collectionsErrorResponse(errCodeUnauthorized, "Unauthorized", "Invalid or missing API key")) + }) + } + + // Route handlers close over state and config + webapp.Post("/api/collections", app.createCollection(state, cfg, openAIClient)) + webapp.Get("/api/collections", app.listCollections(cfg)) + webapp.Post("/api/collections/:name/upload", app.uploadFile(state, cfg)) + webapp.Get("/api/collections/:name/entries", app.listFiles(state)) + webapp.Get("/api/collections/:name/entries/*", app.getEntryContent(state)) + webapp.Post("/api/collections/:name/search", app.searchCollection(state)) + webapp.Post("/api/collections/:name/reset", app.resetCollection(state)) + webapp.Delete("/api/collections/:name/entry/delete", app.deleteEntryFromCollection(state)) + webapp.Post("/api/collections/:name/sources", app.registerExternalSource(state)) + webapp.Delete("/api/collections/:name/sources", app.removeExternalSource(state)) + webapp.Get("/api/collections/:name/sources", app.listSources(state)) +} + +func (app *App) createCollection(state *collectionsState, cfg *Config, client *openai.Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + var r struct { + Name string `json:"name"` + } + if err := c.BodyParser(&r); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(collectionsErrorResponse(errCodeInvalidRequest, "Invalid request", err.Error())) + } + + collection := newVectorEngine(cfg.VectorEngine, client, cfg.LLMAPIURL, cfg.LLMAPIKey, r.Name, cfg.CollectionDBPath, cfg.FileAssets, cfg.EmbeddingModel, cfg.DatabaseURL, cfg.MaxChunkingSize, cfg.ChunkOverlap) + if collection == nil { + return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to create collection", "unsupported or misconfigured vector engine")) + } + + state.mu.Lock() + state.collections[r.Name] = collection + state.sourceManager.RegisterCollection(r.Name, collection) + state.mu.Unlock() + + return c.Status(fiber.StatusCreated).JSON(collectionsSuccessResponse("Collection created successfully", map[string]interface{}{ + "name": r.Name, + "created_at": time.Now().Format(time.RFC3339), + })) + } +} + +func (app *App) listCollections(cfg *Config) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + collectionsList := rag.ListAllCollections(cfg.CollectionDBPath) + return c.JSON(collectionsSuccessResponse("Collections retrieved successfully", map[string]interface{}{ + "collections": collectionsList, + "count": len(collectionsList), + })) + } +} + +func (app *App) uploadFile(state *collectionsState, cfg *Config) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + name := c.Params("name") + state.mu.RLock() + collection, exists := state.collections[name] + state.mu.RUnlock() + if !exists { + return c.Status(fiber.StatusNotFound).JSON(collectionsErrorResponse(errCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name))) + } + + file, err := c.FormFile("file") + if err != nil { + xlog.Error("Failed to read file", err) + return c.Status(fiber.StatusBadRequest).JSON(collectionsErrorResponse(errCodeInvalidRequest, "Failed to read file", err.Error())) + } + + f, err := file.Open() + if err != nil { + xlog.Error("Failed to open file", err) + return c.Status(fiber.StatusBadRequest).JSON(collectionsErrorResponse(errCodeInvalidRequest, "Failed to open file", err.Error())) + } + defer f.Close() + + filePath := filepath.Join(cfg.FileAssets, file.Filename) + out, err := os.Create(filePath) + if err != nil { + xlog.Error("Failed to create file", err) + return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to create file", err.Error())) + } + defer out.Close() + + _, err = io.Copy(out, f) + if err != nil { + xlog.Error("Failed to copy file", err) + return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to copy file", err.Error())) + } + + if collection.EntryExists(file.Filename) { + xlog.Info("Entry already exists") + return c.Status(fiber.StatusBadRequest).JSON(collectionsErrorResponse(errCodeConflict, "Entry already exists", fmt.Sprintf("File '%s' has already been uploaded to collection '%s'", file.Filename, name))) + } + + now := time.Now().Format(time.RFC3339) + if err := collection.Store(filePath, map[string]string{"created_at": now}); err != nil { + xlog.Error("Failed to store file", err) + return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to store file", err.Error())) + } + + return c.JSON(collectionsSuccessResponse("File uploaded successfully", map[string]interface{}{ + "filename": file.Filename, + "collection": name, + "created_at": now, + })) + } +} + +func (app *App) listFiles(state *collectionsState) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + name := c.Params("name") + state.mu.RLock() + collection, exists := state.collections[name] + state.mu.RUnlock() + if !exists { + return c.Status(fiber.StatusNotFound).JSON(collectionsErrorResponse(errCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name))) + } + + entries := collection.ListDocuments() + return c.JSON(collectionsSuccessResponse("Entries retrieved successfully", map[string]interface{}{ + "collection": name, + "entries": entries, + "count": len(entries), + })) + } +} + +// getEntryContent handles GET /api/collections/:name/entries/:entry (Fiber uses * for the rest of path). +func (app *App) getEntryContent(state *collectionsState) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + name := c.Params("name") + entryParam := c.Params("*") + if entryParam == "" { + return c.Status(fiber.StatusBadRequest).JSON(collectionsErrorResponse(errCodeInvalidRequest, "Invalid request", "entry path required")) + } + entry, err := url.PathUnescape(entryParam) + if err != nil { + entry = entryParam + } + + state.mu.RLock() + collection, exists := state.collections[name] + state.mu.RUnlock() + if !exists { + return c.Status(fiber.StatusNotFound).JSON(collectionsErrorResponse(errCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name))) + } + + content, chunkCount, err := collection.GetEntryFileContent(entry) + if err != nil { + if strings.Contains(err.Error(), "entry not found") { + return c.Status(fiber.StatusNotFound).JSON(collectionsErrorResponse(errCodeNotFound, "Entry not found", fmt.Sprintf("Entry '%s' does not exist in collection '%s'", entry, name))) + } + if strings.Contains(err.Error(), "not implemented") || strings.Contains(err.Error(), "unsupported file type") { + return c.Status(fiber.StatusNotImplemented).JSON(collectionsErrorResponse(errCodeInternalError, "Not supported", err.Error())) + } + return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to get entry content", err.Error())) + } + + return c.JSON(collectionsSuccessResponse("Entry content retrieved successfully", map[string]interface{}{ + "collection": name, + "entry": entry, + "content": content, + "chunk_count": chunkCount, + })) + } +} + +func (app *App) searchCollection(state *collectionsState) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + name := c.Params("name") + state.mu.RLock() + collection, exists := state.collections[name] + state.mu.RUnlock() + if !exists { + return c.Status(fiber.StatusNotFound).JSON(collectionsErrorResponse(errCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name))) + } + + var r struct { + Query string `json:"query"` + MaxResults int `json:"max_results"` + } + if err := c.BodyParser(&r); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(collectionsErrorResponse(errCodeInvalidRequest, "Invalid request", err.Error())) + } + + if r.MaxResults == 0 { + if len(collection.ListDocuments()) >= 5 { + r.MaxResults = 5 + } else { + r.MaxResults = 1 + } + } + + results, err := collection.Search(r.Query, r.MaxResults) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to search collection", err.Error())) + } + + return c.JSON(collectionsSuccessResponse("Search completed successfully", map[string]interface{}{ + "query": r.Query, + "max_results": r.MaxResults, + "results": results, + "count": len(results), + })) + } +} + +func (app *App) resetCollection(state *collectionsState) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + name := c.Params("name") + state.mu.Lock() + collection, exists := state.collections[name] + if exists { + delete(state.collections, name) + } + state.mu.Unlock() + + if !exists { + return c.Status(fiber.StatusNotFound).JSON(collectionsErrorResponse(errCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name))) + } + + if err := collection.Reset(); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to reset collection", err.Error())) + } + + return c.JSON(collectionsSuccessResponse("Collection reset successfully", map[string]interface{}{ + "collection": name, + "reset_at": time.Now().Format(time.RFC3339), + })) + } +} + +func (app *App) deleteEntryFromCollection(state *collectionsState) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + name := c.Params("name") + state.mu.RLock() + collection, exists := state.collections[name] + state.mu.RUnlock() + if !exists { + return c.Status(fiber.StatusNotFound).JSON(collectionsErrorResponse(errCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name))) + } + + var r struct { + Entry string `json:"entry"` + } + if err := c.BodyParser(&r); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(collectionsErrorResponse(errCodeInvalidRequest, "Invalid request", err.Error())) + } + + if err := collection.RemoveEntry(r.Entry); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to remove entry", err.Error())) + } + + remainingEntries := collection.ListDocuments() + return c.JSON(collectionsSuccessResponse("Entry deleted successfully", map[string]interface{}{ + "deleted_entry": r.Entry, + "remaining_entries": remainingEntries, + "entry_count": len(remainingEntries), + })) + } +} + +func (app *App) registerExternalSource(state *collectionsState) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + name := c.Params("name") + state.mu.RLock() + collection, exists := state.collections[name] + state.mu.RUnlock() + if !exists { + return c.Status(fiber.StatusNotFound).JSON(collectionsErrorResponse(errCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name))) + } + + var r struct { + URL string `json:"url"` + UpdateInterval int `json:"update_interval"` + } + if err := c.BodyParser(&r); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(collectionsErrorResponse(errCodeInvalidRequest, "Invalid request", err.Error())) + } + + if r.UpdateInterval < 1 { + r.UpdateInterval = 60 + } + + state.sourceManager.RegisterCollection(name, collection) + if err := state.sourceManager.AddSource(name, r.URL, time.Duration(r.UpdateInterval)*time.Minute); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to register source", err.Error())) + } + + return c.JSON(collectionsSuccessResponse("External source registered successfully", map[string]interface{}{ + "collection": name, + "url": r.URL, + "update_interval": r.UpdateInterval, + })) + } +} + +func (app *App) removeExternalSource(state *collectionsState) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + name := c.Params("name") + + var r struct { + URL string `json:"url"` + } + if err := c.BodyParser(&r); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(collectionsErrorResponse(errCodeInvalidRequest, "Invalid request", err.Error())) + } + + if err := state.sourceManager.RemoveSource(name, r.URL); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to remove source", err.Error())) + } + + return c.JSON(collectionsSuccessResponse("External source removed successfully", map[string]interface{}{ + "collection": name, + "url": r.URL, + })) + } +} + +func (app *App) listSources(state *collectionsState) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + name := c.Params("name") + state.mu.RLock() + collection, exists := state.collections[name] + state.mu.RUnlock() + if !exists { + return c.Status(fiber.StatusNotFound).JSON(collectionsErrorResponse(errCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name))) + } + + srcs := collection.GetExternalSources() + sourcesList := make([]map[string]interface{}, 0, len(srcs)) + for _, source := range srcs { + sourcesList = append(sourcesList, map[string]interface{}{ + "url": source.URL, + "update_interval": int(source.UpdateInterval.Minutes()), + "last_update": source.LastUpdate.Format(time.RFC3339), + }) + } + + return c.JSON(collectionsSuccessResponse("Sources retrieved successfully", map[string]interface{}{ + "collection": name, + "sources": sourcesList, + "count": len(sourcesList), + })) + } +} diff --git a/webui/collections_internal.go b/webui/collections_internal.go new file mode 100644 index 00000000..52bcd339 --- /dev/null +++ b/webui/collections_internal.go @@ -0,0 +1,180 @@ +package webui + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/mudler/LocalAGI/core/agent" + "github.com/mudler/LocalAGI/core/state" + "github.com/mudler/localrecall/rag" + "github.com/mudler/xlog" +) + +// internalRAGAdapter implements agent.RAGDB by calling the in-process *rag.PersistentKB directly (no HTTP). +type internalRAGAdapter struct { + mu sync.RWMutex + collection string + kb *rag.PersistentKB +} + +var _ agent.RAGDB = (*internalRAGAdapter)(nil) + +func newInternalRAGAdapter(collection string, kb *rag.PersistentKB) *internalRAGAdapter { + return &internalRAGAdapter{collection: collection, kb: kb} +} + +func (a *internalRAGAdapter) Store(s string) error { + a.mu.RLock() + kb := a.kb + a.mu.RUnlock() + if kb == nil { + return fmt.Errorf("collection not available") + } + t := time.Now() + dateTime := t.Format("2006-01-02-15-04-05") + hash := md5.Sum([]byte(s)) + fileName := fmt.Sprintf("%s-%s.txt", dateTime, hex.EncodeToString(hash[:])) + tempdir, err := os.MkdirTemp("", "localrag") + if err != nil { + return err + } + defer os.RemoveAll(tempdir) + f := filepath.Join(tempdir, fileName) + if err := os.WriteFile(f, []byte(s), 0644); err != nil { + return err + } + meta := map[string]string{"created_at": t.Format(time.RFC3339)} + return kb.Store(f, meta) +} + +func (a *internalRAGAdapter) Reset() error { + a.mu.RLock() + kb := a.kb + a.mu.RUnlock() + if kb == nil { + return fmt.Errorf("collection not available") + } + return kb.Reset() +} + +func (a *internalRAGAdapter) Search(s string, similarEntries int) ([]string, error) { + a.mu.RLock() + kb := a.kb + a.mu.RUnlock() + if kb == nil { + return nil, fmt.Errorf("collection not available") + } + results, err := kb.Search(s, similarEntries) + if err != nil { + return nil, err + } + out := make([]string, 0, len(results)) + for _, r := range results { + out = append(out, fmt.Sprintf("%s (%+v)", r.Content, r.Metadata)) + } + return out, nil +} + +func (a *internalRAGAdapter) Count() int { + a.mu.RLock() + kb := a.kb + a.mu.RUnlock() + if kb == nil { + return 0 + } + return kb.Count() +} + +// internalCompactionAdapter implements state.KBCompactionClient for the same in-process collection. +type internalCompactionAdapter struct { + mu sync.RWMutex + collection string + kb *rag.PersistentKB +} + +var _ state.KBCompactionClient = (*internalCompactionAdapter)(nil) + +func (a *internalCompactionAdapter) Collection() string { + return a.collection +} + +func (a *internalCompactionAdapter) ListEntries() ([]string, error) { + a.mu.RLock() + kb := a.kb + a.mu.RUnlock() + if kb == nil { + return nil, fmt.Errorf("collection not available") + } + return kb.ListDocuments(), nil +} + +func (a *internalCompactionAdapter) GetEntryContent(entry string) (content string, chunkCount int, err error) { + a.mu.RLock() + kb := a.kb + a.mu.RUnlock() + if kb == nil { + return "", 0, fmt.Errorf("collection not available") + } + return kb.GetEntryFileContent(entry) +} + +func (a *internalCompactionAdapter) Store(filePath string) error { + a.mu.RLock() + kb := a.kb + a.mu.RUnlock() + if kb == nil { + return fmt.Errorf("collection not available") + } + meta := map[string]string{"created_at": time.Now().Format(time.RFC3339)} + return kb.Store(filePath, meta) +} + +func (a *internalCompactionAdapter) DeleteEntry(entry string) error { + a.mu.RLock() + kb := a.kb + a.mu.RUnlock() + if kb == nil { + return fmt.Errorf("collection not available") + } + return kb.RemoveEntry(entry) +} + +// CollectionsRAGProvider returns a provider that the pool can use when no LocalRAG URL is set. +// It returns (RAGDB, KBCompactionClient, true) for a collection name, creating the collection on first use if needed. +func (app *App) CollectionsRAGProvider() func(collectionName string) (agent.RAGDB, state.KBCompactionClient, bool) { + return func(collectionName string) (agent.RAGDB, state.KBCompactionClient, bool) { + if app.collectionsState == nil { + return nil, nil, false + } + name := strings.TrimSpace(strings.ToLower(collectionName)) + if name == "" { + return nil, nil, false + } + var kb *rag.PersistentKB + app.collectionsState.mu.RLock() + kb, ok := app.collectionsState.collections[name] + ensure := app.collectionsState.ensureCollection + app.collectionsState.mu.RUnlock() + if !ok || kb == nil { + if ensure == nil { + xlog.Debug("internal RAG: no ensureCollection", "collection", name) + return nil, nil, false + } + var created bool + kb, created = ensure(name) + if !created || kb == nil { + xlog.Debug("internal RAG: ensure collection failed", "collection", name) + return nil, nil, false + } + } + ragAdapter := newInternalRAGAdapter(name, kb) + compAdapter := &internalCompactionAdapter{collection: name, kb: kb} + return ragAdapter, compAdapter, true + } +} diff --git a/webui/options.go b/webui/options.go index 1d7ee347..c32811b3 100644 --- a/webui/options.go +++ b/webui/options.go @@ -18,6 +18,16 @@ type Config struct { StateDir string CustomActionsDir string ConversationStoreDuration time.Duration + + // Collections / knowledge base (LocalRecall) + CollectionDBPath string + FileAssets string + VectorEngine string + EmbeddingModel string + MaxChunkingSize int + ChunkOverlap int + CollectionAPIKeys []string + DatabaseURL string } type Option func(*Config) @@ -86,6 +96,54 @@ func WithApiKeys(keys ...string) Option { } } +func WithCollectionDBPath(path string) Option { + return func(c *Config) { + c.CollectionDBPath = path + } +} + +func WithFileAssets(path string) Option { + return func(c *Config) { + c.FileAssets = path + } +} + +func WithVectorEngine(engine string) Option { + return func(c *Config) { + c.VectorEngine = engine + } +} + +func WithEmbeddingModel(model string) Option { + return func(c *Config) { + c.EmbeddingModel = model + } +} + +func WithMaxChunkingSize(size int) Option { + return func(c *Config) { + c.MaxChunkingSize = size + } +} + +func WithChunkOverlap(overlap int) Option { + return func(c *Config) { + c.ChunkOverlap = overlap + } +} + +func WithCollectionAPIKeys(keys ...string) Option { + return func(c *Config) { + c.CollectionAPIKeys = keys + } +} + +func WithDatabaseURL(url string) Option { + return func(c *Config) { + c.DatabaseURL = url + } +} + func (c *Config) Apply(opts ...Option) { for _, opt := range opts { opt(c) diff --git a/webui/react-ui/src/App.css b/webui/react-ui/src/App.css index c5724bff..7c017aec 100644 --- a/webui/react-ui/src/App.css +++ b/webui/react-ui/src/App.css @@ -2074,3 +2074,260 @@ select.form-control { margin-right: 0.5rem; animation: subtle-pulse 2s ease-in-out infinite; } + +/* =================================== + KNOWLEDGE BASE PAGE + =================================== */ +.knowledge-page .page-description { + color: var(--color-text-secondary); + margin: 0.5rem 0 0 0; + font-size: 0.95rem; +} + +.knowledge-tabs { + display: flex; + gap: 0.25rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.knowledge-tabs .tab-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-bg-secondary); + color: var(--color-text-secondary); + cursor: pointer; + font-size: 0.9rem; +} + +.knowledge-tabs .tab-btn:hover { + background: var(--color-primary-light); + color: var(--color-primary); + border-color: var(--color-primary-border); +} + +.knowledge-tabs .tab-btn.active { + background: var(--color-primary); + color: var(--color-primary-text); + border-color: var(--color-primary); +} + +.knowledge-content { + max-width: 900px; +} + +.knowledge-card { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.knowledge-card-title { + font-size: 1.1rem; + margin: 0 0 0.5rem 0; + color: var(--color-text-primary); +} + +.knowledge-card-desc { + color: var(--color-text-secondary); + font-size: 0.9rem; + margin: 0 0 1rem 0; +} + +.knowledge-card .form-group { + margin-bottom: 1rem; +} + +.knowledge-card .form-group label { + display: block; + margin-bottom: 0.25rem; + font-size: 0.9rem; + color: var(--color-text-secondary); +} + +.knowledge-card .form-group input, +.knowledge-card .form-group select { + width: 100%; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-bg-primary); + color: var(--color-text-primary); +} + +.knowledge-card .form-row { + display: flex; + gap: 1rem; + align-items: flex-end; + flex-wrap: wrap; +} + +.knowledge-card .form-row .form-group { + margin-bottom: 0; +} + +.knowledge-card .flex-1 { + flex: 1; + min-width: 120px; +} + +.knowledge-error { + color: var(--color-error); + margin-bottom: 0.75rem; + font-size: 0.9rem; +} + +.knowledge-card .btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: var(--radius-md); + font-size: 0.9rem; + cursor: pointer; + border: none; +} + +.knowledge-card .btn-primary { + background: var(--color-primary); + color: var(--color-primary-text); +} + +.knowledge-card .btn-primary:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.knowledge-card .btn-ghost { + background: transparent; + color: var(--color-text-secondary); +} + +.knowledge-card .btn-ghost:hover:not(:disabled) { + background: var(--color-primary-light); + color: var(--color-primary); +} + +.knowledge-card .btn-ghost.danger:hover:not(:disabled) { + background: var(--color-error-light); + color: var(--color-error); +} + +.knowledge-card .btn-group { + display: flex; + gap: 0.25rem; +} + +.knowledge-card .muted { + color: var(--color-text-muted); + font-size: 0.9rem; +} + +.search-results { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} + +.search-results h3 { + font-size: 1rem; + margin: 0 0 0.75rem 0; + color: var(--color-text-primary); +} + +.results-list { + list-style: none; + padding: 0; + margin: 0; +} + +.result-item { + background: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 0.75rem; + margin-bottom: 0.5rem; +} + +.result-item pre { + margin: 0; + font-size: 0.85rem; + white-space: pre-wrap; + word-break: break-word; +} + +.knowledge-list { + list-style: none; + padding: 0; + margin: 0; +} + +.knowledge-list-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + background: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + margin-bottom: 0.5rem; +} + +.knowledge-list-item span.truncate { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.knowledge-list-item > div { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.knowledge-modal { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 1.5rem; + max-width: 640px; + width: 100%; + max-height: 90vh; + overflow: auto; +} + +.knowledge-entry-content { + white-space: pre-wrap; + word-break: break-word; + max-height: 400px; + overflow-y: auto; + font-size: 0.85rem; + padding: 0.75rem; + background: var(--color-bg-primary); + border-radius: var(--radius-md); + margin: 1rem 0; +} + +.knowledge-card .icon-only { + padding: 0.5rem; + margin-bottom: 0.5rem; +} diff --git a/webui/react-ui/src/App.jsx b/webui/react-ui/src/App.jsx index a3521a4a..d675f2c9 100644 --- a/webui/react-ui/src/App.jsx +++ b/webui/react-ui/src/App.jsx @@ -24,6 +24,7 @@ function App() { { path: '/agents', icon: 'fas fa-users', label: 'Agents' }, { path: '/actions-playground', icon: 'fas fa-bolt', label: 'Actions' }, { path: '/skills', icon: 'fas fa-book', label: 'Skills' }, + { path: '/knowledge', icon: 'fas fa-database', label: 'Knowledge base' }, { path: '/group-create', icon: 'fas fa-users-cog', label: 'Groups' }, ]; diff --git a/webui/react-ui/src/pages/Knowledge.jsx b/webui/react-ui/src/pages/Knowledge.jsx new file mode 100644 index 00000000..14049f6b --- /dev/null +++ b/webui/react-ui/src/pages/Knowledge.jsx @@ -0,0 +1,614 @@ +import { useState, useEffect } from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { collectionsApi } from '../utils/api'; + +const TABS = [ + { id: 'search', label: 'Search', icon: 'fa-search' }, + { id: 'collections', label: 'Collections', icon: 'fa-folder' }, + { id: 'upload', label: 'Upload', icon: 'fa-upload' }, + { id: 'sources', label: 'Sources', icon: 'fa-globe' }, + { id: 'entries', label: 'Entries', icon: 'fa-list' }, +]; + +function Knowledge() { + const { showToast } = useOutletContext(); + const [tab, setTab] = useState('search'); + const [collections, setCollections] = useState([]); + const [loadingCollections, setLoadingCollections] = useState(true); + + const fetchCollections = async () => { + setLoadingCollections(true); + try { + const list = await collectionsApi.list(); + setCollections(Array.isArray(list) ? list : []); + } catch (err) { + showToast(err.message || 'Failed to load collections', 'error'); + setCollections([]); + } finally { + setLoadingCollections(false); + } + }; + + useEffect(() => { + document.title = 'Knowledge base - LocalAGI'; + fetchCollections(); + }, []); + + return ( +
+
+

+ + Knowledge base +

+

+ Manage collections, upload files, search content, and sync external sources. +

+
+ +
+ {TABS.map((t) => ( + + ))} +
+ +
+ {tab === 'search' && ( + + )} + {tab === 'collections' && ( + + )} + {tab === 'upload' && ( + + )} + {tab === 'sources' && ( + + )} + {tab === 'entries' && ( + + )} +
+
+ ); +} + +function SearchTab({ collections, loadingCollections, onRefreshCollections, showToast }) { + const [selectedCollection, setSelectedCollection] = useState(''); + const [query, setQuery] = useState(''); + const [maxResults, setMaxResults] = useState(5); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSearch = async () => { + if (!selectedCollection || !query.trim()) { + setError('Select a collection and enter a query'); + return; + } + setError(''); + setLoading(true); + try { + const list = await collectionsApi.search(selectedCollection, query.trim(), maxResults || 5); + setResults(Array.isArray(list) ? list : []); + if ((list?.length ?? 0) === 0) { + setResults([{ Content: `No results for "${query}"` }]); + } + } catch (err) { + showToast(err.message || 'Search failed', 'error'); + setResults([]); + } finally { + setLoading(false); + } + }; + + return ( +
+

Search collections

+

Semantic search over your indexed content.

+ {error &&
{error}
} +
+ + +
+
+ + setQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + placeholder="Enter search query..." + /> +
+
+
+ + setMaxResults(Number(e.target.value) || 5)} + /> +
+ +
+
+

Results

+ {results.length > 0 ? ( +
    + {results.map((r, i) => ( +
  • +
    {typeof r === 'object' && r.Content != null ? r.Content : JSON.stringify(r, null, 2)}
    +
  • + ))} +
+ ) : ( + !loading &&

Run a search to see results.

+ )} +
+
+ ); +} + +function CollectionsTab({ collections, loadingCollections, onRefresh, showToast }) { + const [newName, setNewName] = useState(''); + const [creating, setCreating] = useState(false); + const [resetting, setResetting] = useState(null); + + const handleCreate = async () => { + if (!newName.trim()) { + showToast('Enter a collection name', 'error'); + return; + } + setCreating(true); + try { + await collectionsApi.create(newName.trim()); + showToast(`Collection "${newName}" created`, 'success'); + setNewName(''); + onRefresh(); + } catch (err) { + showToast(err.message || 'Failed to create collection', 'error'); + } finally { + setCreating(false); + } + }; + + const handleReset = async (name) => { + if (!confirm(`Reset collection "${name}"? This removes all entries and cannot be undone.`)) return; + setResetting(name); + try { + await collectionsApi.reset(name); + showToast(`Collection "${name}" reset`, 'success'); + onRefresh(); + } catch (err) { + showToast(err.message || 'Failed to reset', 'error'); + } finally { + setResetting(null); + } + }; + + return ( +
+

Create collection

+
+ setNewName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + placeholder="Collection name..." + className="flex-1" + /> + +
+
+

Your collections

+ +
+ {loadingCollections ? ( +

Loading...

+ ) : collections.length === 0 ? ( +

No collections. Create one above.

+ ) : ( +
    + {collections.map((c) => ( +
  • + + {c} + +
  • + ))} +
+ )} +
+ ); +} + +function UploadTab({ collections, loadingCollections, onRefreshCollections, showToast }) { + const [selectedCollection, setSelectedCollection] = useState(''); + const [file, setFile] = useState(null); + const [uploading, setUploading] = useState(false); + + const handleUpload = async () => { + if (!selectedCollection) { + showToast('Select a collection', 'error'); + return; + } + if (!file) { + showToast('Select a file', 'error'); + return; + } + setUploading(true); + try { + await collectionsApi.upload(selectedCollection, file); + showToast('File uploaded', 'success'); + setFile(null); + onRefreshCollections(); + } catch (err) { + showToast(err.message || 'Upload failed', 'error'); + } finally { + setUploading(false); + } + }; + + return ( +
+

Upload file

+
+ + +
+
+ + setFile(e.target.files?.[0] ?? null)} + /> + {file && {file.name}} +
+ +
+ ); +} + +function SourcesTab({ collections, loadingCollections, showToast }) { + const [selectedCollection, setSelectedCollection] = useState(''); + const [url, setUrl] = useState(''); + const [intervalMin, setIntervalMin] = useState(60); + const [sources, setSources] = useState([]); + const [loadingSources, setLoadingSources] = useState(false); + const [adding, setAdding] = useState(false); + const [removing, setRemoving] = useState(null); + + useEffect(() => { + if (!selectedCollection) { + setSources([]); + return; + } + let cancelled = false; + setLoadingSources(true); + collectionsApi.listSources(selectedCollection) + .then((list) => { if (!cancelled) setSources(Array.isArray(list) ? list : []); }) + .catch((err) => { if (!cancelled) showToast(err.message || 'Failed to load sources', 'error'); }) + .finally(() => { if (!cancelled) setLoadingSources(false); }); + return () => { cancelled = true; }; + }, [selectedCollection, showToast]); + + const handleAdd = async () => { + if (!selectedCollection || !url.trim()) { + showToast('Select a collection and enter a URL', 'error'); + return; + } + setAdding(true); + try { + await collectionsApi.addSource(selectedCollection, url.trim(), intervalMin || 60); + showToast('Source added', 'success'); + setUrl(''); + setSources(await collectionsApi.listSources(selectedCollection)); + } catch (err) { + showToast(err.message || 'Failed to add source', 'error'); + } finally { + setAdding(false); + } + }; + + const handleRemove = async (sourceUrl) => { + if (!selectedCollection) return; + setRemoving(sourceUrl); + try { + await collectionsApi.removeSource(selectedCollection, sourceUrl); + showToast('Source removed', 'success'); + setSources(await collectionsApi.listSources(selectedCollection)); + } catch (err) { + showToast(err.message || 'Failed to remove source', 'error'); + } finally { + setRemoving(null); + } + }; + + return ( +
+

External sources

+

Sync URLs to a collection periodically.

+
+ + +
+
+
+ + setUrl(e.target.value)} + placeholder="https://example.com" + /> +
+
+ + setIntervalMin(Number(e.target.value) || 60)} + /> +
+
+ +

Registered sources

+ {loadingSources ? ( +

Loading...

+ ) : sources.length === 0 ? ( +

No sources. Add one above.

+ ) : ( +
    + {sources.map((s) => ( +
  • + +
    + {s.url} + Every {s.update_interval ?? 60} min +
    + +
  • + ))} +
+ )} +
+ ); +} + +function EntriesTab({ collections, loadingCollections, onRefreshCollections, showToast }) { + const [selectedCollection, setSelectedCollection] = useState(''); + const [entries, setEntries] = useState([]); + const [loadingEntries, setLoadingEntries] = useState(false); + const [deleting, setDeleting] = useState(null); + const [resetting, setResetting] = useState(null); + const [viewContent, setViewContent] = useState(null); + const [loadingContent, setLoadingContent] = useState(null); + + useEffect(() => { + if (!selectedCollection) { + setEntries([]); + return; + } + let cancelled = false; + setLoadingEntries(true); + collectionsApi.listEntries(selectedCollection) + .then((list) => { if (!cancelled) setEntries(Array.isArray(list) ? list : []); }) + .catch((err) => { if (!cancelled) showToast(err.message || 'Failed to load entries', 'error'); }) + .finally(() => { if (!cancelled) setLoadingEntries(false); }); + return () => { cancelled = true; }; + }, [selectedCollection, showToast]); + + const handleDelete = async (entry) => { + if (!selectedCollection) return; + if (!confirm(`Delete "${entry}" from collection?`)) return; + setDeleting(entry); + try { + await collectionsApi.deleteEntry(selectedCollection, entry); + showToast('Entry deleted', 'success'); + setEntries(await collectionsApi.listEntries(selectedCollection)); + } catch (err) { + showToast(err.message || 'Failed to delete', 'error'); + } finally { + setDeleting(null); + } + }; + + const handleReset = async () => { + if (!selectedCollection) return; + if (!confirm(`Reset collection "${selectedCollection}"? This removes all entries.`)) return; + setResetting(selectedCollection); + try { + await collectionsApi.reset(selectedCollection); + showToast('Collection reset', 'success'); + setEntries([]); + onRefreshCollections(); + } catch (err) { + showToast(err.message || 'Failed to reset', 'error'); + } finally { + setResetting(null); + } + }; + + const handleViewContent = async (entry) => { + if (!selectedCollection) return; + setLoadingContent(entry); + try { + const { content, chunkCount } = await collectionsApi.getEntryContent(selectedCollection, entry); + setViewContent({ entry, content, chunkCount }); + } catch (err) { + showToast(err.message || 'Failed to load content', 'error'); + } finally { + setLoadingContent(null); + } + }; + + return ( +
+

Collection entries

+
+ + +
+
+ +
+ {loadingEntries ? ( +

Loading entries...

+ ) : entries.length === 0 ? ( +

Select a collection to view entries.

+ ) : ( +
    + {entries.map((entry) => ( +
  • + + {entry} +
    + + +
    +
  • + ))} +
+ )} + {viewContent && ( +
setViewContent(null)}> +
e.stopPropagation()}> +

Content: {viewContent.entry}

+

{viewContent.chunkCount} chunk(s)

+
{viewContent.content || '(empty)'}
+ +
+
+ )} +
+ ); +} + +export default Knowledge; diff --git a/webui/react-ui/src/router.jsx b/webui/react-ui/src/router.jsx index 3e4ddee3..212a47b6 100644 --- a/webui/react-ui/src/router.jsx +++ b/webui/react-ui/src/router.jsx @@ -11,6 +11,7 @@ import AgentStatus from './pages/AgentStatus'; import ImportAgent from './pages/ImportAgent'; import Skills from './pages/Skills'; import SkillEdit from './pages/SkillEdit'; +import Knowledge from './pages/Knowledge'; // Get the base URL from Vite's environment variables or default to '/app/' const BASE_URL = import.meta.env.BASE_URL || '/app'; @@ -68,6 +69,10 @@ export const router = createBrowserRouter([ { path: 'skills/edit/:name', element: + }, + { + path: 'knowledge', + element: } ] } diff --git a/webui/react-ui/src/utils/api.js b/webui/react-ui/src/utils/api.js index 0b368f66..71128215 100644 --- a/webui/react-ui/src/utils/api.js +++ b/webui/react-ui/src/utils/api.js @@ -24,6 +24,16 @@ const buildUrl = (endpoint) => { return `${API_CONFIG.baseUrl}${endpoint.startsWith('/') ? endpoint.substring(1) : endpoint}`; }; +// Collections API returns { success, message, data, error }. Throw if !ok or !success. +const handleCollectionsResponse = async (response) => { + const data = await response.json().catch(() => ({})); + if (!response.ok || data.success === false) { + const msg = data.error?.message || data.error?.details || data.message || `API error: ${response.status}`; + throw new Error(msg); + } + return data; +}; + // Helper function to convert ActionDefinition to FormFieldDefinition format const convertActionDefinitionToFields = (definition) => { if (!definition || !definition.Properties) { @@ -431,3 +441,84 @@ export const skillsApi = { return handleResponse(response); }, }; + +// Collections / knowledge base API (LocalRecall-compatible) +export const collectionsApi = { + list: async () => { + const response = await fetch(buildUrl(API_CONFIG.endpoints.collections)); + const data = await handleCollectionsResponse(response); + return data.data?.collections || []; + }, + create: async (name) => { + const response = await fetch(buildUrl(API_CONFIG.endpoints.collections), { + method: 'POST', + headers: API_CONFIG.headers, + body: JSON.stringify({ name }), + }); + return handleCollectionsResponse(response); + }, + upload: async (collectionName, file) => { + const form = new FormData(); + form.append('file', file); + const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionUpload(collectionName)), { + method: 'POST', + body: form, + }); + return handleCollectionsResponse(response); + }, + listEntries: async (collectionName) => { + const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionEntries(collectionName))); + const data = await handleCollectionsResponse(response); + return data.data?.entries || []; + }, + getEntryContent: async (collectionName, entry) => { + const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionEntry(collectionName, entry))); + const data = await handleCollectionsResponse(response); + return { content: data.data?.content ?? '', chunkCount: data.data?.chunk_count ?? 0, entry: data.data?.entry ?? entry }; + }, + search: async (collectionName, query, maxResults = 5) => { + const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionSearch(collectionName)), { + method: 'POST', + headers: API_CONFIG.headers, + body: JSON.stringify({ query, max_results: maxResults }), + }); + const data = await handleCollectionsResponse(response); + return data.data?.results || []; + }, + reset: async (collectionName) => { + const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionReset(collectionName)), { + method: 'POST', + headers: API_CONFIG.headers, + }); + return handleCollectionsResponse(response); + }, + deleteEntry: async (collectionName, entry) => { + const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionDeleteEntry(collectionName)), { + method: 'DELETE', + headers: API_CONFIG.headers, + body: JSON.stringify({ entry }), + }); + return handleCollectionsResponse(response); + }, + listSources: async (collectionName) => { + const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionSources(collectionName))); + const data = await handleCollectionsResponse(response); + return data.data?.sources || []; + }, + addSource: async (collectionName, url, updateIntervalMinutes = 60) => { + const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionSources(collectionName)), { + method: 'POST', + headers: API_CONFIG.headers, + body: JSON.stringify({ url, update_interval: updateIntervalMinutes }), + }); + return handleCollectionsResponse(response); + }, + removeSource: async (collectionName, url) => { + const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionSources(collectionName)), { + method: 'DELETE', + headers: API_CONFIG.headers, + body: JSON.stringify({ url }), + }); + return handleCollectionsResponse(response); + }, +}; diff --git a/webui/react-ui/src/utils/config.js b/webui/react-ui/src/utils/config.js index 81203155..55207278 100644 --- a/webui/react-ui/src/utils/config.js +++ b/webui/react-ui/src/utils/config.js @@ -62,5 +62,15 @@ export const API_CONFIG = { gitRepo: (id) => `/api/git-repos/${id}`, gitRepoSync: (id) => `/api/git-repos/${id}/sync`, gitRepoToggle: (id) => `/api/git-repos/${id}/toggle`, + + // Collections / knowledge base (LocalRecall-compatible) + collections: '/api/collections', + collectionUpload: (name) => `/api/collections/${encodeURIComponent(name)}/upload`, + collectionEntries: (name) => `/api/collections/${encodeURIComponent(name)}/entries`, + collectionEntry: (name, entry) => `/api/collections/${encodeURIComponent(name)}/entries/${encodeURIComponent(entry)}`, + collectionSearch: (name) => `/api/collections/${encodeURIComponent(name)}/search`, + collectionReset: (name) => `/api/collections/${encodeURIComponent(name)}/reset`, + collectionDeleteEntry: (name) => `/api/collections/${encodeURIComponent(name)}/entry/delete`, + collectionSources: (name) => `/api/collections/${encodeURIComponent(name)}/sources`, } }; diff --git a/webui/routes.go b/webui/routes.go index a9c97376..9deff93d 100644 --- a/webui/routes.go +++ b/webui/routes.go @@ -212,6 +212,9 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) { webapp.Delete("/api/git-repos/:id", app.DeleteGitRepo) webapp.Post("/api/git-repos/:id/sync", app.SyncGitRepo) webapp.Post("/api/git-repos/:id/toggle", app.ToggleGitRepo) + + // Collections / knowledge base API (LocalRecall-compatible) + app.RegisterCollectionRoutes(webapp, app.config) } var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")