mirror of
https://github.com/mudler/LocalAI.git
synced 2026-01-04 17:50:13 -06:00
feat(ui): allow to order search results (#7507)
* feat(ui): improve table view and let items to be sorted Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactorings Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore: add tests Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore: use constants Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
committed by
GitHub
parent
e1d060d147
commit
3b5c2ea633
@@ -45,6 +45,14 @@ func (backend *GalleryBackend) FindBestBackendFromMeta(systemState *system.Syste
|
||||
return backends.FindByName(realBackend)
|
||||
}
|
||||
|
||||
func (m *GalleryBackend) GetInstalled() bool {
|
||||
return m.Installed
|
||||
}
|
||||
|
||||
func (m *GalleryBackend) GetLicense() string {
|
||||
return m.License
|
||||
}
|
||||
|
||||
type GalleryBackends []*GalleryBackend
|
||||
|
||||
func (m *GalleryBackend) SetGallery(gallery config.Gallery) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -67,6 +68,8 @@ type GalleryElement interface {
|
||||
GetName() string
|
||||
GetDescription() string
|
||||
GetTags() []string
|
||||
GetInstalled() bool
|
||||
GetLicense() string
|
||||
GetGallery() config.Gallery
|
||||
}
|
||||
|
||||
@@ -89,6 +92,69 @@ func (gm GalleryElements[T]) Search(term string) GalleryElements[T] {
|
||||
return filteredModels
|
||||
}
|
||||
|
||||
func (gm GalleryElements[T]) SortByName(sortOrder string) GalleryElements[T] {
|
||||
sort.Slice(gm, func(i, j int) bool {
|
||||
if sortOrder == "asc" {
|
||||
return strings.ToLower(gm[i].GetName()) < strings.ToLower(gm[j].GetName())
|
||||
} else {
|
||||
return strings.ToLower(gm[i].GetName()) > strings.ToLower(gm[j].GetName())
|
||||
}
|
||||
})
|
||||
return gm
|
||||
}
|
||||
|
||||
func (gm GalleryElements[T]) SortByRepository(sortOrder string) GalleryElements[T] {
|
||||
sort.Slice(gm, func(i, j int) bool {
|
||||
if sortOrder == "asc" {
|
||||
return strings.ToLower(gm[i].GetGallery().Name) < strings.ToLower(gm[j].GetGallery().Name)
|
||||
} else {
|
||||
return strings.ToLower(gm[i].GetGallery().Name) > strings.ToLower(gm[j].GetGallery().Name)
|
||||
}
|
||||
})
|
||||
return gm
|
||||
}
|
||||
|
||||
func (gm GalleryElements[T]) SortByLicense(sortOrder string) GalleryElements[T] {
|
||||
sort.Slice(gm, func(i, j int) bool {
|
||||
licenseI := gm[i].GetLicense()
|
||||
licenseJ := gm[j].GetLicense()
|
||||
var result bool
|
||||
if licenseI == "" && licenseJ != "" {
|
||||
return sortOrder == "desc"
|
||||
} else if licenseI != "" && licenseJ == "" {
|
||||
return sortOrder == "asc"
|
||||
} else if licenseI == "" && licenseJ == "" {
|
||||
return false
|
||||
} else {
|
||||
result = strings.ToLower(licenseI) < strings.ToLower(licenseJ)
|
||||
}
|
||||
if sortOrder == "desc" {
|
||||
return !result
|
||||
} else {
|
||||
return result
|
||||
}
|
||||
})
|
||||
return gm
|
||||
}
|
||||
|
||||
func (gm GalleryElements[T]) SortByInstalled(sortOrder string) GalleryElements[T] {
|
||||
sort.Slice(gm, func(i, j int) bool {
|
||||
var result bool
|
||||
// Sort by installed status: installed items first (true > false)
|
||||
if gm[i].GetInstalled() != gm[j].GetInstalled() {
|
||||
result = gm[i].GetInstalled()
|
||||
} else {
|
||||
result = strings.ToLower(gm[i].GetName()) < strings.ToLower(gm[j].GetName())
|
||||
}
|
||||
if sortOrder == "desc" {
|
||||
return !result
|
||||
} else {
|
||||
return result
|
||||
}
|
||||
})
|
||||
return gm
|
||||
}
|
||||
|
||||
func (gm GalleryElements[T]) FindByName(name string) T {
|
||||
for _, m := range gm {
|
||||
if strings.EqualFold(m.GetName(), name) {
|
||||
|
||||
465
core/gallery/gallery_test.go
Normal file
465
core/gallery/gallery_test.go
Normal file
@@ -0,0 +1,465 @@
|
||||
package gallery_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
. "github.com/mudler/LocalAI/core/gallery"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
var _ = Describe("Gallery", func() {
|
||||
var tempDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
tempDir, err = os.MkdirTemp("", "gallery-test-*")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(tempDir)
|
||||
})
|
||||
|
||||
Describe("ReadConfigFile", func() {
|
||||
It("should read and unmarshal a valid YAML file", func() {
|
||||
testConfig := map[string]interface{}{
|
||||
"name": "test-model",
|
||||
"description": "A test model",
|
||||
"license": "MIT",
|
||||
}
|
||||
yamlData, err := yaml.Marshal(testConfig)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
filePath := filepath.Join(tempDir, "test.yaml")
|
||||
err = os.WriteFile(filePath, yamlData, 0644)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
var result map[string]interface{}
|
||||
config, err := ReadConfigFile[map[string]interface{}](filePath)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(config).NotTo(BeNil())
|
||||
result = *config
|
||||
Expect(result["name"]).To(Equal("test-model"))
|
||||
Expect(result["description"]).To(Equal("A test model"))
|
||||
Expect(result["license"]).To(Equal("MIT"))
|
||||
})
|
||||
|
||||
It("should return error when file does not exist", func() {
|
||||
_, err := ReadConfigFile[map[string]interface{}]("nonexistent.yaml")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error when YAML is invalid", func() {
|
||||
filePath := filepath.Join(tempDir, "invalid.yaml")
|
||||
err := os.WriteFile(filePath, []byte("invalid: yaml: content: [unclosed"), 0644)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = ReadConfigFile[map[string]interface{}](filePath)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GalleryElements Search", func() {
|
||||
var elements GalleryElements[*GalleryModel]
|
||||
|
||||
BeforeEach(func() {
|
||||
elements = GalleryElements[*GalleryModel]{
|
||||
{
|
||||
Metadata: Metadata{
|
||||
Name: "bert-embeddings",
|
||||
Description: "BERT model for embeddings",
|
||||
Tags: []string{"embeddings", "bert", "nlp"},
|
||||
License: "Apache-2.0",
|
||||
Gallery: config.Gallery{
|
||||
Name: "huggingface",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Metadata: Metadata{
|
||||
Name: "gpt-2",
|
||||
Description: "GPT-2 language model",
|
||||
Tags: []string{"gpt", "language-model"},
|
||||
License: "MIT",
|
||||
Gallery: config.Gallery{
|
||||
Name: "openai",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Metadata: Metadata{
|
||||
Name: "llama-7b",
|
||||
Description: "LLaMA 7B model",
|
||||
Tags: []string{"llama", "llm"},
|
||||
License: "LLaMA",
|
||||
Gallery: config.Gallery{
|
||||
Name: "meta",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("should find elements by exact name match", func() {
|
||||
results := elements.Search("bert-embeddings")
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].GetName()).To(Equal("bert-embeddings"))
|
||||
})
|
||||
|
||||
It("should find elements by partial name match", func() {
|
||||
results := elements.Search("bert")
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].GetName()).To(Equal("bert-embeddings"))
|
||||
})
|
||||
|
||||
It("should find elements by description", func() {
|
||||
results := elements.Search("embeddings")
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].GetName()).To(Equal("bert-embeddings"))
|
||||
})
|
||||
|
||||
It("should find elements by gallery name", func() {
|
||||
results := elements.Search("huggingface")
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].GetGallery().Name).To(Equal("huggingface"))
|
||||
})
|
||||
|
||||
It("should find elements by tags", func() {
|
||||
results := elements.Search("nlp")
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].GetName()).To(Equal("bert-embeddings"))
|
||||
})
|
||||
|
||||
It("should be case insensitive", func() {
|
||||
results := elements.Search("BERT")
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].GetName()).To(Equal("bert-embeddings"))
|
||||
})
|
||||
|
||||
It("should find multiple elements", func() {
|
||||
results := elements.Search("gpt")
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].GetName()).To(Equal("gpt-2"))
|
||||
})
|
||||
|
||||
It("should return empty results for no matches", func() {
|
||||
results := elements.Search("nonexistent")
|
||||
Expect(results).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("should use fuzzy matching", func() {
|
||||
results := elements.Search("bert-emb")
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].GetName()).To(Equal("bert-embeddings"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GalleryElements SortByName", func() {
|
||||
var elements GalleryElements[*GalleryModel]
|
||||
|
||||
BeforeEach(func() {
|
||||
elements = GalleryElements[*GalleryModel]{
|
||||
{Metadata: Metadata{Name: "zebra"}},
|
||||
{Metadata: Metadata{Name: "alpha"}},
|
||||
{Metadata: Metadata{Name: "beta"}},
|
||||
}
|
||||
})
|
||||
|
||||
It("should sort ascending", func() {
|
||||
sorted := elements.SortByName("asc")
|
||||
Expect(sorted).To(HaveLen(3))
|
||||
Expect(sorted[0].GetName()).To(Equal("alpha"))
|
||||
Expect(sorted[1].GetName()).To(Equal("beta"))
|
||||
Expect(sorted[2].GetName()).To(Equal("zebra"))
|
||||
})
|
||||
|
||||
It("should sort descending", func() {
|
||||
sorted := elements.SortByName("desc")
|
||||
Expect(sorted).To(HaveLen(3))
|
||||
Expect(sorted[0].GetName()).To(Equal("zebra"))
|
||||
Expect(sorted[1].GetName()).To(Equal("beta"))
|
||||
Expect(sorted[2].GetName()).To(Equal("alpha"))
|
||||
})
|
||||
|
||||
It("should be case insensitive", func() {
|
||||
elements = GalleryElements[*GalleryModel]{
|
||||
{Metadata: Metadata{Name: "Zebra"}},
|
||||
{Metadata: Metadata{Name: "alpha"}},
|
||||
{Metadata: Metadata{Name: "Beta"}},
|
||||
}
|
||||
sorted := elements.SortByName("asc")
|
||||
Expect(sorted[0].GetName()).To(Equal("alpha"))
|
||||
Expect(sorted[1].GetName()).To(Equal("Beta"))
|
||||
Expect(sorted[2].GetName()).To(Equal("Zebra"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GalleryElements SortByRepository", func() {
|
||||
var elements GalleryElements[*GalleryModel]
|
||||
|
||||
BeforeEach(func() {
|
||||
elements = GalleryElements[*GalleryModel]{
|
||||
{
|
||||
Metadata: Metadata{
|
||||
Gallery: config.Gallery{Name: "zebra-repo"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Metadata: Metadata{
|
||||
Gallery: config.Gallery{Name: "alpha-repo"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Metadata: Metadata{
|
||||
Gallery: config.Gallery{Name: "beta-repo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("should sort ascending", func() {
|
||||
sorted := elements.SortByRepository("asc")
|
||||
Expect(sorted).To(HaveLen(3))
|
||||
Expect(sorted[0].GetGallery().Name).To(Equal("alpha-repo"))
|
||||
Expect(sorted[1].GetGallery().Name).To(Equal("beta-repo"))
|
||||
Expect(sorted[2].GetGallery().Name).To(Equal("zebra-repo"))
|
||||
})
|
||||
|
||||
It("should sort descending", func() {
|
||||
sorted := elements.SortByRepository("desc")
|
||||
Expect(sorted).To(HaveLen(3))
|
||||
Expect(sorted[0].GetGallery().Name).To(Equal("zebra-repo"))
|
||||
Expect(sorted[1].GetGallery().Name).To(Equal("beta-repo"))
|
||||
Expect(sorted[2].GetGallery().Name).To(Equal("alpha-repo"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GalleryElements SortByLicense", func() {
|
||||
var elements GalleryElements[*GalleryModel]
|
||||
|
||||
BeforeEach(func() {
|
||||
elements = GalleryElements[*GalleryModel]{
|
||||
{Metadata: Metadata{License: "MIT"}},
|
||||
{Metadata: Metadata{License: "Apache-2.0"}},
|
||||
{Metadata: Metadata{License: ""}},
|
||||
{Metadata: Metadata{License: "GPL-3.0"}},
|
||||
}
|
||||
})
|
||||
|
||||
It("should sort ascending with empty licenses at end", func() {
|
||||
sorted := elements.SortByLicense("asc")
|
||||
Expect(sorted).To(HaveLen(4))
|
||||
Expect(sorted[0].GetLicense()).To(Equal("Apache-2.0"))
|
||||
Expect(sorted[1].GetLicense()).To(Equal("GPL-3.0"))
|
||||
Expect(sorted[2].GetLicense()).To(Equal("MIT"))
|
||||
Expect(sorted[3].GetLicense()).To(Equal(""))
|
||||
})
|
||||
|
||||
It("should sort descending with empty licenses at beginning", func() {
|
||||
sorted := elements.SortByLicense("desc")
|
||||
Expect(sorted).To(HaveLen(4))
|
||||
Expect(sorted[0].GetLicense()).To(Equal(""))
|
||||
Expect(sorted[1].GetLicense()).To(Equal("MIT"))
|
||||
Expect(sorted[2].GetLicense()).To(Equal("GPL-3.0"))
|
||||
Expect(sorted[3].GetLicense()).To(Equal("Apache-2.0"))
|
||||
})
|
||||
|
||||
It("should handle all empty licenses", func() {
|
||||
elements = GalleryElements[*GalleryModel]{
|
||||
{Metadata: Metadata{License: ""}},
|
||||
{Metadata: Metadata{License: ""}},
|
||||
}
|
||||
sorted := elements.SortByLicense("asc")
|
||||
Expect(sorted).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GalleryElements SortByInstalled", func() {
|
||||
var elements GalleryElements[*GalleryModel]
|
||||
|
||||
BeforeEach(func() {
|
||||
elements = GalleryElements[*GalleryModel]{
|
||||
{Metadata: Metadata{Name: "installed-2", Installed: true}},
|
||||
{Metadata: Metadata{Name: "not-installed-1", Installed: false}},
|
||||
{Metadata: Metadata{Name: "installed-1", Installed: true}},
|
||||
{Metadata: Metadata{Name: "not-installed-2", Installed: false}},
|
||||
}
|
||||
})
|
||||
|
||||
It("should sort ascending with installed first, then by name", func() {
|
||||
sorted := elements.SortByInstalled("asc")
|
||||
Expect(sorted).To(HaveLen(4))
|
||||
Expect(sorted[0].GetInstalled()).To(BeTrue())
|
||||
Expect(sorted[0].GetName()).To(Equal("installed-1"))
|
||||
Expect(sorted[1].GetInstalled()).To(BeTrue())
|
||||
Expect(sorted[1].GetName()).To(Equal("installed-2"))
|
||||
Expect(sorted[2].GetInstalled()).To(BeFalse())
|
||||
Expect(sorted[2].GetName()).To(Equal("not-installed-1"))
|
||||
Expect(sorted[3].GetInstalled()).To(BeFalse())
|
||||
Expect(sorted[3].GetName()).To(Equal("not-installed-2"))
|
||||
})
|
||||
|
||||
It("should sort descending with not-installed first, then by name", func() {
|
||||
sorted := elements.SortByInstalled("desc")
|
||||
Expect(sorted).To(HaveLen(4))
|
||||
Expect(sorted[0].GetInstalled()).To(BeFalse())
|
||||
Expect(sorted[0].GetName()).To(Equal("not-installed-2"))
|
||||
Expect(sorted[1].GetInstalled()).To(BeFalse())
|
||||
Expect(sorted[1].GetName()).To(Equal("not-installed-1"))
|
||||
Expect(sorted[2].GetInstalled()).To(BeTrue())
|
||||
Expect(sorted[2].GetName()).To(Equal("installed-2"))
|
||||
Expect(sorted[3].GetInstalled()).To(BeTrue())
|
||||
Expect(sorted[3].GetName()).To(Equal("installed-1"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GalleryElements FindByName", func() {
|
||||
var elements GalleryElements[*GalleryModel]
|
||||
|
||||
BeforeEach(func() {
|
||||
elements = GalleryElements[*GalleryModel]{
|
||||
{Metadata: Metadata{Name: "bert-embeddings"}},
|
||||
{Metadata: Metadata{Name: "gpt-2"}},
|
||||
{Metadata: Metadata{Name: "llama-7b"}},
|
||||
}
|
||||
})
|
||||
|
||||
It("should find element by exact name", func() {
|
||||
result := elements.FindByName("bert-embeddings")
|
||||
Expect(result).NotTo(BeNil())
|
||||
Expect(result.GetName()).To(Equal("bert-embeddings"))
|
||||
})
|
||||
|
||||
It("should be case insensitive", func() {
|
||||
result := elements.FindByName("BERT-EMBEDDINGS")
|
||||
Expect(result).NotTo(BeNil())
|
||||
Expect(result.GetName()).To(Equal("bert-embeddings"))
|
||||
})
|
||||
|
||||
It("should return zero value when not found", func() {
|
||||
result := elements.FindByName("nonexistent")
|
||||
Expect(result).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GalleryElements Paginate", func() {
|
||||
var elements GalleryElements[*GalleryModel]
|
||||
|
||||
BeforeEach(func() {
|
||||
elements = GalleryElements[*GalleryModel]{
|
||||
{Metadata: Metadata{Name: "model-1"}},
|
||||
{Metadata: Metadata{Name: "model-2"}},
|
||||
{Metadata: Metadata{Name: "model-3"}},
|
||||
{Metadata: Metadata{Name: "model-4"}},
|
||||
{Metadata: Metadata{Name: "model-5"}},
|
||||
}
|
||||
})
|
||||
|
||||
It("should return first page", func() {
|
||||
page := elements.Paginate(1, 2)
|
||||
Expect(page).To(HaveLen(2))
|
||||
Expect(page[0].GetName()).To(Equal("model-1"))
|
||||
Expect(page[1].GetName()).To(Equal("model-2"))
|
||||
})
|
||||
|
||||
It("should return second page", func() {
|
||||
page := elements.Paginate(2, 2)
|
||||
Expect(page).To(HaveLen(2))
|
||||
Expect(page[0].GetName()).To(Equal("model-3"))
|
||||
Expect(page[1].GetName()).To(Equal("model-4"))
|
||||
})
|
||||
|
||||
It("should return partial last page", func() {
|
||||
page := elements.Paginate(3, 2)
|
||||
Expect(page).To(HaveLen(1))
|
||||
Expect(page[0].GetName()).To(Equal("model-5"))
|
||||
})
|
||||
|
||||
It("should handle page beyond range", func() {
|
||||
page := elements.Paginate(10, 2)
|
||||
Expect(page).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("should handle empty elements", func() {
|
||||
empty := GalleryElements[*GalleryModel]{}
|
||||
page := empty.Paginate(1, 10)
|
||||
Expect(page).To(HaveLen(0))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FindGalleryElement", func() {
|
||||
var models []*GalleryModel
|
||||
|
||||
BeforeEach(func() {
|
||||
models = []*GalleryModel{
|
||||
{
|
||||
Metadata: Metadata{
|
||||
Name: "bert-embeddings",
|
||||
Gallery: config.Gallery{
|
||||
Name: "huggingface",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Metadata: Metadata{
|
||||
Name: "gpt-2",
|
||||
Gallery: config.Gallery{
|
||||
Name: "openai",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("should find element by name without @ notation", func() {
|
||||
result := FindGalleryElement(models, "bert-embeddings")
|
||||
Expect(result).NotTo(BeNil())
|
||||
Expect(result.GetName()).To(Equal("bert-embeddings"))
|
||||
})
|
||||
|
||||
It("should find element by name with @ notation", func() {
|
||||
result := FindGalleryElement(models, "huggingface@bert-embeddings")
|
||||
Expect(result).NotTo(BeNil())
|
||||
Expect(result.GetName()).To(Equal("bert-embeddings"))
|
||||
Expect(result.GetGallery().Name).To(Equal("huggingface"))
|
||||
})
|
||||
|
||||
It("should be case insensitive", func() {
|
||||
result := FindGalleryElement(models, "BERT-EMBEDDINGS")
|
||||
Expect(result).NotTo(BeNil())
|
||||
Expect(result.GetName()).To(Equal("bert-embeddings"))
|
||||
})
|
||||
|
||||
It("should handle path separators in name", func() {
|
||||
// Path separators are replaced with __, so bert/embeddings becomes bert__embeddings
|
||||
// This test verifies the replacement happens, but won't match unless model name has __
|
||||
modelsWithPath := []*GalleryModel{
|
||||
{
|
||||
Metadata: Metadata{
|
||||
Name: "bert__embeddings",
|
||||
Gallery: config.Gallery{
|
||||
Name: "huggingface",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
result := FindGalleryElement(modelsWithPath, "bert/embeddings")
|
||||
Expect(result).NotTo(BeNil())
|
||||
Expect(result.GetName()).To(Equal("bert__embeddings"))
|
||||
})
|
||||
|
||||
It("should return zero value when not found", func() {
|
||||
result := FindGalleryElement(models, "nonexistent")
|
||||
Expect(result).To(BeNil())
|
||||
})
|
||||
|
||||
It("should return zero value when gallery@name not found", func() {
|
||||
result := FindGalleryElement(models, "nonexistent@model")
|
||||
Expect(result).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,14 @@ type GalleryModel struct {
|
||||
Overrides map[string]interface{} `json:"overrides,omitempty" yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
func (m *GalleryModel) GetInstalled() bool {
|
||||
return m.Installed
|
||||
}
|
||||
|
||||
func (m *GalleryModel) GetLicense() string {
|
||||
return m.License
|
||||
}
|
||||
|
||||
func (m *GalleryModel) SetGallery(gallery config.Gallery) {
|
||||
m.Gallery = gallery
|
||||
}
|
||||
|
||||
@@ -22,6 +22,14 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
nameSortFieldName = "name"
|
||||
repositorySortFieldName = "repository"
|
||||
licenseSortFieldName = "license"
|
||||
statusSortFieldName = "status"
|
||||
ascSortOrder = "asc"
|
||||
)
|
||||
|
||||
// RegisterUIAPIRoutes registers JSON API routes for the web UI
|
||||
func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig, galleryService *services.GalleryService, opcache *services.OpCache, applicationInstance *application.Application) {
|
||||
|
||||
@@ -189,6 +197,24 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||
// Get model statuses
|
||||
processingModelsData, taskTypes := opcache.GetStatus()
|
||||
|
||||
// Apply sorting if requested
|
||||
sortBy := c.QueryParam("sort")
|
||||
sortOrder := c.QueryParam("order")
|
||||
if sortOrder == "" {
|
||||
sortOrder = ascSortOrder
|
||||
}
|
||||
|
||||
switch sortBy {
|
||||
case nameSortFieldName:
|
||||
models = gallery.GalleryElements[*gallery.GalleryModel](models).SortByName(sortOrder)
|
||||
case repositorySortFieldName:
|
||||
models = gallery.GalleryElements[*gallery.GalleryModel](models).SortByRepository(sortOrder)
|
||||
case licenseSortFieldName:
|
||||
models = gallery.GalleryElements[*gallery.GalleryModel](models).SortByLicense(sortOrder)
|
||||
case statusSortFieldName:
|
||||
models = gallery.GalleryElements[*gallery.GalleryModel](models).SortByInstalled(sortOrder)
|
||||
}
|
||||
|
||||
pageNum, err := strconv.Atoi(page)
|
||||
if err != nil || pageNum < 1 {
|
||||
pageNum = 1
|
||||
@@ -493,6 +519,24 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||
// Get backend statuses
|
||||
processingBackendsData, taskTypes := opcache.GetStatus()
|
||||
|
||||
// Apply sorting if requested
|
||||
sortBy := c.QueryParam("sort")
|
||||
sortOrder := c.QueryParam("order")
|
||||
if sortOrder == "" {
|
||||
sortOrder = ascSortOrder
|
||||
}
|
||||
|
||||
switch sortBy {
|
||||
case nameSortFieldName:
|
||||
backends = gallery.GalleryElements[*gallery.GalleryBackend](backends).SortByName(sortOrder)
|
||||
case repositorySortFieldName:
|
||||
backends = gallery.GalleryElements[*gallery.GalleryBackend](backends).SortByRepository(sortOrder)
|
||||
case licenseSortFieldName:
|
||||
backends = gallery.GalleryElements[*gallery.GalleryBackend](backends).SortByLicense(sortOrder)
|
||||
case statusSortFieldName:
|
||||
backends = gallery.GalleryElements[*gallery.GalleryBackend](backends).SortByInstalled(sortOrder)
|
||||
}
|
||||
|
||||
pageNum, err := strconv.Atoi(page)
|
||||
if err != nil || pageNum < 1 {
|
||||
pageNum = 1
|
||||
|
||||
@@ -153,11 +153,47 @@
|
||||
<thead>
|
||||
<tr class="bg-gradient-to-r from-[#38BDF8]/20 to-[#8B5CF6]/20 border-b border-[#38BDF8]/30">
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider">Icon</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider">Backend Name</th>
|
||||
<th @click="setSort('name')"
|
||||
:class="sortBy === 'name' ? 'bg-[#38BDF8]/20' : ''"
|
||||
class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider cursor-pointer hover:bg-[#38BDF8]/10 transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Backend Name</span>
|
||||
<i :class="sortBy === 'name' ? (sortOrder === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down') : 'fas fa-sort'"
|
||||
:class="sortBy === 'name' ? 'text-[#38BDF8]' : 'text-[#94A3B8]'"
|
||||
class="text-xs"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider">Description</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider">Repository</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider">License</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider">Status</th>
|
||||
<th @click="setSort('repository')"
|
||||
:class="sortBy === 'repository' ? 'bg-[#38BDF8]/20' : ''"
|
||||
class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider cursor-pointer hover:bg-[#38BDF8]/10 transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Repository</span>
|
||||
<i :class="sortBy === 'repository' ? (sortOrder === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down') : 'fas fa-sort'"
|
||||
:class="sortBy === 'repository' ? 'text-[#38BDF8]' : 'text-[#94A3B8]'"
|
||||
class="text-xs"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th @click="setSort('license')"
|
||||
:class="sortBy === 'license' ? 'bg-[#38BDF8]/20' : ''"
|
||||
class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider cursor-pointer hover:bg-[#38BDF8]/10 transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>License</span>
|
||||
<i :class="sortBy === 'license' ? (sortOrder === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down') : 'fas fa-sort'"
|
||||
:class="sortBy === 'license' ? 'text-[#38BDF8]' : 'text-[#94A3B8]'"
|
||||
class="text-xs"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th @click="setSort('status')"
|
||||
:class="sortBy === 'status' ? 'bg-[#38BDF8]/20' : ''"
|
||||
class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider cursor-pointer hover:bg-[#38BDF8]/10 transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Status</span>
|
||||
<i :class="sortBy === 'status' ? (sortOrder === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down') : 'fas fa-sort'"
|
||||
:class="sortBy === 'status' ? 'text-[#38BDF8]' : 'text-[#94A3B8]'"
|
||||
class="text-xs"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-right text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -495,6 +531,8 @@ function backendsGallery() {
|
||||
selectedBackend: null,
|
||||
jobProgress: {},
|
||||
notifications: [],
|
||||
sortBy: '',
|
||||
sortOrder: 'asc',
|
||||
|
||||
init() {
|
||||
this.fetchBackends();
|
||||
@@ -521,6 +559,10 @@ function backendsGallery() {
|
||||
items: 21,
|
||||
term: this.searchTerm
|
||||
});
|
||||
if (this.sortBy) {
|
||||
params.append('sort', this.sortBy);
|
||||
params.append('order', this.sortOrder);
|
||||
}
|
||||
const response = await fetch(`/api/backends?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
@@ -544,6 +586,19 @@ function backendsGallery() {
|
||||
this.fetchBackends();
|
||||
},
|
||||
|
||||
setSort(column) {
|
||||
if (this.sortBy === column) {
|
||||
// Toggle sort order if clicking the same column
|
||||
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
// Set new column and default to ascending
|
||||
this.sortBy = column;
|
||||
this.sortOrder = 'asc';
|
||||
}
|
||||
this.currentPage = 1;
|
||||
this.fetchBackends();
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
if (page >= 1 && page <= this.totalPages) {
|
||||
this.currentPage = page;
|
||||
|
||||
@@ -186,41 +186,77 @@
|
||||
</div>
|
||||
|
||||
<!-- Table View -->
|
||||
<div x-show="models.length > 0" class="card overflow-hidden">
|
||||
<div x-show="models.length > 0" class="bg-[#1E293B] rounded-2xl border border-[#38BDF8]/20 overflow-hidden shadow-xl backdrop-blur-sm">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="bg-gradient-to-r from-[#38BDF8]/20 to-[#8B5CF6]/20 border-b border-[var(--color-primary-border)]/30">
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider">Icon</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider">Model Name</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider">Description</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider">Repository</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider">License</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-4 text-right text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider">Actions</th>
|
||||
<tr class="bg-gradient-to-r from-[#38BDF8]/20 to-[#8B5CF6]/20 border-b border-[#38BDF8]/30">
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider">Icon</th>
|
||||
<th @click="setSort('name')"
|
||||
:class="sortBy === 'name' ? 'bg-[#38BDF8]/20' : ''"
|
||||
class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider cursor-pointer hover:bg-[#38BDF8]/10 transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Model Name</span>
|
||||
<i :class="sortBy === 'name' ? (sortOrder === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down') : 'fas fa-sort'"
|
||||
:class="sortBy === 'name' ? 'text-[#38BDF8]' : 'text-[#94A3B8]'"
|
||||
class="text-xs"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider">Description</th>
|
||||
<th @click="setSort('repository')"
|
||||
:class="sortBy === 'repository' ? 'bg-[#38BDF8]/20' : ''"
|
||||
class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider cursor-pointer hover:bg-[#38BDF8]/10 transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Repository</span>
|
||||
<i :class="sortBy === 'repository' ? (sortOrder === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down') : 'fas fa-sort'"
|
||||
:class="sortBy === 'repository' ? 'text-[#38BDF8]' : 'text-[#94A3B8]'"
|
||||
class="text-xs"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th @click="setSort('license')"
|
||||
:class="sortBy === 'license' ? 'bg-[#38BDF8]/20' : ''"
|
||||
class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider cursor-pointer hover:bg-[#38BDF8]/10 transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>License</span>
|
||||
<i :class="sortBy === 'license' ? (sortOrder === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down') : 'fas fa-sort'"
|
||||
:class="sortBy === 'license' ? 'text-[#38BDF8]' : 'text-[#94A3B8]'"
|
||||
class="text-xs"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th @click="setSort('status')"
|
||||
:class="sortBy === 'status' ? 'bg-[#38BDF8]/20' : ''"
|
||||
class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider cursor-pointer hover:bg-[#38BDF8]/10 transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Status</span>
|
||||
<i :class="sortBy === 'status' ? (sortOrder === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down') : 'fas fa-sort'"
|
||||
:class="sortBy === 'status' ? 'text-[#38BDF8]' : 'text-[#94A3B8]'"
|
||||
class="text-xs"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-right text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[#38BDF8]/20">
|
||||
<template x-for="model in models" :key="model.id">
|
||||
<tr class="hover:bg-[var(--color-primary)]/10 transition-colors duration-200">
|
||||
<tr class="hover:bg-[#38BDF8]/10 transition-colors duration-200">
|
||||
<!-- Icon -->
|
||||
<td class="px-6 py-4">
|
||||
<div class="w-12 h-12 rounded-lg border border-[var(--color-primary-border)]/30 flex items-center justify-center bg-[var(--color-bg-primary)]">
|
||||
<div class="w-12 h-12 rounded-lg border border-[#38BDF8]/30 flex items-center justify-center bg-[#101827]">
|
||||
<img x-show="model.icon"
|
||||
:src="model.icon"
|
||||
class="w-full h-full object-cover rounded-lg"
|
||||
loading="lazy"
|
||||
:alt="model.name">
|
||||
<i x-show="!model.icon" class="fas fa-brain text-xl text-[var(--color-primary)]"></i>
|
||||
<i x-show="!model.icon" class="fas fa-brain text-xl text-[#8B5CF6]"></i>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Model Name -->
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-semibold text-[var(--color-text-primary)]" x-text="model.name"></span>
|
||||
<span class="text-sm font-semibold text-[#E5E7EB]" x-text="model.name"></span>
|
||||
<div x-show="model.trustRemoteCode" class="mt-1">
|
||||
<span class="inline-flex items-center text-xs px-2 py-1 rounded bg-[var(--color-error)]/20 text-[var(--color-error)] border border-[var(--color-error-light)]">
|
||||
<span class="inline-flex items-center text-xs px-2 py-1 rounded bg-red-500/20 text-red-300 border border-red-500/30">
|
||||
<i class="fa-solid fa-circle-exclamation mr-1"></i>
|
||||
Trust Remote Code
|
||||
</span>
|
||||
@@ -230,12 +266,12 @@
|
||||
|
||||
<!-- Description -->
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-[var(--color-text-secondary)] max-w-xs truncate" x-text="model.description" :title="model.description"></div>
|
||||
<div class="text-sm text-[#94A3B8] max-w-xs truncate" x-text="model.description" :title="model.description"></div>
|
||||
</td>
|
||||
|
||||
<!-- Repository -->
|
||||
<td class="px-6 py-4">
|
||||
<span class="inline-flex items-center text-xs px-2 py-1 rounded bg-[var(--color-primary)]/10 text-[var(--color-text-primary)] border border-[var(--color-primary-border)]/30">
|
||||
<span class="inline-flex items-center text-xs px-2 py-1 rounded bg-[#38BDF8]/10 text-[#E5E7EB] border border-[#38BDF8]/30">
|
||||
<i class="fa-brands fa-git-alt mr-1"></i>
|
||||
<span x-text="model.gallery"></span>
|
||||
</span>
|
||||
@@ -243,21 +279,21 @@
|
||||
|
||||
<!-- License -->
|
||||
<td class="px-6 py-4">
|
||||
<span x-show="model.license" class="inline-flex items-center text-xs px-2 py-1 rounded bg-[var(--color-accent)]/10 text-[var(--color-text-primary)] border border-[var(--color-accent-border)]/30">
|
||||
<span x-show="model.license" class="inline-flex items-center text-xs px-2 py-1 rounded bg-[#8B5CF6]/10 text-[#E5E7EB] border border-[#8B5CF6]/30">
|
||||
<i class="fas fa-book mr-1"></i>
|
||||
<span x-text="model.license"></span>
|
||||
</span>
|
||||
<span x-show="!model.license" class="text-xs text-[var(--color-text-secondary)]">-</span>
|
||||
<span x-show="!model.license" class="text-xs text-[#94A3B8]">-</span>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="px-6 py-4">
|
||||
<!-- Processing State -->
|
||||
<div x-show="model.processing" class="min-w-[200px]">
|
||||
<div class="text-xs font-medium text-[var(--color-text-primary)] mb-1">
|
||||
<div class="text-xs font-medium text-[#E5E7EB] mb-1">
|
||||
<span x-text="model.isDeletion ? 'Deleting...' : 'Installing...'"></span>
|
||||
</div>
|
||||
<div x-show="(jobProgress[model.jobID] || 0) === 0" class="text-xs text-[var(--color-primary)]">
|
||||
<div x-show="(jobProgress[model.jobID] || 0) === 0" class="text-xs text-[#38BDF8]">
|
||||
<i class="fas fa-clock mr-1"></i>Queued
|
||||
</div>
|
||||
<div class="progress-table mt-1">
|
||||
@@ -267,7 +303,7 @@
|
||||
|
||||
<!-- Installed State -->
|
||||
<div x-show="!model.processing && model.installed">
|
||||
<span class="badge badge-success">
|
||||
<span class="inline-flex items-center text-xs px-2 py-1 rounded bg-green-500/20 text-green-300 border border-green-500/30">
|
||||
<i class="fas fa-check-circle mr-1"></i>
|
||||
Installed
|
||||
</span>
|
||||
@@ -275,7 +311,7 @@
|
||||
|
||||
<!-- Not Installed State -->
|
||||
<div x-show="!model.processing && !model.installed">
|
||||
<span class="badge">
|
||||
<span class="inline-flex items-center text-xs px-2 py-1 rounded bg-[#1E293B] text-[#94A3B8] border border-[#38BDF8]/30">
|
||||
<i class="fas fa-circle mr-1"></i>
|
||||
Not Installed
|
||||
</span>
|
||||
@@ -566,6 +602,8 @@ function modelsGallery() {
|
||||
selectedModel: null,
|
||||
jobProgress: {},
|
||||
notifications: [],
|
||||
sortBy: '',
|
||||
sortOrder: 'asc',
|
||||
|
||||
init() {
|
||||
this.fetchModels();
|
||||
@@ -592,6 +630,10 @@ function modelsGallery() {
|
||||
items: 21,
|
||||
term: this.searchTerm
|
||||
});
|
||||
if (this.sortBy) {
|
||||
params.append('sort', this.sortBy);
|
||||
params.append('order', this.sortOrder);
|
||||
}
|
||||
const response = await fetch(`/api/models?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
@@ -615,6 +657,19 @@ function modelsGallery() {
|
||||
this.fetchModels();
|
||||
},
|
||||
|
||||
setSort(column) {
|
||||
if (this.sortBy === column) {
|
||||
// Toggle sort order if clicking the same column
|
||||
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
// Set new column and default to ascending
|
||||
this.sortBy = column;
|
||||
this.sortOrder = 'asc';
|
||||
}
|
||||
this.currentPage = 1;
|
||||
this.fetchModels();
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
if (page >= 1 && page <= this.totalPages) {
|
||||
this.currentPage = page;
|
||||
|
||||
Reference in New Issue
Block a user