Skip to content

Commit 410ba7a

Browse files
authored
feat: allow to manage external localrecall instances (#425)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent c56cc43 commit 410ba7a

File tree

8 files changed

+635
-220
lines changed

8 files changed

+635
-220
lines changed

main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ func main() {
153153
webui.WithChunkOverlap(chunkOverlap),
154154
webui.WithDatabaseURL(databaseURL),
155155
webui.WithCollectionAPIKeys(collectionAPIKeys...),
156+
webui.WithLocalRAGURL(localRAG),
156157
)
157158

158159
// Single RAG provider: HTTP client when URL set, in-process when not

pkg/localrag/client.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,3 +516,105 @@ func (c *Client) Store(collection, filePath string) error {
516516

517517
return nil
518518
}
519+
520+
// SourceInfo represents an external source for a collection (LocalRecall API contract).
521+
type SourceInfo struct {
522+
URL string `json:"url"`
523+
UpdateInterval int `json:"update_interval"` // minutes
524+
LastUpdate string `json:"last_update"` // RFC3339
525+
}
526+
527+
// AddSource registers an external source for a collection.
528+
func (c *Client) AddSource(collection, url string, updateIntervalMinutes int) error {
529+
reqURL := fmt.Sprintf("%s/api/collections/%s/sources", c.BaseURL, collection)
530+
var body struct {
531+
URL string `json:"url"`
532+
UpdateInterval int `json:"update_interval"`
533+
}
534+
body.URL = url
535+
body.UpdateInterval = updateIntervalMinutes
536+
if body.UpdateInterval < 1 {
537+
body.UpdateInterval = 60
538+
}
539+
payload, err := json.Marshal(body)
540+
if err != nil {
541+
return err
542+
}
543+
req, err := http.NewRequest(http.MethodPost, reqURL, bytes.NewBuffer(payload))
544+
if err != nil {
545+
return err
546+
}
547+
req.Header.Set("Content-Type", "application/json")
548+
c.addAuthHeader(req)
549+
resp, err := (&http.Client{}).Do(req)
550+
if err != nil {
551+
return err
552+
}
553+
defer resp.Body.Close()
554+
if resp.StatusCode != http.StatusOK {
555+
b, _ := io.ReadAll(resp.Body)
556+
return parseAPIError(resp, b, "failed to add source")
557+
}
558+
return nil
559+
}
560+
561+
// RemoveSource removes an external source from a collection.
562+
func (c *Client) RemoveSource(collection, url string) error {
563+
reqURL := fmt.Sprintf("%s/api/collections/%s/sources", c.BaseURL, collection)
564+
payload, err := json.Marshal(map[string]string{"url": url})
565+
if err != nil {
566+
return err
567+
}
568+
req, err := http.NewRequest(http.MethodDelete, reqURL, bytes.NewBuffer(payload))
569+
if err != nil {
570+
return err
571+
}
572+
req.Header.Set("Content-Type", "application/json")
573+
c.addAuthHeader(req)
574+
resp, err := (&http.Client{}).Do(req)
575+
if err != nil {
576+
return err
577+
}
578+
defer resp.Body.Close()
579+
if resp.StatusCode != http.StatusOK {
580+
b, _ := io.ReadAll(resp.Body)
581+
return parseAPIError(resp, b, "failed to remove source")
582+
}
583+
return nil
584+
}
585+
586+
// ListSources returns external sources for a collection.
587+
func (c *Client) ListSources(collection string) ([]SourceInfo, error) {
588+
reqURL := fmt.Sprintf("%s/api/collections/%s/sources", c.BaseURL, collection)
589+
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
590+
if err != nil {
591+
return nil, err
592+
}
593+
c.addAuthHeader(req)
594+
resp, err := (&http.Client{}).Do(req)
595+
if err != nil {
596+
return nil, err
597+
}
598+
defer resp.Body.Close()
599+
body, err := io.ReadAll(resp.Body)
600+
if err != nil {
601+
return nil, err
602+
}
603+
if resp.StatusCode != http.StatusOK {
604+
return nil, parseAPIError(resp, body, "failed to list sources")
605+
}
606+
var wrap apiResponse
607+
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
608+
if wrap.Error != nil {
609+
return nil, errors.New(wrap.Error.Message)
610+
}
611+
return nil, fmt.Errorf("invalid response: %w", err)
612+
}
613+
var data struct {
614+
Sources []SourceInfo `json:"sources"`
615+
}
616+
if err := json.Unmarshal(wrap.Data, &data); err != nil {
617+
return nil, err
618+
}
619+
return data.Sources, nil
620+
}

webui/collections_backend.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package webui
2+
3+
import (
4+
"io"
5+
"time"
6+
)
7+
8+
// CollectionSearchResult is a single search result (content + metadata) for API responses.
9+
type CollectionSearchResult struct {
10+
Content string `json:"content"`
11+
Metadata map[string]string `json:"metadata,omitempty"`
12+
ID string `json:"id,omitempty"`
13+
Similarity float32 `json:"similarity,omitempty"`
14+
}
15+
16+
// CollectionSourceInfo is a single external source for a collection.
17+
type CollectionSourceInfo struct {
18+
URL string `json:"url"`
19+
UpdateInterval int `json:"update_interval"` // minutes
20+
LastUpdate time.Time `json:"last_update"`
21+
}
22+
23+
// CollectionsBackend is the interface used by REST handlers for collection operations.
24+
// It is implemented by in-process state (embedded) or by an HTTP client (when LocalRAG URL is set).
25+
type CollectionsBackend interface {
26+
ListCollections() ([]string, error)
27+
CreateCollection(name string) error
28+
Upload(collection, filename string, fileBody io.Reader) error
29+
ListEntries(collection string) ([]string, error)
30+
GetEntryContent(collection, entry string) (content string, chunkCount int, err error)
31+
Search(collection, query string, maxResults int) ([]CollectionSearchResult, error)
32+
Reset(collection string) error
33+
DeleteEntry(collection, entry string) (remainingEntries []string, err error)
34+
AddSource(collection, url string, intervalMin int) error
35+
RemoveSource(collection, url string) error
36+
ListSources(collection string) ([]CollectionSourceInfo, error)
37+
// EntryExists is used by upload handler to avoid duplicate entries.
38+
EntryExists(collection, entry string) bool
39+
}

webui/collections_backend_http.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package webui
2+
3+
import (
4+
"io"
5+
"os"
6+
"path/filepath"
7+
"time"
8+
9+
"github.com/mudler/LocalAGI/pkg/localrag"
10+
)
11+
12+
// collectionsBackendHTTP implements CollectionsBackend using the LocalRAG HTTP API.
13+
type collectionsBackendHTTP struct {
14+
client *localrag.Client
15+
}
16+
17+
var _ CollectionsBackend = (*collectionsBackendHTTP)(nil)
18+
19+
// NewCollectionsBackendHTTP returns a CollectionsBackend that delegates to the given HTTP client.
20+
func NewCollectionsBackendHTTP(client *localrag.Client) CollectionsBackend {
21+
return &collectionsBackendHTTP{client: client}
22+
}
23+
24+
func (b *collectionsBackendHTTP) ListCollections() ([]string, error) {
25+
return b.client.ListCollections()
26+
}
27+
28+
func (b *collectionsBackendHTTP) CreateCollection(name string) error {
29+
return b.client.CreateCollection(name)
30+
}
31+
32+
func (b *collectionsBackendHTTP) Upload(collection, filename string, fileBody io.Reader) error {
33+
tmpDir, err := os.MkdirTemp("", "localagi-upload")
34+
if err != nil {
35+
return err
36+
}
37+
defer os.RemoveAll(tmpDir)
38+
tmpPath := filepath.Join(tmpDir, filename)
39+
out, err := os.Create(tmpPath)
40+
if err != nil {
41+
return err
42+
}
43+
if _, err := io.Copy(out, fileBody); err != nil {
44+
out.Close()
45+
return err
46+
}
47+
if err := out.Close(); err != nil {
48+
return err
49+
}
50+
return b.client.Store(collection, tmpPath)
51+
}
52+
53+
func (b *collectionsBackendHTTP) ListEntries(collection string) ([]string, error) {
54+
return b.client.ListEntries(collection)
55+
}
56+
57+
func (b *collectionsBackendHTTP) GetEntryContent(collection, entry string) (string, int, error) {
58+
return b.client.GetEntryContent(collection, entry)
59+
}
60+
61+
func (b *collectionsBackendHTTP) Search(collection, query string, maxResults int) ([]CollectionSearchResult, error) {
62+
if maxResults <= 0 {
63+
maxResults = 5
64+
}
65+
results, err := b.client.Search(collection, query, maxResults)
66+
if err != nil {
67+
return nil, err
68+
}
69+
out := make([]CollectionSearchResult, 0, len(results))
70+
for _, r := range results {
71+
out = append(out, CollectionSearchResult{
72+
ID: r.ID,
73+
Content: r.Content,
74+
Metadata: r.Metadata,
75+
Similarity: r.Similarity,
76+
})
77+
}
78+
return out, nil
79+
}
80+
81+
func (b *collectionsBackendHTTP) Reset(collection string) error {
82+
return b.client.Reset(collection)
83+
}
84+
85+
func (b *collectionsBackendHTTP) DeleteEntry(collection, entry string) ([]string, error) {
86+
return b.client.DeleteEntry(collection, entry)
87+
}
88+
89+
func (b *collectionsBackendHTTP) AddSource(collection, url string, intervalMin int) error {
90+
return b.client.AddSource(collection, url, intervalMin)
91+
}
92+
93+
func (b *collectionsBackendHTTP) RemoveSource(collection, url string) error {
94+
return b.client.RemoveSource(collection, url)
95+
}
96+
97+
func (b *collectionsBackendHTTP) ListSources(collection string) ([]CollectionSourceInfo, error) {
98+
srcs, err := b.client.ListSources(collection)
99+
if err != nil {
100+
return nil, err
101+
}
102+
out := make([]CollectionSourceInfo, 0, len(srcs))
103+
for _, s := range srcs {
104+
var lastUpdate time.Time
105+
if s.LastUpdate != "" {
106+
lastUpdate, _ = time.Parse(time.RFC3339, s.LastUpdate)
107+
}
108+
out = append(out, CollectionSourceInfo{
109+
URL: s.URL,
110+
UpdateInterval: s.UpdateInterval,
111+
LastUpdate: lastUpdate,
112+
})
113+
}
114+
return out, nil
115+
}
116+
117+
func (b *collectionsBackendHTTP) EntryExists(collection, entry string) bool {
118+
entries, err := b.client.ListEntries(collection)
119+
if err != nil {
120+
return false
121+
}
122+
for _, e := range entries {
123+
if e == entry {
124+
return true
125+
}
126+
}
127+
return false
128+
}

0 commit comments

Comments
 (0)