Skip to content

Commit 81ee94a

Browse files
richiejpclaude
andcommitted
feat(gallery): add multi-select usecase filtering, backend-aware filters, and async estimates
Move gallery model listing to a cached implementation with background refresh so the page loads instantly. Add per-model VRAM/size estimate endpoint so estimates load asynchronously without blocking the gallery. Introduce backend capabilities map as a single source of truth for what each backend supports, powering a new /api/backends/usecases endpoint that lets the UI grey out unavailable filter buttons when a backend is selected. Gallery filters now support multi-select (comma-separated tags with AND-match filtering) and a dedicated "multimodal" filter. Includes Playwright e2e tests for the new filter behaviors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent efdcbbe commit 81ee94a

11 files changed

Lines changed: 1101 additions & 124 deletions

File tree

core/config/backend_capabilities.go

Lines changed: 439 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package config
2+
3+
import (
4+
"slices"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestBackendCapabilities_AllHaveUsecases(t *testing.T) {
10+
for name, cap := range BackendCapabilities {
11+
if len(cap.PossibleUsecases) == 0 {
12+
t.Errorf("backend %q has no possible usecases", name)
13+
}
14+
if len(cap.DefaultUsecases) == 0 {
15+
t.Errorf("backend %q has no default usecases", name)
16+
}
17+
if len(cap.GRPCMethods) == 0 {
18+
t.Errorf("backend %q has no gRPC methods", name)
19+
}
20+
}
21+
}
22+
23+
func TestBackendCapabilities_DefaultsSubsetOfPossible(t *testing.T) {
24+
for name, cap := range BackendCapabilities {
25+
for _, d := range cap.DefaultUsecases {
26+
if !slices.Contains(cap.PossibleUsecases, d) {
27+
t.Errorf("backend %q: default %q not in possible %v", name, d, cap.PossibleUsecases)
28+
}
29+
}
30+
}
31+
}
32+
33+
func TestBackendCapabilities_UsecasesMatchFlags(t *testing.T) {
34+
allFlags := GetAllModelConfigUsecases()
35+
for name, cap := range BackendCapabilities {
36+
for _, u := range cap.PossibleUsecases {
37+
info, ok := UsecaseInfoMap[u]
38+
if !ok {
39+
t.Errorf("backend %q: usecase %q not in UsecaseInfoMap", name, u)
40+
continue
41+
}
42+
flagName := "FLAG_" + strings.ToUpper(u)
43+
if _, ok := allFlags[flagName]; !ok {
44+
// Try without transform — some names differ
45+
found := false
46+
for _, flag := range allFlags {
47+
if flag == info.Flag {
48+
found = true
49+
break
50+
}
51+
}
52+
if !found {
53+
t.Errorf("backend %q: usecase %q flag %d not in GetAllModelConfigUsecases", name, u, info.Flag)
54+
}
55+
}
56+
}
57+
}
58+
}
59+
60+
func TestUsecaseInfoMap_AllHaveFlags(t *testing.T) {
61+
for name, info := range UsecaseInfoMap {
62+
if info.Flag == FLAG_ANY {
63+
t.Errorf("usecase %q has FLAG_ANY (zero) — should have a real flag", name)
64+
}
65+
if info.GRPCMethod == "" {
66+
t.Errorf("usecase %q has no gRPC method", name)
67+
}
68+
}
69+
}
70+
71+
func TestGetBackendCapability(t *testing.T) {
72+
cap := GetBackendCapability("llama-cpp")
73+
if cap == nil {
74+
t.Fatal("llama-cpp should be known")
75+
}
76+
if !slices.Contains(cap.PossibleUsecases, "chat") {
77+
t.Error("llama-cpp should support chat")
78+
}
79+
}
80+
81+
func TestGetBackendCapability_Normalize(t *testing.T) {
82+
cap := GetBackendCapability("llama.cpp")
83+
if cap == nil {
84+
t.Fatal("llama.cpp should normalize to llama-cpp")
85+
}
86+
}
87+
88+
func TestGetBackendCapability_Unknown(t *testing.T) {
89+
cap := GetBackendCapability("nonexistent")
90+
if cap != nil {
91+
t.Error("unknown backend should return nil")
92+
}
93+
}
94+
95+
func TestIsValidUsecaseForBackend(t *testing.T) {
96+
if !IsValidUsecaseForBackend("piper", "tts") {
97+
t.Error("piper should support tts")
98+
}
99+
if IsValidUsecaseForBackend("piper", "chat") {
100+
t.Error("piper should not support chat")
101+
}
102+
// Unknown backend is permissive
103+
if !IsValidUsecaseForBackend("unknown", "anything") {
104+
t.Error("unknown backend should allow any usecase")
105+
}
106+
}
107+
108+
func TestAllBackendNames(t *testing.T) {
109+
names := AllBackendNames()
110+
if len(names) < 30 {
111+
t.Errorf("expected 30+ backends, got %d", len(names))
112+
}
113+
if !slices.IsSorted(names) {
114+
t.Error("should be sorted")
115+
}
116+
}

core/config/model_config.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,11 +565,39 @@ const (
565565
FLAG_VAD ModelConfigUsecase = 0b010000000000
566566
FLAG_VIDEO ModelConfigUsecase = 0b100000000000
567567
FLAG_DETECTION ModelConfigUsecase = 0b1000000000000
568+
FLAG_VISION ModelConfigUsecase = 0b10000000000000
568569

569570
// Common Subsets
570571
FLAG_LLM ModelConfigUsecase = FLAG_CHAT | FLAG_COMPLETION | FLAG_EDIT
571572
)
572573

574+
// ModalityGroups defines groups of usecases that belong to the same modality.
575+
// Flags within the same group are NOT orthogonal (e.g., chat and completion are
576+
// both text/language). A model is multimodal when its usecases span 2+ groups.
577+
var ModalityGroups = []ModelConfigUsecase{
578+
FLAG_CHAT | FLAG_COMPLETION | FLAG_EDIT, // text/language
579+
FLAG_VISION | FLAG_DETECTION, // visual understanding
580+
FLAG_TRANSCRIPT, // speech input
581+
FLAG_TTS | FLAG_SOUND_GENERATION, // audio output
582+
FLAG_IMAGE | FLAG_VIDEO, // visual generation
583+
}
584+
585+
// IsMultimodal returns true if the given usecases span two or more orthogonal
586+
// modality groups. For example chat+vision is multimodal, but chat+completion
587+
// is not (both belong to the text/language group).
588+
func IsMultimodal(usecases ModelConfigUsecase) bool {
589+
groupCount := 0
590+
for _, group := range ModalityGroups {
591+
if usecases&group != 0 {
592+
groupCount++
593+
if groupCount >= 2 {
594+
return true
595+
}
596+
}
597+
}
598+
return false
599+
}
600+
573601
func GetAllModelConfigUsecases() map[string]ModelConfigUsecase {
574602
return map[string]ModelConfigUsecase{
575603
// Note: FLAG_ANY is intentionally excluded from this map
@@ -588,6 +616,7 @@ func GetAllModelConfigUsecases() map[string]ModelConfigUsecase {
588616
"FLAG_LLM": FLAG_LLM,
589617
"FLAG_VIDEO": FLAG_VIDEO,
590618
"FLAG_DETECTION": FLAG_DETECTION,
619+
"FLAG_VISION": FLAG_VISION,
591620
}
592621
}
593622

core/gallery/gallery.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"path/filepath"
88
"slices"
99
"strings"
10+
"sync"
11+
"sync/atomic"
1012
"time"
1113

1214
"github.com/lithammer/fuzzysearch/fuzzy"
@@ -92,6 +94,34 @@ func (gm GalleryElements[T]) Search(term string) GalleryElements[T] {
9294
return filteredModels
9395
}
9496

97+
// FilterGalleryModelsByUsecase returns models whose known_usecases include all
98+
// the bits set in usecase. For example, passing FLAG_CHAT matches any model
99+
// with the chat usecase; passing FLAG_CHAT|FLAG_VISION matches only models
100+
// that have both.
101+
func FilterGalleryModelsByUsecase(models GalleryElements[*GalleryModel], usecase config.ModelConfigUsecase) GalleryElements[*GalleryModel] {
102+
var filtered GalleryElements[*GalleryModel]
103+
for _, m := range models {
104+
u := m.GetKnownUsecases()
105+
if u != nil && (*u&usecase) == usecase {
106+
filtered = append(filtered, m)
107+
}
108+
}
109+
return filtered
110+
}
111+
112+
// FilterGalleryModelsByMultimodal returns models whose known_usecases span two
113+
// or more orthogonal modality groups (e.g. chat+vision, tts+transcript).
114+
func FilterGalleryModelsByMultimodal(models GalleryElements[*GalleryModel]) GalleryElements[*GalleryModel] {
115+
var filtered GalleryElements[*GalleryModel]
116+
for _, m := range models {
117+
u := m.GetKnownUsecases()
118+
if u != nil && config.IsMultimodal(*u) {
119+
filtered = append(filtered, m)
120+
}
121+
}
122+
return filtered
123+
}
124+
95125
func (gm GalleryElements[T]) FilterByTag(tag string) GalleryElements[T] {
96126
var filtered GalleryElements[T]
97127
for _, m := range gm {
@@ -267,6 +297,69 @@ func AvailableGalleryModels(galleries []config.Gallery, systemState *system.Syst
267297
return models, nil
268298
}
269299

300+
var (
301+
availableModelsMu sync.RWMutex
302+
availableModelsCache GalleryElements[*GalleryModel]
303+
refreshing atomic.Bool
304+
)
305+
306+
// AvailableGalleryModelsCached returns gallery models from an in-memory cache.
307+
// Local-only fields (installed status) are refreshed on every call. A background
308+
// goroutine is triggered to re-fetch the full model list (including network
309+
// calls) so subsequent requests pick up changes without blocking the caller.
310+
// The first call with an empty cache blocks until the initial load completes.
311+
func AvailableGalleryModelsCached(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryModel], error) {
312+
availableModelsMu.RLock()
313+
cached := availableModelsCache
314+
availableModelsMu.RUnlock()
315+
316+
if cached != nil {
317+
// Refresh installed status under write lock to avoid races with
318+
// concurrent readers and the background refresh goroutine.
319+
availableModelsMu.Lock()
320+
for _, m := range cached {
321+
_, err := os.Stat(filepath.Join(systemState.Model.ModelsPath, fmt.Sprintf("%s.yaml", m.GetName())))
322+
m.SetInstalled(err == nil)
323+
}
324+
availableModelsMu.Unlock()
325+
// Trigger a background refresh if one is not already running.
326+
triggerGalleryRefresh(galleries, systemState)
327+
return cached, nil
328+
}
329+
330+
// No cache yet — must do a blocking load.
331+
models, err := AvailableGalleryModels(galleries, systemState)
332+
if err != nil {
333+
return nil, err
334+
}
335+
336+
availableModelsMu.Lock()
337+
availableModelsCache = models
338+
availableModelsMu.Unlock()
339+
340+
return models, nil
341+
}
342+
343+
// triggerGalleryRefresh starts a background goroutine that refreshes the
344+
// gallery model cache. Only one refresh runs at a time; concurrent calls
345+
// are no-ops.
346+
func triggerGalleryRefresh(galleries []config.Gallery, systemState *system.SystemState) {
347+
if !refreshing.CompareAndSwap(false, true) {
348+
return
349+
}
350+
go func() {
351+
defer refreshing.Store(false)
352+
models, err := AvailableGalleryModels(galleries, systemState)
353+
if err != nil {
354+
xlog.Error("background gallery refresh failed", "error", err)
355+
return
356+
}
357+
availableModelsMu.Lock()
358+
availableModelsCache = models
359+
availableModelsMu.Unlock()
360+
}()
361+
}
362+
270363
// List available backends
271364
func AvailableBackends(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryBackend], error) {
272365
return availableBackendsWithFilter(galleries, systemState, true)

core/gallery/models_types.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,23 @@ func (m *GalleryModel) GetTags() []string {
5252
func (m *GalleryModel) GetDescription() string {
5353
return m.Description
5454
}
55+
56+
// GetKnownUsecases extracts known_usecases from the model's Overrides and
57+
// returns the parsed usecase flags. Returns nil when no usecases are declared.
58+
func (m *GalleryModel) GetKnownUsecases() *config.ModelConfigUsecase {
59+
raw, ok := m.Overrides["known_usecases"]
60+
if !ok {
61+
return nil
62+
}
63+
list, ok := raw.([]any)
64+
if !ok {
65+
return nil
66+
}
67+
strs := make([]string, 0, len(list))
68+
for _, v := range list {
69+
if s, ok := v.(string); ok {
70+
strs = append(strs, s)
71+
}
72+
}
73+
return config.GetUsecasesFromYAML(strs)
74+
}

0 commit comments

Comments
 (0)