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
- 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) => (
+ setTab(t.id)}
+ >
+
+ {t.label}
+
+ ))}
+
+
+
+ {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}
}
+
+ Collection
+ setSelectedCollection(e.target.value)}
+ disabled={loadingCollections}
+ >
+ Select a collection
+ {collections.map((c) => (
+ {c}
+ ))}
+
+
+
+ Query
+ setQuery(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
+ placeholder="Enter search query..."
+ />
+
+
+
+ Max results
+ setMaxResults(Number(e.target.value) || 5)}
+ />
+
+
+ {loading ? : }
+ {loading ? 'Searching...' : 'Search'}
+
+
+
+
Results
+ {results.length > 0 ? (
+
+ ) : (
+ !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"
+ />
+
+ {creating ? : }
+ {creating ? 'Creating...' : 'Create'}
+
+
+
+
Your collections
+
+
+
+
+ {loadingCollections ? (
+ Loading...
+ ) : collections.length === 0 ? (
+ No collections. Create one above.
+ ) : (
+
+ {collections.map((c) => (
+
+
+ {c}
+ handleReset(c)}
+ disabled={resetting === c}
+ title="Reset collection"
+ >
+ {resetting === 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 (
+
+ );
+}
+
+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.
+
+ Collection
+ setSelectedCollection(e.target.value)}
+ disabled={loadingCollections}
+ >
+ Select a collection
+ {collections.map((c) => (
+ {c}
+ ))}
+
+
+
+
+ {adding ? : }
+ {adding ? 'Adding...' : 'Add source'}
+
+ Registered sources
+ {loadingSources ? (
+ Loading...
+ ) : sources.length === 0 ? (
+ No sources. Add one above.
+ ) : (
+
+ )}
+
+ );
+}
+
+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
+
+ Collection
+ setSelectedCollection(e.target.value)}
+ disabled={loadingCollections}
+ >
+ Select a collection
+ {collections.map((c) => (
+ {c}
+ ))}
+
+
+
+
+ {resetting === selectedCollection ? : }
+ Reset collection
+
+
+ {loadingEntries ? (
+ Loading entries...
+ ) : entries.length === 0 ? (
+ Select a collection to view entries.
+ ) : (
+
+ )}
+ {viewContent && (
+ setViewContent(null)}>
+
e.stopPropagation()}>
+
Content: {viewContent.entry}
+
{viewContent.chunkCount} chunk(s)
+
{viewContent.content || '(empty)'}
+
setViewContent(null)}>Close
+
+
+ )}
+
+ );
+}
+
+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")