Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ func main() {
webui.WithChunkOverlap(chunkOverlap),
webui.WithDatabaseURL(databaseURL),
webui.WithCollectionAPIKeys(collectionAPIKeys...),
webui.WithLocalRAGURL(localRAG),
)

// Single RAG provider: HTTP client when URL set, in-process when not
Expand Down
102 changes: 102 additions & 0 deletions pkg/localrag/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -516,3 +516,105 @@ func (c *Client) Store(collection, filePath string) error {

return nil
}

// SourceInfo represents an external source for a collection (LocalRecall API contract).
type SourceInfo struct {
URL string `json:"url"`
UpdateInterval int `json:"update_interval"` // minutes
LastUpdate string `json:"last_update"` // RFC3339
}

// AddSource registers an external source for a collection.
func (c *Client) AddSource(collection, url string, updateIntervalMinutes int) error {
reqURL := fmt.Sprintf("%s/api/collections/%s/sources", c.BaseURL, collection)
var body struct {
URL string `json:"url"`
UpdateInterval int `json:"update_interval"`
}
body.URL = url
body.UpdateInterval = updateIntervalMinutes
if body.UpdateInterval < 1 {
body.UpdateInterval = 60
}
payload, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, reqURL, bytes.NewBuffer(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
c.addAuthHeader(req)
resp, err := (&http.Client{}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return parseAPIError(resp, b, "failed to add source")
}
return nil
}

// RemoveSource removes an external source from a collection.
func (c *Client) RemoveSource(collection, url string) error {
reqURL := fmt.Sprintf("%s/api/collections/%s/sources", c.BaseURL, collection)
payload, err := json.Marshal(map[string]string{"url": url})
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodDelete, reqURL, bytes.NewBuffer(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
c.addAuthHeader(req)
resp, err := (&http.Client{}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return parseAPIError(resp, b, "failed to remove source")
}
return nil
}

// ListSources returns external sources for a collection.
func (c *Client) ListSources(collection string) ([]SourceInfo, error) {
reqURL := fmt.Sprintf("%s/api/collections/%s/sources", c.BaseURL, collection)
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
c.addAuthHeader(req)
resp, err := (&http.Client{}).Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, parseAPIError(resp, body, "failed to list sources")
}
var wrap apiResponse
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
if wrap.Error != nil {
return nil, errors.New(wrap.Error.Message)
}
return nil, fmt.Errorf("invalid response: %w", err)
}
var data struct {
Sources []SourceInfo `json:"sources"`
}
if err := json.Unmarshal(wrap.Data, &data); err != nil {
return nil, err
}
return data.Sources, nil
}
39 changes: 39 additions & 0 deletions webui/collections_backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package webui

import (
"io"
"time"
)

// CollectionSearchResult is a single search result (content + metadata) for API responses.
type CollectionSearchResult struct {
Content string `json:"content"`
Metadata map[string]string `json:"metadata,omitempty"`
ID string `json:"id,omitempty"`
Similarity float32 `json:"similarity,omitempty"`
}

// CollectionSourceInfo is a single external source for a collection.
type CollectionSourceInfo struct {
URL string `json:"url"`
UpdateInterval int `json:"update_interval"` // minutes
LastUpdate time.Time `json:"last_update"`
}

// CollectionsBackend is the interface used by REST handlers for collection operations.
// It is implemented by in-process state (embedded) or by an HTTP client (when LocalRAG URL is set).
type CollectionsBackend interface {
ListCollections() ([]string, error)
CreateCollection(name string) error
Upload(collection, filename string, fileBody io.Reader) error
ListEntries(collection string) ([]string, error)
GetEntryContent(collection, entry string) (content string, chunkCount int, err error)
Search(collection, query string, maxResults int) ([]CollectionSearchResult, error)
Reset(collection string) error
DeleteEntry(collection, entry string) (remainingEntries []string, err error)
AddSource(collection, url string, intervalMin int) error
RemoveSource(collection, url string) error
ListSources(collection string) ([]CollectionSourceInfo, error)
// EntryExists is used by upload handler to avoid duplicate entries.
EntryExists(collection, entry string) bool
}
128 changes: 128 additions & 0 deletions webui/collections_backend_http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package webui

import (
"io"
"os"
"path/filepath"
"time"

"github.com/mudler/LocalAGI/pkg/localrag"
)

// collectionsBackendHTTP implements CollectionsBackend using the LocalRAG HTTP API.
type collectionsBackendHTTP struct {
client *localrag.Client
}

var _ CollectionsBackend = (*collectionsBackendHTTP)(nil)

// NewCollectionsBackendHTTP returns a CollectionsBackend that delegates to the given HTTP client.
func NewCollectionsBackendHTTP(client *localrag.Client) CollectionsBackend {
return &collectionsBackendHTTP{client: client}
}

func (b *collectionsBackendHTTP) ListCollections() ([]string, error) {
return b.client.ListCollections()
}

func (b *collectionsBackendHTTP) CreateCollection(name string) error {
return b.client.CreateCollection(name)
}

func (b *collectionsBackendHTTP) Upload(collection, filename string, fileBody io.Reader) error {
tmpDir, err := os.MkdirTemp("", "localagi-upload")
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)
tmpPath := filepath.Join(tmpDir, filename)
out, err := os.Create(tmpPath)
if err != nil {
return err
Comment on lines +38 to +41
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upload builds tmpPath := filepath.Join(tmpDir, filename) from a user-controlled filename. If filename is absolute or contains ../path separators, Join can escape tmpDir and write to arbitrary locations before the deferred cleanup runs. Sanitize the name (e.g., filepath.Base + reject path traversal/absolute paths) before constructing tmpPath.

Copilot uses AI. Check for mistakes.
}
if _, err := io.Copy(out, fileBody); err != nil {
out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
return b.client.Store(collection, tmpPath)
}

func (b *collectionsBackendHTTP) ListEntries(collection string) ([]string, error) {
return b.client.ListEntries(collection)
}

func (b *collectionsBackendHTTP) GetEntryContent(collection, entry string) (string, int, error) {
return b.client.GetEntryContent(collection, entry)
}

func (b *collectionsBackendHTTP) Search(collection, query string, maxResults int) ([]CollectionSearchResult, error) {
if maxResults <= 0 {
maxResults = 5
}
results, err := b.client.Search(collection, query, maxResults)
if err != nil {
return nil, err
}
out := make([]CollectionSearchResult, 0, len(results))
for _, r := range results {
out = append(out, CollectionSearchResult{
ID: r.ID,
Content: r.Content,
Metadata: r.Metadata,
Similarity: r.Similarity,
})
}
return out, nil
}

func (b *collectionsBackendHTTP) Reset(collection string) error {
return b.client.Reset(collection)
}

func (b *collectionsBackendHTTP) DeleteEntry(collection, entry string) ([]string, error) {
return b.client.DeleteEntry(collection, entry)
}

func (b *collectionsBackendHTTP) AddSource(collection, url string, intervalMin int) error {
return b.client.AddSource(collection, url, intervalMin)
}

func (b *collectionsBackendHTTP) RemoveSource(collection, url string) error {
return b.client.RemoveSource(collection, url)
}

func (b *collectionsBackendHTTP) ListSources(collection string) ([]CollectionSourceInfo, error) {
srcs, err := b.client.ListSources(collection)
if err != nil {
return nil, err
}
out := make([]CollectionSourceInfo, 0, len(srcs))
for _, s := range srcs {
var lastUpdate time.Time
if s.LastUpdate != "" {
lastUpdate, _ = time.Parse(time.RFC3339, s.LastUpdate)
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ListSources ignores time.Parse errors (lastUpdate, _ = time.Parse(...)). If the server ever returns a malformed timestamp, this silently becomes the zero time and will serialize as 0001-01-01T00:00:00Z in responses. Consider handling the parse error (return it, or at least keep LastUpdate unset/zero only when the input is empty) so clients don't get misleading timestamps.

Suggested change
lastUpdate, _ = time.Parse(time.RFC3339, s.LastUpdate)
lastUpdate, err = time.Parse(time.RFC3339, s.LastUpdate)
if err != nil {
return nil, err
}

Copilot uses AI. Check for mistakes.
}
out = append(out, CollectionSourceInfo{
URL: s.URL,
UpdateInterval: s.UpdateInterval,
LastUpdate: lastUpdate,
})
}
return out, nil
}

func (b *collectionsBackendHTTP) EntryExists(collection, entry string) bool {
entries, err := b.client.ListEntries(collection)
if err != nil {
return false
}
for _, e := range entries {
if e == entry {
return true
}
}
return false
Comment on lines +117 to +127
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EntryExists for the HTTP backend calls ListEntries and scans the full list on every upload. For large collections this adds an extra network round-trip and O(n) work per upload. Prefer relying on Store/Upload returning a conflict error (and mapping it to 409) or add a dedicated endpoint to check existence (or cache entries) to avoid listing the whole collection each time.

Copilot uses AI. Check for mistakes.
}
Loading
Loading