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:
Ettore Di Giacinto
2025-12-11 00:11:33 +01:00
committed by GitHub
parent e1d060d147
commit 3b5c2ea633
7 changed files with 727 additions and 26 deletions

View File

@@ -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) {

View File

@@ -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) {

View 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())
})
})
})

View File

@@ -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
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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;