From 802db229a6e0fd204c00def2358bbfe3250db317 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 2 Jan 2026 21:54:50 +0100 Subject: [PATCH] feat: add model filters and last updated display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add size filter (≤3B, 4-13B, 14-70B, >70B) based on model tags - Add model family filter dropdown with dynamic family list - Display last updated date on model cards (scraped from ollama.com) - Add /api/v1/models/remote/families endpoint - Convert relative time strings ("2 weeks ago") to timestamps during sync --- backend/cmd/server/main.go | 2 +- backend/internal/api/model_registry.go | 370 ++++++++++++++++-- backend/internal/api/routes.go | 2 + frontend/package.json | 2 +- frontend/src/lib/api/model-registry.ts | 30 ++ .../lib/components/models/ModelCard.svelte | 32 ++ .../src/lib/stores/model-registry.svelte.ts | 88 ++++- frontend/src/routes/models/+page.svelte | 68 +++- 8 files changed, 553 insertions(+), 41 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 3e3ee4d..196eb25 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -18,7 +18,7 @@ import ( ) // Version is set at build time via -ldflags, or defaults to dev -var Version = "0.4.3" +var Version = "0.4.5" func getEnvOrDefault(key, defaultValue string) string { if value := os.Getenv(key); value != "" { diff --git a/backend/internal/api/model_registry.go b/backend/internal/api/model_registry.go index 83850f9..9564a87 100644 --- a/backend/internal/api/model_registry.go +++ b/backend/internal/api/model_registry.go @@ -70,6 +70,7 @@ type ScrapedModel struct { PullCount int64 Tags []string Capabilities []string + UpdatedAt string // Relative time like "2 weeks ago" converted to RFC3339 } // scrapeOllamaLibrary fetches the model list from ollama.com/library @@ -168,6 +169,14 @@ func parseLibraryHTML(html string) ([]ScrapedModel, error) { capabilities = append(capabilities, "cloud") } + // Extract updated time from 2 weeks ago + updatedPattern := regexp.MustCompile(`]*x-test-updated[^>]*>([^<]+)`) + updatedAt := "" + if um := updatedPattern.FindStringSubmatch(cardContent); len(um) > 1 { + relativeTime := strings.TrimSpace(um[1]) + updatedAt = parseRelativeTime(relativeTime) + } + models[slug] = &ScrapedModel{ Slug: slug, Name: slug, @@ -176,6 +185,7 @@ func parseLibraryHTML(html string) ([]ScrapedModel, error) { PullCount: pullCount, Tags: tags, Capabilities: capabilities, + UpdatedAt: updatedAt, } } @@ -211,6 +221,52 @@ func decodeHTMLEntities(s string) string { return s } +// parseRelativeTime converts relative time strings like "2 weeks ago" to RFC3339 timestamps +func parseRelativeTime(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + return "" + } + + now := time.Now() + + // Parse patterns like "2 weeks ago", "1 month ago", "3 days ago" + pattern := regexp.MustCompile(`(\d+)\s*(second|minute|hour|day|week|month|year)s?\s*ago`) + matches := pattern.FindStringSubmatch(s) + if len(matches) < 3 { + return "" + } + + num, err := strconv.Atoi(matches[1]) + if err != nil { + return "" + } + + unit := matches[2] + var duration time.Duration + + switch unit { + case "second": + duration = time.Duration(num) * time.Second + case "minute": + duration = time.Duration(num) * time.Minute + case "hour": + duration = time.Duration(num) * time.Hour + case "day": + duration = time.Duration(num) * 24 * time.Hour + case "week": + duration = time.Duration(num) * 7 * 24 * time.Hour + case "month": + duration = time.Duration(num) * 30 * 24 * time.Hour + case "year": + duration = time.Duration(num) * 365 * 24 * time.Hour + default: + return "" + } + + return now.Add(-duration).Format(time.RFC3339) +} + // extractDescription tries to find the description for a model func extractDescription(html, slug string) string { // Look for text after the model link that looks like a description @@ -417,15 +473,16 @@ func (s *ModelRegistryService) SyncModels(ctx context.Context, fetchDetails bool modelType := inferModelType(model.Slug) _, err := s.db.ExecContext(ctx, ` - INSERT INTO remote_models (slug, name, description, model_type, url, pull_count, tags, capabilities, scraped_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO remote_models (slug, name, description, model_type, url, pull_count, tags, capabilities, ollama_updated_at, scraped_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(slug) DO UPDATE SET description = COALESCE(NULLIF(excluded.description, ''), remote_models.description), model_type = excluded.model_type, pull_count = excluded.pull_count, capabilities = excluded.capabilities, + ollama_updated_at = COALESCE(excluded.ollama_updated_at, remote_models.ollama_updated_at), scraped_at = excluded.scraped_at - `, model.Slug, model.Name, model.Description, modelType, model.URL, model.PullCount, string(tagsJSON), string(capsJSON), now) + `, model.Slug, model.Name, model.Description, modelType, model.URL, model.PullCount, string(tagsJSON), string(capsJSON), model.UpdatedAt, now) if err != nil { log.Printf("Failed to upsert model %s: %v", model.Slug, err) @@ -572,6 +629,106 @@ func formatParamCount(n int64) string { return fmt.Sprintf("%d", n) } +// parseParamSizeToFloat extracts numeric value from parameter size strings like "8b", "70b", "1.5b" +// Returns value in billions (e.g., "8b" -> 8.0, "70b" -> 70.0, "500m" -> 0.5) +func parseParamSizeToFloat(s string) float64 { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + return 0 + } + + // Handle suffix + multiplier := 1.0 + if strings.HasSuffix(s, "b") { + s = strings.TrimSuffix(s, "b") + } else if strings.HasSuffix(s, "m") { + s = strings.TrimSuffix(s, "m") + multiplier = 0.001 // Convert millions to billions + } + + if f, err := strconv.ParseFloat(s, 64); err == nil { + return f * multiplier + } + return 0 +} + +// getSizeRange returns the size range category for a given parameter size +// small: ≤3B, medium: 4-13B, large: 14-70B, xlarge: >70B +func getSizeRange(paramSize string) string { + size := parseParamSizeToFloat(paramSize) + if size <= 0 { + return "" + } + if size <= 3 { + return "small" + } + if size <= 13 { + return "medium" + } + if size <= 70 { + return "large" + } + return "xlarge" +} + +// modelMatchesSizeRanges checks if any of the model's tags fall within the requested size ranges +// A model matches if at least one of its tags is in any of the requested ranges +func modelMatchesSizeRanges(tags []string, sizeRanges []string) bool { + if len(tags) == 0 || len(sizeRanges) == 0 { + return false + } + for _, tag := range tags { + tagRange := getSizeRange(tag) + if tagRange == "" { + continue + } + for _, sr := range sizeRanges { + if sr == tagRange { + return true + } + } + } + return false +} + +// getContextRange returns the context range category for a given context length +// standard: ≤8K, extended: 8K-32K, large: 32K-128K, unlimited: >128K +func getContextRange(ctxLen int64) string { + if ctxLen <= 0 { + return "" + } + if ctxLen <= 8192 { + return "standard" + } + if ctxLen <= 32768 { + return "extended" + } + if ctxLen <= 131072 { + return "large" + } + return "unlimited" +} + +// extractFamily extracts the model family from slug (e.g., "llama3.2" -> "llama", "qwen2.5" -> "qwen") +func extractFamily(slug string) string { + // Remove namespace prefix for community models + if idx := strings.LastIndex(slug, "/"); idx != -1 { + slug = slug[idx+1:] + } + // Extract letters before any digits + family := "" + for _, r := range slug { + if r >= '0' && r <= '9' { + break + } + if r == '-' || r == '_' || r == '.' { + break + } + family += string(r) + } + return strings.ToLower(family) +} + // GetModel retrieves a single model from the database func (s *ModelRegistryService) GetModel(ctx context.Context, slug string) (*RemoteModel, error) { row := s.db.QueryRowContext(ctx, ` @@ -584,40 +741,65 @@ func (s *ModelRegistryService) GetModel(ctx context.Context, slug string) (*Remo return scanRemoteModel(row) } +// ModelSearchParams holds all search/filter parameters +type ModelSearchParams struct { + Query string + ModelType string + Capabilities []string + SizeRanges []string // small, medium, large, xlarge + ContextRanges []string // standard, extended, large, unlimited + Family string + SortBy string + Limit int + Offset int +} + // SearchModels searches for models in the database func (s *ModelRegistryService) SearchModels(ctx context.Context, query string, modelType string, capabilities []string, sortBy string, limit, offset int) ([]RemoteModel, int, error) { + return s.SearchModelsAdvanced(ctx, ModelSearchParams{ + Query: query, + ModelType: modelType, + Capabilities: capabilities, + SortBy: sortBy, + Limit: limit, + Offset: offset, + }) +} + +// SearchModelsAdvanced searches for models with all filter options +func (s *ModelRegistryService) SearchModelsAdvanced(ctx context.Context, params ModelSearchParams) ([]RemoteModel, int, error) { // Build query baseQuery := `FROM remote_models WHERE 1=1` args := []any{} - if query != "" { + if params.Query != "" { baseQuery += ` AND (slug LIKE ? OR name LIKE ? OR description LIKE ?)` - q := "%" + query + "%" + q := "%" + params.Query + "%" args = append(args, q, q, q) } - if modelType != "" { + if params.ModelType != "" { baseQuery += ` AND model_type = ?` - args = append(args, modelType) + args = append(args, params.ModelType) } // Filter by capabilities (JSON array contains) - for _, cap := range capabilities { + for _, cap := range params.Capabilities { // Use JSON contains for SQLite - capabilities column stores JSON array like ["vision","code"] baseQuery += ` AND capabilities LIKE ?` args = append(args, `%"`+cap+`"%`) } - // Get total count - var total int - countQuery := "SELECT COUNT(*) " + baseQuery - if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { - return nil, 0, err + // Filter by family (extracted from slug) + if params.Family != "" { + // Match slugs that start with the family name + baseQuery += ` AND (slug LIKE ? OR slug LIKE ?)` + args = append(args, params.Family+"%", "%/"+params.Family+"%") } // Build ORDER BY clause based on sort parameter orderBy := "pull_count DESC" // default: most popular - switch sortBy { + switch params.SortBy { case "name_asc": orderBy = "name ASC" case "name_desc": @@ -630,12 +812,25 @@ func (s *ModelRegistryService) SearchModels(ctx context.Context, query string, m orderBy = "ollama_updated_at DESC NULLS LAST, scraped_at DESC" } - // Get models - selectQuery := `SELECT slug, name, description, model_type, architecture, parameter_size, - context_length, embedding_length, quantization, capabilities, default_params, - license, pull_count, tags, tag_sizes, ollama_updated_at, details_fetched_at, scraped_at, url ` + - baseQuery + ` ORDER BY ` + orderBy + ` LIMIT ? OFFSET ?` - args = append(args, limit, offset) + // For size/context filtering, we need to fetch all matching models first + // then filter and paginate in memory (these filters require computed values) + needsPostFilter := len(params.SizeRanges) > 0 || len(params.ContextRanges) > 0 + + var selectQuery string + if needsPostFilter { + // Fetch all (no limit/offset) for post-filtering + selectQuery = `SELECT slug, name, description, model_type, architecture, parameter_size, + context_length, embedding_length, quantization, capabilities, default_params, + license, pull_count, tags, tag_sizes, ollama_updated_at, details_fetched_at, scraped_at, url ` + + baseQuery + ` ORDER BY ` + orderBy + } else { + // Direct pagination + selectQuery = `SELECT slug, name, description, model_type, architecture, parameter_size, + context_length, embedding_length, quantization, capabilities, default_params, + license, pull_count, tags, tag_sizes, ollama_updated_at, details_fetched_at, scraped_at, url ` + + baseQuery + ` ORDER BY ` + orderBy + ` LIMIT ? OFFSET ?` + args = append(args, params.Limit, params.Offset) + } rows, err := s.db.QueryContext(ctx, selectQuery, args...) if err != nil { @@ -649,10 +844,64 @@ func (s *ModelRegistryService) SearchModels(ctx context.Context, query string, m if err != nil { return nil, 0, err } + + // Apply size range filter based on tags + if len(params.SizeRanges) > 0 { + if !modelMatchesSizeRanges(m.Tags, params.SizeRanges) { + continue // Skip models without matching size tags + } + } + + // Apply context range filter + if len(params.ContextRanges) > 0 { + modelCtxRange := getContextRange(m.ContextLength) + if modelCtxRange == "" { + continue // Skip models without context info + } + found := false + for _, cr := range params.ContextRanges { + if cr == modelCtxRange { + found = true + break + } + } + if !found { + continue + } + } + models = append(models, *m) } - return models, total, rows.Err() + if err := rows.Err(); err != nil { + return nil, 0, err + } + + // Get total after filtering + total := len(models) + + // Apply pagination for post-filtered results + if needsPostFilter { + if params.Offset >= len(models) { + models = []RemoteModel{} + } else { + end := params.Offset + params.Limit + if end > len(models) { + end = len(models) + } + models = models[params.Offset:end] + } + } else { + // Get total count from DB for non-post-filtered queries + countQuery := "SELECT COUNT(*) " + baseQuery + // Remove the limit/offset args we added + countArgs := args[:len(args)-2] + if err := s.db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&total); err != nil { + return nil, 0, err + } + } + + return models, total, nil } // GetSyncStatus returns info about when models were last synced @@ -764,31 +1013,53 @@ func scanRemoteModelRows(rows *sql.Rows) (*RemoteModel, error) { // ListRemoteModelsHandler returns a handler for listing/searching remote models func (s *ModelRegistryService) ListRemoteModelsHandler() gin.HandlerFunc { return func(c *gin.Context) { - query := c.Query("search") - modelType := c.Query("type") - sortBy := c.Query("sort") // name_asc, name_desc, pulls_asc, pulls_desc, updated_desc - limit := 50 - offset := 0 + params := ModelSearchParams{ + Query: c.Query("search"), + ModelType: c.Query("type"), + SortBy: c.Query("sort"), // name_asc, name_desc, pulls_asc, pulls_desc, updated_desc + Family: c.Query("family"), + Limit: 50, + Offset: 0, + } if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 200 { - limit = l + params.Limit = l } if o, err := strconv.Atoi(c.Query("offset")); err == nil && o >= 0 { - offset = o + params.Offset = o } // Parse capabilities filter (comma-separated) - var capabilities []string if caps := c.Query("capabilities"); caps != "" { for _, cap := range strings.Split(caps, ",") { cap = strings.TrimSpace(cap) if cap != "" { - capabilities = append(capabilities, cap) + params.Capabilities = append(params.Capabilities, cap) } } } - models, total, err := s.SearchModels(c.Request.Context(), query, modelType, capabilities, sortBy, limit, offset) + // Parse size range filter (comma-separated: small,medium,large,xlarge) + if sizes := c.Query("sizeRange"); sizes != "" { + for _, sz := range strings.Split(sizes, ",") { + sz = strings.TrimSpace(strings.ToLower(sz)) + if sz == "small" || sz == "medium" || sz == "large" || sz == "xlarge" { + params.SizeRanges = append(params.SizeRanges, sz) + } + } + } + + // Parse context range filter (comma-separated: standard,extended,large,unlimited) + if ctx := c.Query("contextRange"); ctx != "" { + for _, cr := range strings.Split(ctx, ",") { + cr = strings.TrimSpace(strings.ToLower(cr)) + if cr == "standard" || cr == "extended" || cr == "large" || cr == "unlimited" { + params.ContextRanges = append(params.ContextRanges, cr) + } + } + } + + models, total, err := s.SearchModelsAdvanced(c.Request.Context(), params) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -797,8 +1068,8 @@ func (s *ModelRegistryService) ListRemoteModelsHandler() gin.HandlerFunc { c.JSON(http.StatusOK, gin.H{ "models": models, "total": total, - "limit": limit, - "offset": offset, + "limit": params.Limit, + "offset": params.Offset, }) } } @@ -1138,3 +1409,36 @@ func (s *ModelRegistryService) GetLocalFamiliesHandler() gin.HandlerFunc { c.JSON(http.StatusOK, gin.H{"families": families}) } } + +// GetRemoteFamiliesHandler returns unique model families from remote models +// Useful for populating filter dropdowns +func (s *ModelRegistryService) GetRemoteFamiliesHandler() gin.HandlerFunc { + return func(c *gin.Context) { + rows, err := s.db.QueryContext(c.Request.Context(), `SELECT DISTINCT slug FROM remote_models`) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + defer rows.Close() + + familySet := make(map[string]bool) + for rows.Next() { + var slug string + if err := rows.Scan(&slug); err != nil { + continue + } + family := extractFamily(slug) + if family != "" { + familySet[family] = true + } + } + + families := make([]string, 0, len(familySet)) + for f := range familySet { + families = append(families, f) + } + sort.Strings(families) + + c.JSON(http.StatusOK, gin.H{"families": families}) + } +} diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index b6d90e4..70cc093 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -83,6 +83,8 @@ func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string, appVersion string) // === Remote Models (from ollama.com cache) === // List/search remote models (from cache) models.GET("/remote", modelRegistry.ListRemoteModelsHandler()) + // Get unique model families for filter dropdowns + models.GET("/remote/families", modelRegistry.GetRemoteFamiliesHandler()) // Get single model details models.GET("/remote/:slug", modelRegistry.GetRemoteModelHandler()) // Fetch detailed info from Ollama (requires model to be pulled) diff --git a/frontend/package.json b/frontend/package.json index 573bcc0..6884ce6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "vessel", - "version": "0.4.3", + "version": "0.4.5", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/model-registry.ts b/frontend/src/lib/api/model-registry.ts index 01a9adb..fbab6db 100644 --- a/frontend/src/lib/api/model-registry.ts +++ b/frontend/src/lib/api/model-registry.ts @@ -49,11 +49,20 @@ export interface SyncStatus { /** Sort options for model list */ export type ModelSortOption = 'name_asc' | 'name_desc' | 'pulls_asc' | 'pulls_desc' | 'updated_desc'; +/** Size range filter options */ +export type SizeRange = 'small' | 'medium' | 'large' | 'xlarge'; + +/** Context length range filter options */ +export type ContextRange = 'standard' | 'extended' | 'large' | 'unlimited'; + /** Search/filter options */ export interface ModelSearchOptions { search?: string; type?: 'official' | 'community'; capabilities?: string[]; + sizeRanges?: SizeRange[]; + contextRanges?: ContextRange[]; + family?: string; sort?: ModelSortOption; limit?: number; offset?: number; @@ -73,6 +82,13 @@ export async function fetchRemoteModels(options: ModelSearchOptions = {}): Promi if (options.capabilities && options.capabilities.length > 0) { params.set('capabilities', options.capabilities.join(',')); } + if (options.sizeRanges && options.sizeRanges.length > 0) { + params.set('sizeRange', options.sizeRanges.join(',')); + } + if (options.contextRanges && options.contextRanges.length > 0) { + params.set('contextRange', options.contextRanges.join(',')); + } + if (options.family) params.set('family', options.family); if (options.sort) params.set('sort', options.sort); if (options.limit) params.set('limit', String(options.limit)); if (options.offset) params.set('offset', String(options.offset)); @@ -87,6 +103,20 @@ export async function fetchRemoteModels(options: ModelSearchOptions = {}): Promi return response.json(); } +/** + * Get unique model families for filter dropdowns (remote models) + */ +export async function fetchRemoteFamilies(): Promise { + const response = await fetch(`${API_BASE}/remote/families`); + + if (!response.ok) { + throw new Error(`Failed to fetch families: ${response.statusText}`); + } + + const data = await response.json(); + return data.families; +} + /** * Get a single remote model by slug */ diff --git a/frontend/src/lib/components/models/ModelCard.svelte b/frontend/src/lib/components/models/ModelCard.svelte index 54ff7b7..c51cac5 100644 --- a/frontend/src/lib/components/models/ModelCard.svelte +++ b/frontend/src/lib/components/models/ModelCard.svelte @@ -12,6 +12,28 @@ let { model, onSelect }: Props = $props(); + /** + * Format a date as relative time (e.g., "2d ago", "3w ago") + */ + function formatRelativeTime(date: string | Date | undefined): string { + if (!date) return ''; + const now = Date.now(); + const then = new Date(date).getTime(); + const diff = now - then; + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + const weeks = Math.floor(days / 7); + const months = Math.floor(days / 30); + + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + if (weeks < 4) return `${weeks}w ago`; + return `${months}mo ago`; + } + // Capability badges config (matches ollama.com capabilities) const capabilityBadges: Record = { vision: { icon: '👁', color: 'bg-purple-900/50 text-purple-300', label: 'Vision' }, @@ -92,6 +114,16 @@ {formatContextLength(model.contextLength)} {/if} + + + {#if model.ollamaUpdatedAt} +
+ + + + {formatRelativeTime(model.ollamaUpdatedAt)} +
+ {/if} diff --git a/frontend/src/lib/stores/model-registry.svelte.ts b/frontend/src/lib/stores/model-registry.svelte.ts index 14954fb..57510c9 100644 --- a/frontend/src/lib/stores/model-registry.svelte.ts +++ b/frontend/src/lib/stores/model-registry.svelte.ts @@ -5,12 +5,15 @@ import { fetchRemoteModels, + fetchRemoteFamilies, getSyncStatus, syncModels, type RemoteModel, type SyncStatus, type ModelSearchOptions, - type ModelSortOption + type ModelSortOption, + type SizeRange, + type ContextRange } from '$lib/api/model-registry'; /** Store state */ @@ -25,6 +28,10 @@ class ModelRegistryState { searchQuery = $state(''); modelType = $state<'official' | 'community' | ''>(''); selectedCapabilities = $state([]); + selectedSizeRanges = $state([]); + selectedContextRanges = $state([]); + selectedFamily = $state(''); + availableFamilies = $state([]); sortBy = $state('pulls_desc'); currentPage = $state(0); pageSize = $state(24); @@ -69,6 +76,18 @@ class ModelRegistryState { options.capabilities = this.selectedCapabilities; } + if (this.selectedSizeRanges.length > 0) { + options.sizeRanges = this.selectedSizeRanges; + } + + if (this.selectedContextRanges.length > 0) { + options.contextRanges = this.selectedContextRanges; + } + + if (this.selectedFamily) { + options.family = this.selectedFamily; + } + const response = await fetchRemoteModels(options); this.models = response.models; this.total = response.total; @@ -119,6 +138,68 @@ class ModelRegistryState { return this.selectedCapabilities.includes(capability); } + /** + * Toggle a size range filter + */ + async toggleSizeRange(size: SizeRange): Promise { + const index = this.selectedSizeRanges.indexOf(size); + if (index === -1) { + this.selectedSizeRanges = [...this.selectedSizeRanges, size]; + } else { + this.selectedSizeRanges = this.selectedSizeRanges.filter((s) => s !== size); + } + this.currentPage = 0; + await this.loadModels(); + } + + /** + * Check if a size range is selected + */ + hasSizeRange(size: SizeRange): boolean { + return this.selectedSizeRanges.includes(size); + } + + /** + * Toggle a context range filter + */ + async toggleContextRange(range: ContextRange): Promise { + const index = this.selectedContextRanges.indexOf(range); + if (index === -1) { + this.selectedContextRanges = [...this.selectedContextRanges, range]; + } else { + this.selectedContextRanges = this.selectedContextRanges.filter((r) => r !== range); + } + this.currentPage = 0; + await this.loadModels(); + } + + /** + * Check if a context range is selected + */ + hasContextRange(range: ContextRange): boolean { + return this.selectedContextRanges.includes(range); + } + + /** + * Set family filter + */ + async setFamily(family: string): Promise { + this.selectedFamily = family; + this.currentPage = 0; + await this.loadModels(); + } + + /** + * Load available families for filter dropdown + */ + async loadFamilies(): Promise { + try { + this.availableFamilies = await fetchRemoteFamilies(); + } catch (err) { + console.error('Failed to load families:', err); + } + } + /** * Set sort order */ @@ -200,6 +281,9 @@ class ModelRegistryState { this.searchQuery = ''; this.modelType = ''; this.selectedCapabilities = []; + this.selectedSizeRanges = []; + this.selectedContextRanges = []; + this.selectedFamily = ''; this.sortBy = 'pulls_desc'; this.currentPage = 0; await this.loadModels(); @@ -209,7 +293,7 @@ class ModelRegistryState { * Initialize the store */ async init(): Promise { - await Promise.all([this.loadSyncStatus(), this.loadModels()]); + await Promise.all([this.loadSyncStatus(), this.loadModels(), this.loadFamilies()]); } } diff --git a/frontend/src/routes/models/+page.svelte b/frontend/src/routes/models/+page.svelte index 475b264..7980047 100644 --- a/frontend/src/routes/models/+page.svelte +++ b/frontend/src/routes/models/+page.svelte @@ -641,7 +641,7 @@ -
+
Capabilities: +
- {#if modelRegistry.selectedCapabilities.length > 0 || modelRegistry.modelType || modelRegistry.searchQuery || modelRegistry.sortBy !== 'pulls_desc'} + +
+ Size: + + + + +
+ + +
+ {#if modelRegistry.availableFamilies.length > 0} +
+ Family: + +
+ {/if} + + {#if modelRegistry.selectedCapabilities.length > 0 || modelRegistry.selectedSizeRanges.length > 0 || modelRegistry.selectedFamily || modelRegistry.modelType || modelRegistry.searchQuery || modelRegistry.sortBy !== 'pulls_desc'} {/if}