feat: add model browser with pull functionality and file size display
- Add model registry backend that scrapes ollama.com library page - Extract capabilities (vision, tools, thinking, embedding, cloud) from HTML - Store models in SQLite with search, filter by type and capabilities - Add tag sizes fetching from individual model pages - Create Model Browser UI with search, filters, and pagination - Implement streaming model pull with progress bar - Auto-refresh model selector and select new model after pull - Add cloud capability detection (uses different HTML pattern) - Update Go version to 1.24 in Dockerfile 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
867
backend/internal/api/model_registry.go
Normal file
867
backend/internal/api/model_registry.go
Normal file
@@ -0,0 +1,867 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
// RemoteModel represents a model from ollama.com with cached details
|
||||
type RemoteModel struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ModelType string `json:"modelType"` // "official" or "community"
|
||||
Architecture string `json:"architecture,omitempty"`
|
||||
ParameterSize string `json:"parameterSize,omitempty"`
|
||||
ContextLength int64 `json:"contextLength,omitempty"`
|
||||
EmbeddingLength int64 `json:"embeddingLength,omitempty"`
|
||||
Quantization string `json:"quantization,omitempty"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
DefaultParams map[string]any `json:"defaultParams,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
PullCount int64 `json:"pullCount"`
|
||||
Tags []string `json:"tags"`
|
||||
TagSizes map[string]int64 `json:"tagSizes,omitempty"` // Maps tag name to file size in bytes
|
||||
OllamaUpdatedAt string `json:"ollamaUpdatedAt,omitempty"`
|
||||
DetailsFetchedAt string `json:"detailsFetchedAt,omitempty"`
|
||||
ScrapedAt string `json:"scrapedAt"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// ModelRegistryService handles fetching and caching remote models
|
||||
type ModelRegistryService struct {
|
||||
db *sql.DB
|
||||
ollamaClient *api.Client
|
||||
httpClient *http.Client
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewModelRegistryService creates a new model registry service
|
||||
func NewModelRegistryService(db *sql.DB, ollamaClient *api.Client) *ModelRegistryService {
|
||||
return &ModelRegistryService{
|
||||
db: db,
|
||||
ollamaClient: ollamaClient,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ScrapedModel represents basic model info scraped from ollama.com
|
||||
type ScrapedModel struct {
|
||||
Slug string
|
||||
Name string
|
||||
Description string
|
||||
URL string
|
||||
PullCount int64
|
||||
Tags []string
|
||||
Capabilities []string
|
||||
}
|
||||
|
||||
// scrapeOllamaLibrary fetches the model list from ollama.com/library
|
||||
func (s *ModelRegistryService) scrapeOllamaLibrary(ctx context.Context) ([]ScrapedModel, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://ollama.com/library", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "OllamaWebUI/1.0")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch library: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read body: %w", err)
|
||||
}
|
||||
|
||||
return parseLibraryHTML(string(body))
|
||||
}
|
||||
|
||||
// parseLibraryHTML extracts model information from the HTML
|
||||
func parseLibraryHTML(html string) ([]ScrapedModel, error) {
|
||||
models := make(map[string]*ScrapedModel)
|
||||
|
||||
// Pattern to find model cards: <a href="/library/modelname" or "/library/namespace/modelname" class="group...">
|
||||
// Each card contains description and pull count
|
||||
// Note: [^":]+ allows / for community models like "username/modelname"
|
||||
cardPattern := regexp.MustCompile(`<a[^>]*href="/library/([^":]+)"[^>]*class="[^"]*group[^"]*"[^>]*>([\s\S]*?)</a>`)
|
||||
matches := cardPattern.FindAllStringSubmatch(html, -1)
|
||||
|
||||
for _, match := range matches {
|
||||
if len(match) < 3 {
|
||||
continue
|
||||
}
|
||||
slug := strings.TrimSpace(match[1])
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if we already have this model
|
||||
if _, exists := models[slug]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
cardContent := match[2]
|
||||
|
||||
// Extract description from <p class="...text-neutral-800...">
|
||||
descPattern := regexp.MustCompile(`<p[^>]*class="[^"]*text-neutral-800[^"]*"[^>]*>([^<]+)</p>`)
|
||||
desc := ""
|
||||
if dm := descPattern.FindStringSubmatch(cardContent); len(dm) > 1 {
|
||||
desc = decodeHTMLEntities(strings.TrimSpace(dm[1]))
|
||||
}
|
||||
|
||||
// Extract pull count from <span x-test-pull-count>60.3K</span>
|
||||
pullPattern := regexp.MustCompile(`<span[^>]*x-test-pull-count[^>]*>([^<]+)</span>`)
|
||||
pullCount := int64(0)
|
||||
if pm := pullPattern.FindStringSubmatch(cardContent); len(pm) > 1 {
|
||||
pullCount = parsePullCount(pm[1])
|
||||
}
|
||||
|
||||
// Extract size tags (8b, 70b, etc.)
|
||||
sizePattern := regexp.MustCompile(`<span[^>]*x-test-size[^>]*>([^<]+)</span>`)
|
||||
sizeMatches := sizePattern.FindAllStringSubmatch(cardContent, -1)
|
||||
tags := []string{}
|
||||
for _, sm := range sizeMatches {
|
||||
if len(sm) > 1 {
|
||||
tags = append(tags, strings.TrimSpace(sm[1]))
|
||||
}
|
||||
}
|
||||
|
||||
// Extract capabilities from <span x-test-capability>vision</span>
|
||||
capPattern := regexp.MustCompile(`<span[^>]*x-test-capability[^>]*>([^<]+)</span>`)
|
||||
capMatches := capPattern.FindAllStringSubmatch(cardContent, -1)
|
||||
capabilities := []string{}
|
||||
for _, cm := range capMatches {
|
||||
if len(cm) > 1 {
|
||||
cap := strings.TrimSpace(strings.ToLower(cm[1]))
|
||||
if cap != "" {
|
||||
capabilities = append(capabilities, cap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract "cloud" capability which uses different styling (bg-cyan-50 text-cyan-500)
|
||||
// Pattern: <span class="...bg-cyan-50...text-cyan-500...">cloud</span>
|
||||
cloudPattern := regexp.MustCompile(`<span[^>]*class="[^"]*bg-cyan-50[^"]*text-cyan-500[^"]*"[^>]*>cloud</span>`)
|
||||
if cloudPattern.MatchString(cardContent) {
|
||||
capabilities = append(capabilities, "cloud")
|
||||
}
|
||||
|
||||
models[slug] = &ScrapedModel{
|
||||
Slug: slug,
|
||||
Name: slug,
|
||||
Description: desc,
|
||||
URL: "https://ollama.com/library/" + slug,
|
||||
PullCount: pullCount,
|
||||
Tags: tags,
|
||||
Capabilities: capabilities,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to slice
|
||||
result := make([]ScrapedModel, 0, len(models))
|
||||
for _, m := range models {
|
||||
result = append(result, *m)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// stripHTML removes HTML tags from a string
|
||||
func stripHTML(s string) string {
|
||||
re := regexp.MustCompile(`<[^>]*>`)
|
||||
return re.ReplaceAllString(s, " ")
|
||||
}
|
||||
|
||||
// decodeHTMLEntities decodes common HTML entities
|
||||
func decodeHTMLEntities(s string) string {
|
||||
replacements := map[string]string{
|
||||
"'": "'",
|
||||
""": "\"",
|
||||
""": "\"",
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
" ": " ",
|
||||
}
|
||||
for entity, char := range replacements {
|
||||
s = strings.ReplaceAll(s, entity, char)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// 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
|
||||
pattern := regexp.MustCompile(`/library/` + regexp.QuoteMeta(slug) + `"[^>]*>([^<]*)</a>\s*([^<]{10,200})`)
|
||||
if m := pattern.FindStringSubmatch(html); len(m) > 2 {
|
||||
desc := strings.TrimSpace(m[2])
|
||||
// Clean up the description
|
||||
desc = strings.ReplaceAll(desc, "\n", " ")
|
||||
desc = strings.Join(strings.Fields(desc), " ")
|
||||
if len(desc) > 200 {
|
||||
desc = desc[:197] + "..."
|
||||
}
|
||||
return desc
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// inferModelType determines if a model is official or community based on slug structure
|
||||
// Official models have no namespace (e.g., "llama3.1", "mistral")
|
||||
// Community models have a namespace prefix (e.g., "username/model-name")
|
||||
func inferModelType(slug string) string {
|
||||
if strings.Contains(slug, "/") {
|
||||
return "community"
|
||||
}
|
||||
return "official"
|
||||
}
|
||||
|
||||
// parsePullCount converts "1.2M" or "500K" to an integer
|
||||
func parsePullCount(s string) int64 {
|
||||
s = strings.TrimSpace(s)
|
||||
multiplier := int64(1)
|
||||
|
||||
if strings.HasSuffix(s, "K") {
|
||||
multiplier = 1000
|
||||
s = strings.TrimSuffix(s, "K")
|
||||
} else if strings.HasSuffix(s, "M") {
|
||||
multiplier = 1000000
|
||||
s = strings.TrimSuffix(s, "M")
|
||||
} else if strings.HasSuffix(s, "B") {
|
||||
multiplier = 1000000000
|
||||
s = strings.TrimSuffix(s, "B")
|
||||
}
|
||||
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return int64(f * float64(multiplier))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// scrapeModelDetailPage fetches the individual model page and extracts file sizes per tag
|
||||
// Example: "2.0GB · 128K context window" -> {"8b": 2147483648}
|
||||
func (s *ModelRegistryService) scrapeModelDetailPage(ctx context.Context, slug string) (map[string]int64, error) {
|
||||
url := "https://ollama.com/library/" + slug
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "OllamaWebUI/1.0")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch model page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read body: %w", err)
|
||||
}
|
||||
|
||||
return parseModelPageForSizes(string(body))
|
||||
}
|
||||
|
||||
// parseModelPageForSizes extracts file sizes from the model detail page
|
||||
// The page has rows like: tag name | "2.0GB · 128K context window · Text · 1 year ago"
|
||||
func parseModelPageForSizes(html string) (map[string]int64, error) {
|
||||
sizes := make(map[string]int64)
|
||||
|
||||
// Pattern to find model rows in the table
|
||||
// Looking for tag names and their associated sizes
|
||||
// The table typically has rows with tag name and size info like "2.0GB"
|
||||
rowPattern := regexp.MustCompile(`href="/library/[^"]+:([^"]+)"[^>]*>[\s\S]*?(\d+(?:\.\d+)?)\s*(GB|MB|KB)`)
|
||||
matches := rowPattern.FindAllStringSubmatch(html, -1)
|
||||
|
||||
for _, match := range matches {
|
||||
if len(match) >= 4 {
|
||||
tag := strings.TrimSpace(match[1])
|
||||
sizeStr := match[2]
|
||||
unit := match[3]
|
||||
|
||||
if size, err := strconv.ParseFloat(sizeStr, 64); err == nil {
|
||||
var bytes int64
|
||||
switch unit {
|
||||
case "GB":
|
||||
bytes = int64(size * 1024 * 1024 * 1024)
|
||||
case "MB":
|
||||
bytes = int64(size * 1024 * 1024)
|
||||
case "KB":
|
||||
bytes = int64(size * 1024)
|
||||
}
|
||||
if bytes > 0 {
|
||||
sizes[tag] = bytes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sizes, nil
|
||||
}
|
||||
|
||||
// parseSizeToBytes converts "2.0GB" to bytes
|
||||
func parseSizeToBytes(s string) int64 {
|
||||
s = strings.TrimSpace(s)
|
||||
var multiplier int64 = 1
|
||||
|
||||
if strings.HasSuffix(s, "GB") {
|
||||
multiplier = 1024 * 1024 * 1024
|
||||
s = strings.TrimSuffix(s, "GB")
|
||||
} else if strings.HasSuffix(s, "MB") {
|
||||
multiplier = 1024 * 1024
|
||||
s = strings.TrimSuffix(s, "MB")
|
||||
} else if strings.HasSuffix(s, "KB") {
|
||||
multiplier = 1024
|
||||
s = strings.TrimSuffix(s, "KB")
|
||||
}
|
||||
|
||||
if f, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
|
||||
return int64(f * float64(multiplier))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// FetchAndStoreTagSizes fetches tag sizes for a model from its detail page and stores them
|
||||
func (s *ModelRegistryService) FetchAndStoreTagSizes(ctx context.Context, slug string) (*RemoteModel, error) {
|
||||
sizes, err := s.scrapeModelDetailPage(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scrape model page: %w", err)
|
||||
}
|
||||
|
||||
// Store in database
|
||||
sizesJSON, _ := json.Marshal(sizes)
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
UPDATE remote_models SET tag_sizes = ? WHERE slug = ?
|
||||
`, string(sizesJSON), slug)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update tag sizes: %w", err)
|
||||
}
|
||||
|
||||
return s.GetModel(ctx, slug)
|
||||
}
|
||||
|
||||
// fetchModelDetails uses ollama show to get detailed model info
|
||||
func (s *ModelRegistryService) fetchModelDetails(ctx context.Context, slug string) (*api.ShowResponse, error) {
|
||||
if s.ollamaClient == nil {
|
||||
return nil, fmt.Errorf("ollama client not available")
|
||||
}
|
||||
|
||||
resp, err := s.ollamaClient.Show(ctx, &api.ShowRequest{
|
||||
Name: slug,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// SyncModels scrapes ollama.com and updates the database
|
||||
func (s *ModelRegistryService) SyncModels(ctx context.Context, fetchDetails bool) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Scrape the library
|
||||
scraped, err := s.scrapeOllamaLibrary(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to scrape library: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Scraped %d models from ollama.com", len(scraped))
|
||||
|
||||
// Update database
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
count := 0
|
||||
|
||||
for _, model := range scraped {
|
||||
// Check if context is cancelled
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return count, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Upsert model
|
||||
tagsJSON, _ := json.Marshal(model.Tags)
|
||||
|
||||
// Use scraped capabilities from ollama.com
|
||||
capsJSON, _ := json.Marshal(model.Capabilities)
|
||||
|
||||
// Infer model type (official vs community) based on slug structure
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
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,
|
||||
scraped_at = excluded.scraped_at
|
||||
`, model.Slug, model.Name, model.Description, modelType, model.URL, model.PullCount, string(tagsJSON), string(capsJSON), now)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to upsert model %s: %v", model.Slug, err)
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// FetchModelDetails fetches detailed info for a specific model and updates the DB
|
||||
func (s *ModelRegistryService) FetchModelDetails(ctx context.Context, slug string) (*RemoteModel, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Get details from Ollama
|
||||
details, err := s.fetchModelDetails(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch details: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
// Extract capabilities
|
||||
capabilities := []string{}
|
||||
if details.Capabilities != nil {
|
||||
for _, cap := range details.Capabilities {
|
||||
capabilities = append(capabilities, string(cap))
|
||||
}
|
||||
}
|
||||
capsJSON, _ := json.Marshal(capabilities)
|
||||
|
||||
// Extract default params
|
||||
paramsJSON := "{}"
|
||||
if details.Parameters != "" {
|
||||
// Parse the parameters string into a map
|
||||
params := parseOllamaParams(details.Parameters)
|
||||
if len(params) > 0 {
|
||||
if b, err := json.Marshal(params); err == nil {
|
||||
paramsJSON = string(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get model info
|
||||
arch := ""
|
||||
paramSize := ""
|
||||
ctxLen := int64(0)
|
||||
embedLen := int64(0)
|
||||
quant := ""
|
||||
|
||||
if details.ModelInfo != nil {
|
||||
for k, v := range details.ModelInfo {
|
||||
switch {
|
||||
case strings.Contains(k, "architecture"):
|
||||
if s, ok := v.(string); ok {
|
||||
arch = s
|
||||
}
|
||||
case strings.Contains(k, "parameter"):
|
||||
if s, ok := v.(string); ok {
|
||||
paramSize = s
|
||||
} else if f, ok := v.(float64); ok {
|
||||
paramSize = formatParamCount(int64(f))
|
||||
}
|
||||
case strings.Contains(k, "context"):
|
||||
if f, ok := v.(float64); ok {
|
||||
ctxLen = int64(f)
|
||||
}
|
||||
case strings.Contains(k, "embedding"):
|
||||
if f, ok := v.(float64); ok {
|
||||
embedLen = int64(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get quantization from details
|
||||
if details.Details.QuantizationLevel != "" {
|
||||
quant = details.Details.QuantizationLevel
|
||||
}
|
||||
if paramSize == "" && details.Details.ParameterSize != "" {
|
||||
paramSize = details.Details.ParameterSize
|
||||
}
|
||||
|
||||
// Update database
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
UPDATE remote_models SET
|
||||
architecture = ?,
|
||||
parameter_size = ?,
|
||||
context_length = ?,
|
||||
embedding_length = ?,
|
||||
quantization = ?,
|
||||
capabilities = ?,
|
||||
default_params = ?,
|
||||
license = ?,
|
||||
details_fetched_at = ?
|
||||
WHERE slug = ?
|
||||
`, arch, paramSize, ctxLen, embedLen, quant, string(capsJSON), paramsJSON, details.License, now, slug)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update model details: %w", err)
|
||||
}
|
||||
|
||||
// Return the updated model
|
||||
return s.GetModel(ctx, slug)
|
||||
}
|
||||
|
||||
// parseOllamaParams parses the parameters string from ollama show
|
||||
func parseOllamaParams(params string) map[string]any {
|
||||
result := make(map[string]any)
|
||||
lines := strings.Split(params, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
key := parts[0]
|
||||
val := strings.Join(parts[1:], " ")
|
||||
// Try to parse as number
|
||||
if f, err := strconv.ParseFloat(val, 64); err == nil {
|
||||
result[key] = f
|
||||
} else {
|
||||
result[key] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// formatParamCount formats a parameter count like "13900000000" to "13.9B"
|
||||
func formatParamCount(n int64) string {
|
||||
if n >= 1000000000 {
|
||||
return fmt.Sprintf("%.1fB", float64(n)/1000000000)
|
||||
}
|
||||
if n >= 1000000 {
|
||||
return fmt.Sprintf("%.1fM", float64(n)/1000000)
|
||||
}
|
||||
if n >= 1000 {
|
||||
return fmt.Sprintf("%.1fK", float64(n)/1000)
|
||||
}
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
// GetModel retrieves a single model from the database
|
||||
func (s *ModelRegistryService) GetModel(ctx context.Context, slug string) (*RemoteModel, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
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
|
||||
FROM remote_models WHERE slug = ?
|
||||
`, slug)
|
||||
|
||||
return scanRemoteModel(row)
|
||||
}
|
||||
|
||||
// SearchModels searches for models in the database
|
||||
func (s *ModelRegistryService) SearchModels(ctx context.Context, query string, modelType string, capabilities []string, limit, offset int) ([]RemoteModel, int, error) {
|
||||
// Build query
|
||||
baseQuery := `FROM remote_models WHERE 1=1`
|
||||
args := []any{}
|
||||
|
||||
if query != "" {
|
||||
baseQuery += ` AND (slug LIKE ? OR name LIKE ? OR description LIKE ?)`
|
||||
q := "%" + query + "%"
|
||||
args = append(args, q, q, q)
|
||||
}
|
||||
|
||||
if modelType != "" {
|
||||
baseQuery += ` AND model_type = ?`
|
||||
args = append(args, modelType)
|
||||
}
|
||||
|
||||
// Filter by capabilities (JSON array contains)
|
||||
for _, cap := range 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
|
||||
}
|
||||
|
||||
// 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 pull_count DESC LIMIT ? OFFSET ?`
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, selectQuery, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
models := []RemoteModel{}
|
||||
for rows.Next() {
|
||||
m, err := scanRemoteModelRows(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
models = append(models, *m)
|
||||
}
|
||||
|
||||
return models, total, rows.Err()
|
||||
}
|
||||
|
||||
// GetSyncStatus returns info about when models were last synced
|
||||
func (s *ModelRegistryService) GetSyncStatus(ctx context.Context) (map[string]any, error) {
|
||||
var count int
|
||||
var lastSync sql.NullString
|
||||
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*), MAX(scraped_at) FROM remote_models`).Scan(&count, &lastSync)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"modelCount": count,
|
||||
"lastSync": lastSync.String,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// scanRemoteModel scans a single row into a RemoteModel
|
||||
func scanRemoteModel(row *sql.Row) (*RemoteModel, error) {
|
||||
var m RemoteModel
|
||||
var caps, params, tags, tagSizes string
|
||||
var arch, paramSize, quant, license, ollamaUpdated, detailsFetched sql.NullString
|
||||
var ctxLen, embedLen sql.NullInt64
|
||||
|
||||
err := row.Scan(
|
||||
&m.Slug, &m.Name, &m.Description, &m.ModelType,
|
||||
&arch, ¶mSize, &ctxLen, &embedLen, &quant,
|
||||
&caps, ¶ms, &license, &m.PullCount, &tags, &tagSizes,
|
||||
&ollamaUpdated, &detailsFetched, &m.ScrapedAt, &m.URL,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.Architecture = arch.String
|
||||
m.ParameterSize = paramSize.String
|
||||
m.ContextLength = ctxLen.Int64
|
||||
m.EmbeddingLength = embedLen.Int64
|
||||
m.Quantization = quant.String
|
||||
m.License = license.String
|
||||
m.OllamaUpdatedAt = ollamaUpdated.String
|
||||
m.DetailsFetchedAt = detailsFetched.String
|
||||
|
||||
json.Unmarshal([]byte(caps), &m.Capabilities)
|
||||
json.Unmarshal([]byte(params), &m.DefaultParams)
|
||||
json.Unmarshal([]byte(tags), &m.Tags)
|
||||
json.Unmarshal([]byte(tagSizes), &m.TagSizes)
|
||||
|
||||
if m.Capabilities == nil {
|
||||
m.Capabilities = []string{}
|
||||
}
|
||||
if m.Tags == nil {
|
||||
m.Tags = []string{}
|
||||
}
|
||||
if m.TagSizes == nil {
|
||||
m.TagSizes = make(map[string]int64)
|
||||
}
|
||||
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// scanRemoteModelRows scans from rows
|
||||
func scanRemoteModelRows(rows *sql.Rows) (*RemoteModel, error) {
|
||||
var m RemoteModel
|
||||
var caps, params, tags, tagSizes string
|
||||
var arch, paramSize, quant, license, ollamaUpdated, detailsFetched sql.NullString
|
||||
var ctxLen, embedLen sql.NullInt64
|
||||
|
||||
err := rows.Scan(
|
||||
&m.Slug, &m.Name, &m.Description, &m.ModelType,
|
||||
&arch, ¶mSize, &ctxLen, &embedLen, &quant,
|
||||
&caps, ¶ms, &license, &m.PullCount, &tags, &tagSizes,
|
||||
&ollamaUpdated, &detailsFetched, &m.ScrapedAt, &m.URL,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.Architecture = arch.String
|
||||
m.ParameterSize = paramSize.String
|
||||
m.ContextLength = ctxLen.Int64
|
||||
m.EmbeddingLength = embedLen.Int64
|
||||
m.Quantization = quant.String
|
||||
m.License = license.String
|
||||
m.OllamaUpdatedAt = ollamaUpdated.String
|
||||
m.DetailsFetchedAt = detailsFetched.String
|
||||
|
||||
json.Unmarshal([]byte(caps), &m.Capabilities)
|
||||
json.Unmarshal([]byte(params), &m.DefaultParams)
|
||||
json.Unmarshal([]byte(tags), &m.Tags)
|
||||
json.Unmarshal([]byte(tagSizes), &m.TagSizes)
|
||||
|
||||
if m.Capabilities == nil {
|
||||
m.Capabilities = []string{}
|
||||
}
|
||||
if m.Tags == nil {
|
||||
m.Tags = []string{}
|
||||
}
|
||||
if m.TagSizes == nil {
|
||||
m.TagSizes = make(map[string]int64)
|
||||
}
|
||||
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// === HTTP Handlers ===
|
||||
|
||||
// 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")
|
||||
limit := 50
|
||||
offset := 0
|
||||
|
||||
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 200 {
|
||||
limit = l
|
||||
}
|
||||
if o, err := strconv.Atoi(c.Query("offset")); err == nil && o >= 0 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
models, total, err := s.SearchModels(c.Request.Context(), query, modelType, capabilities, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"models": models,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetRemoteModelHandler returns a handler for getting a single model
|
||||
func (s *ModelRegistryService) GetRemoteModelHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
|
||||
model, err := s.GetModel(c.Request.Context(), slug)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model)
|
||||
}
|
||||
}
|
||||
|
||||
// FetchModelDetailsHandler returns a handler for fetching detailed model info
|
||||
func (s *ModelRegistryService) FetchModelDetailsHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
|
||||
model, err := s.FetchModelDetails(c.Request.Context(), slug)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model)
|
||||
}
|
||||
}
|
||||
|
||||
// FetchTagSizesHandler returns a handler for fetching file sizes per tag
|
||||
func (s *ModelRegistryService) FetchTagSizesHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
|
||||
model, err := s.FetchAndStoreTagSizes(c.Request.Context(), slug)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model)
|
||||
}
|
||||
}
|
||||
|
||||
// SyncModelsHandler returns a handler for syncing models from ollama.com
|
||||
func (s *ModelRegistryService) SyncModelsHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
fetchDetails := c.Query("details") == "true"
|
||||
|
||||
count, err := s.SyncModels(c.Request.Context(), fetchDetails)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"synced": count,
|
||||
"message": fmt.Sprintf("Synced %d models from ollama.com", count),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SyncStatusHandler returns a handler for getting sync status
|
||||
func (s *ModelRegistryService) SyncStatusHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
status, err := s.GetSyncStatus(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, status)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@ type OllamaService struct {
|
||||
ollamaURL string
|
||||
}
|
||||
|
||||
// Client returns the underlying Ollama API client
|
||||
func (s *OllamaService) Client() *api.Client {
|
||||
return s.client
|
||||
}
|
||||
|
||||
// NewOllamaService creates a new Ollama service with the official client
|
||||
func NewOllamaService(ollamaURL string) (*OllamaService, error) {
|
||||
baseURL, err := url.Parse(ollamaURL)
|
||||
|
||||
@@ -15,6 +15,14 @@ func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string) {
|
||||
log.Printf("Warning: Failed to initialize Ollama service: %v", err)
|
||||
}
|
||||
|
||||
// Initialize model registry service
|
||||
var modelRegistry *ModelRegistryService
|
||||
if ollamaService != nil {
|
||||
modelRegistry = NewModelRegistryService(db, ollamaService.Client())
|
||||
} else {
|
||||
modelRegistry = NewModelRegistryService(db, nil)
|
||||
}
|
||||
|
||||
// Health check
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
@@ -54,6 +62,23 @@ func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string) {
|
||||
// IP-based geolocation (fallback when browser geolocation fails)
|
||||
v1.GET("/location", IPGeolocationHandler())
|
||||
|
||||
// Model registry routes (cached models from ollama.com)
|
||||
models := v1.Group("/models")
|
||||
{
|
||||
// List/search remote models (from cache)
|
||||
models.GET("/remote", modelRegistry.ListRemoteModelsHandler())
|
||||
// Get single model details
|
||||
models.GET("/remote/:slug", modelRegistry.GetRemoteModelHandler())
|
||||
// Fetch detailed info from Ollama (requires model to be pulled)
|
||||
models.POST("/remote/:slug/details", modelRegistry.FetchModelDetailsHandler())
|
||||
// Fetch tag sizes from ollama.com (scrapes model detail page)
|
||||
models.POST("/remote/:slug/sizes", modelRegistry.FetchTagSizesHandler())
|
||||
// Sync models from ollama.com
|
||||
models.POST("/remote/sync", modelRegistry.SyncModelsHandler())
|
||||
// Get sync status
|
||||
models.GET("/remote/status", modelRegistry.SyncStatusHandler())
|
||||
}
|
||||
|
||||
// Ollama API routes (using official client)
|
||||
if ollamaService != nil {
|
||||
ollama := v1.Group("/ollama")
|
||||
@@ -76,13 +101,10 @@ func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string) {
|
||||
// Status
|
||||
ollama.GET("/api/version", ollamaService.VersionHandler())
|
||||
ollama.GET("/", ollamaService.HeartbeatHandler())
|
||||
|
||||
// Fallback proxy for any other endpoints
|
||||
ollama.Any("/*path", ollamaService.ProxyHandler())
|
||||
}
|
||||
} else {
|
||||
// Fallback to simple proxy if service init failed
|
||||
v1.Any("/ollama/*path", OllamaProxyHandler(ollamaURL))
|
||||
}
|
||||
|
||||
// Fallback proxy for direct Ollama access (separate path to avoid conflicts)
|
||||
v1.Any("/ollama-proxy/*path", OllamaProxyHandler(ollamaURL))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,57 @@ CREATE INDEX IF NOT EXISTS idx_attachments_message_id ON attachments(message_id)
|
||||
CREATE INDEX IF NOT EXISTS idx_chats_updated_at ON chats(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_chats_sync_version ON chats(sync_version);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_sync_version ON messages(sync_version);
|
||||
|
||||
-- Remote models registry (cached from ollama.com)
|
||||
CREATE TABLE IF NOT EXISTS remote_models (
|
||||
slug TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
model_type TEXT NOT NULL DEFAULT 'community' CHECK (model_type IN ('official', 'community')),
|
||||
|
||||
-- Model architecture details (from ollama show)
|
||||
architecture TEXT,
|
||||
parameter_size TEXT,
|
||||
context_length INTEGER,
|
||||
embedding_length INTEGER,
|
||||
quantization TEXT,
|
||||
|
||||
-- Capabilities (stored as JSON array)
|
||||
capabilities TEXT NOT NULL DEFAULT '[]',
|
||||
|
||||
-- Default parameters (stored as JSON object)
|
||||
default_params TEXT NOT NULL DEFAULT '{}',
|
||||
|
||||
-- License info
|
||||
license TEXT,
|
||||
|
||||
-- Popularity metrics
|
||||
pull_count INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- Available tags/variants (stored as JSON array)
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
|
||||
-- Timestamps
|
||||
ollama_updated_at TEXT,
|
||||
details_fetched_at TEXT,
|
||||
scraped_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
|
||||
-- URL to model page
|
||||
url TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Indexes for remote models
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_models_name ON remote_models(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_models_model_type ON remote_models(model_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_models_pull_count ON remote_models(pull_count DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_models_scraped_at ON remote_models(scraped_at);
|
||||
`
|
||||
|
||||
// Additional migrations for schema updates (run separately to handle existing tables)
|
||||
const additionalMigrations = `
|
||||
-- Add tag_sizes column for storing file sizes per tag variant
|
||||
-- This column stores a JSON object mapping tag names to file sizes in bytes
|
||||
-- Example: {"8b": 4700000000, "70b": 40000000000}
|
||||
`
|
||||
|
||||
// RunMigrations executes all database migrations
|
||||
@@ -57,5 +108,20 @@ func RunMigrations(db *sql.DB) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
// Add tag_sizes column if it doesn't exist
|
||||
// SQLite doesn't have IF NOT EXISTS for ALTER TABLE, so we check first
|
||||
var count int
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('remote_models') WHERE name='tag_sizes'`).Scan(&count)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check tag_sizes column: %w", err)
|
||||
}
|
||||
if count == 0 {
|
||||
_, err = db.Exec(`ALTER TABLE remote_models ADD COLUMN tag_sizes TEXT NOT NULL DEFAULT '{}'`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add tag_sizes column: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
193
frontend/src/lib/api/model-registry.ts
Normal file
193
frontend/src/lib/api/model-registry.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Model Registry API Client
|
||||
* Interacts with the backend model registry for browsing/searching ollama.com models
|
||||
*/
|
||||
|
||||
/** Remote model from ollama.com (cached in backend) */
|
||||
export interface RemoteModel {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
modelType: 'official' | 'community';
|
||||
architecture?: string;
|
||||
parameterSize?: string;
|
||||
contextLength?: number;
|
||||
embeddingLength?: number;
|
||||
quantization?: string;
|
||||
capabilities: string[];
|
||||
defaultParams?: Record<string, unknown>;
|
||||
license?: string;
|
||||
pullCount: number;
|
||||
tags: string[];
|
||||
tagSizes?: Record<string, number>; // Maps tag name to file size in bytes
|
||||
ollamaUpdatedAt?: string;
|
||||
detailsFetchedAt?: string;
|
||||
scrapedAt: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** Response from listing/searching models */
|
||||
export interface ModelListResponse {
|
||||
models: RemoteModel[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** Response from sync operation */
|
||||
export interface SyncResponse {
|
||||
synced: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Sync status */
|
||||
export interface SyncStatus {
|
||||
modelCount: number;
|
||||
lastSync: string | null;
|
||||
}
|
||||
|
||||
/** Search/filter options */
|
||||
export interface ModelSearchOptions {
|
||||
search?: string;
|
||||
type?: 'official' | 'community';
|
||||
capabilities?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// Backend API base URL (relative to frontend)
|
||||
const API_BASE = '/api/v1/models';
|
||||
|
||||
/**
|
||||
* Fetch remote models with optional search/filter
|
||||
*/
|
||||
export async function fetchRemoteModels(options: ModelSearchOptions = {}): Promise<ModelListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (options.search) params.set('search', options.search);
|
||||
if (options.type) params.set('type', options.type);
|
||||
if (options.capabilities && options.capabilities.length > 0) {
|
||||
params.set('capabilities', options.capabilities.join(','));
|
||||
}
|
||||
if (options.limit) params.set('limit', String(options.limit));
|
||||
if (options.offset) params.set('offset', String(options.offset));
|
||||
|
||||
const url = `${API_BASE}/remote${params.toString() ? '?' + params.toString() : ''}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch models: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single remote model by slug
|
||||
*/
|
||||
export async function getRemoteModel(slug: string): Promise<RemoteModel> {
|
||||
const response = await fetch(`${API_BASE}/remote/${encodeURIComponent(slug)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Model not found: ${slug}`);
|
||||
}
|
||||
throw new Error(`Failed to fetch model: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch detailed model info via ollama show (requires model to be available locally)
|
||||
*/
|
||||
export async function fetchModelDetails(slug: string): Promise<RemoteModel> {
|
||||
const response = await fetch(`${API_BASE}/remote/${encodeURIComponent(slug)}/details`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch model details: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch file sizes per tag from ollama.com (scrapes model detail page)
|
||||
*/
|
||||
export async function fetchTagSizes(slug: string): Promise<RemoteModel> {
|
||||
const response = await fetch(`${API_BASE}/remote/${encodeURIComponent(slug)}/sizes`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch tag sizes: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync models from ollama.com
|
||||
*/
|
||||
export async function syncModels(): Promise<SyncResponse> {
|
||||
const response = await fetch(`${API_BASE}/remote/sync`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to sync models: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync status
|
||||
*/
|
||||
export async function getSyncStatus(): Promise<SyncStatus> {
|
||||
const response = await fetch(`${API_BASE}/remote/status`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get sync status: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format pull count for display (e.g., "108.2M")
|
||||
*/
|
||||
export function formatPullCount(count: number): string {
|
||||
if (count >= 1_000_000_000) {
|
||||
return `${(count / 1_000_000_000).toFixed(1)}B`;
|
||||
}
|
||||
if (count >= 1_000_000) {
|
||||
return `${(count / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (count >= 1_000) {
|
||||
return `${(count / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
return String(count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format context length for display
|
||||
*/
|
||||
export function formatContextLength(length: number): string {
|
||||
if (length >= 1_000_000) {
|
||||
return `${(length / 1_000_000).toFixed(0)}M`;
|
||||
}
|
||||
if (length >= 1_000) {
|
||||
return `${(length / 1_000).toFixed(0)}K`;
|
||||
}
|
||||
return String(length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model has a specific capability
|
||||
*/
|
||||
export function hasCapability(model: RemoteModel, capability: string): boolean {
|
||||
return model.capabilities.includes(capability);
|
||||
}
|
||||
@@ -49,6 +49,28 @@
|
||||
|
||||
<!-- Footer / Navigation links -->
|
||||
<div class="border-t border-slate-700/50 p-3 space-y-1">
|
||||
<!-- Model Browser link -->
|
||||
<a
|
||||
href="/models"
|
||||
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {isActive('/models') ? 'bg-cyan-900/30 text-cyan-400' : 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 0-6.23.693L5 14.5m14.8.8 1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0 1 12 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"
|
||||
/>
|
||||
</svg>
|
||||
<span>Models</span>
|
||||
</a>
|
||||
|
||||
<!-- Knowledge Base link -->
|
||||
<a
|
||||
href="/knowledge"
|
||||
|
||||
107
frontend/src/lib/components/models/ModelCard.svelte
Normal file
107
frontend/src/lib/components/models/ModelCard.svelte
Normal file
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ModelCard - Displays a remote model from ollama.com
|
||||
*/
|
||||
import type { RemoteModel } from '$lib/api/model-registry';
|
||||
import { formatPullCount, formatContextLength } from '$lib/api/model-registry';
|
||||
|
||||
interface Props {
|
||||
model: RemoteModel;
|
||||
onSelect?: (model: RemoteModel) => void;
|
||||
}
|
||||
|
||||
let { model, onSelect }: Props = $props();
|
||||
|
||||
// Capability badges config (matches ollama.com capabilities)
|
||||
const capabilityBadges: Record<string, { icon: string; color: string; label: string }> = {
|
||||
vision: { icon: '👁', color: 'bg-purple-900/50 text-purple-300', label: 'Vision' },
|
||||
tools: { icon: '🔧', color: 'bg-blue-900/50 text-blue-300', label: 'Tools' },
|
||||
thinking: { icon: '🧠', color: 'bg-pink-900/50 text-pink-300', label: 'Thinking' },
|
||||
embedding: { icon: '📊', color: 'bg-amber-900/50 text-amber-300', label: 'Embedding' },
|
||||
cloud: { icon: '☁️', color: 'bg-cyan-900/50 text-cyan-300', label: 'Cloud' }
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onSelect?.(model)}
|
||||
class="group w-full rounded-lg border border-slate-700 bg-slate-800 p-4 text-left transition-all hover:border-slate-600 hover:bg-slate-750"
|
||||
>
|
||||
<!-- Header: Name and Type Badge -->
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="font-medium text-white group-hover:text-blue-400">
|
||||
{model.name}
|
||||
</h3>
|
||||
<span
|
||||
class="shrink-0 rounded px-2 py-0.5 text-xs {model.modelType === 'official'
|
||||
? 'bg-blue-900/50 text-blue-300'
|
||||
: 'bg-slate-700 text-slate-400'}"
|
||||
>
|
||||
{model.modelType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
{#if model.description}
|
||||
<p class="mt-2 line-clamp-2 text-sm text-slate-400">
|
||||
{model.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Capabilities -->
|
||||
{#if model.capabilities.length > 0}
|
||||
<div class="mt-3 flex flex-wrap gap-1.5">
|
||||
{#each model.capabilities as capability}
|
||||
{@const badge = capabilityBadges[capability]}
|
||||
{#if badge}
|
||||
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs {badge.color}">
|
||||
<span>{badge.icon}</span>
|
||||
<span>{badge.label}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="mt-3 flex items-center gap-4 text-xs text-slate-500">
|
||||
<!-- Pull Count -->
|
||||
<div class="flex items-center gap-1" title="{model.pullCount.toLocaleString()} pulls">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>{formatPullCount(model.pullCount)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Available Sizes (from tags) -->
|
||||
{#if model.tags.length > 0}
|
||||
<div class="flex items-center gap-1" title="Available parameter sizes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<span>{model.tags.length} size{model.tags.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Context Length (if fetched from ollama show) -->
|
||||
{#if model.contextLength}
|
||||
<div class="flex items-center gap-1" title="Context length">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
<span>{formatContextLength(model.contextLength)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Size Tags -->
|
||||
{#if model.tags.length > 0}
|
||||
<div class="mt-3 flex flex-wrap gap-1">
|
||||
{#each model.tags as tag}
|
||||
<span class="rounded bg-blue-900/30 px-1.5 py-0.5 text-xs font-medium text-blue-300">
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
1
frontend/src/lib/components/models/index.ts
Normal file
1
frontend/src/lib/components/models/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ModelCard } from './ModelCard.svelte';
|
||||
204
frontend/src/lib/stores/model-registry.svelte.ts
Normal file
204
frontend/src/lib/stores/model-registry.svelte.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Model Registry Store
|
||||
* Manages state for browsing and searching remote models from ollama.com
|
||||
*/
|
||||
|
||||
import {
|
||||
fetchRemoteModels,
|
||||
getSyncStatus,
|
||||
syncModels,
|
||||
type RemoteModel,
|
||||
type SyncStatus,
|
||||
type ModelSearchOptions
|
||||
} from '$lib/api/model-registry';
|
||||
|
||||
/** Store state */
|
||||
class ModelRegistryState {
|
||||
// Model list
|
||||
models = $state<RemoteModel[]>([]);
|
||||
total = $state(0);
|
||||
loading = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
// Search/filter state
|
||||
searchQuery = $state('');
|
||||
modelType = $state<'official' | 'community' | ''>('');
|
||||
selectedCapabilities = $state<string[]>([]);
|
||||
currentPage = $state(0);
|
||||
pageSize = $state(24);
|
||||
|
||||
// Sync status
|
||||
syncStatus = $state<SyncStatus | null>(null);
|
||||
syncing = $state(false);
|
||||
|
||||
// Selected model for details view
|
||||
selectedModel = $state<RemoteModel | null>(null);
|
||||
|
||||
// Derived: total pages
|
||||
totalPages = $derived(Math.ceil(this.total / this.pageSize));
|
||||
|
||||
// Derived: has more pages
|
||||
hasNextPage = $derived(this.currentPage < this.totalPages - 1);
|
||||
hasPrevPage = $derived(this.currentPage > 0);
|
||||
|
||||
/**
|
||||
* Load models with current filters
|
||||
*/
|
||||
async loadModels(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const options: ModelSearchOptions = {
|
||||
limit: this.pageSize,
|
||||
offset: this.currentPage * this.pageSize
|
||||
};
|
||||
|
||||
if (this.searchQuery.trim()) {
|
||||
options.search = this.searchQuery.trim();
|
||||
}
|
||||
|
||||
if (this.modelType) {
|
||||
options.type = this.modelType;
|
||||
}
|
||||
|
||||
if (this.selectedCapabilities.length > 0) {
|
||||
options.capabilities = this.selectedCapabilities;
|
||||
}
|
||||
|
||||
const response = await fetchRemoteModels(options);
|
||||
this.models = response.models;
|
||||
this.total = response.total;
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : 'Failed to load models';
|
||||
console.error('Failed to load models:', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search models (resets to first page)
|
||||
*/
|
||||
async search(query: string): Promise<void> {
|
||||
this.searchQuery = query;
|
||||
this.currentPage = 0;
|
||||
await this.loadModels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter by model type
|
||||
*/
|
||||
async filterByType(type: 'official' | 'community' | ''): Promise<void> {
|
||||
this.modelType = type;
|
||||
this.currentPage = 0;
|
||||
await this.loadModels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a capability filter
|
||||
*/
|
||||
async toggleCapability(capability: string): Promise<void> {
|
||||
const index = this.selectedCapabilities.indexOf(capability);
|
||||
if (index === -1) {
|
||||
this.selectedCapabilities = [...this.selectedCapabilities, capability];
|
||||
} else {
|
||||
this.selectedCapabilities = this.selectedCapabilities.filter((c) => c !== capability);
|
||||
}
|
||||
this.currentPage = 0;
|
||||
await this.loadModels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a capability is selected
|
||||
*/
|
||||
hasCapability(capability: string): boolean {
|
||||
return this.selectedCapabilities.includes(capability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to next page
|
||||
*/
|
||||
async nextPage(): Promise<void> {
|
||||
if (this.hasNextPage) {
|
||||
this.currentPage++;
|
||||
await this.loadModels();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to previous page
|
||||
*/
|
||||
async prevPage(): Promise<void> {
|
||||
if (this.hasPrevPage) {
|
||||
this.currentPage--;
|
||||
await this.loadModels();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to specific page
|
||||
*/
|
||||
async goToPage(page: number): Promise<void> {
|
||||
if (page >= 0 && page < this.totalPages) {
|
||||
this.currentPage = page;
|
||||
await this.loadModels();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load sync status
|
||||
*/
|
||||
async loadSyncStatus(): Promise<void> {
|
||||
try {
|
||||
this.syncStatus = await getSyncStatus();
|
||||
} catch (err) {
|
||||
console.error('Failed to load sync status:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync models from ollama.com
|
||||
*/
|
||||
async sync(): Promise<void> {
|
||||
this.syncing = true;
|
||||
try {
|
||||
await syncModels();
|
||||
await this.loadSyncStatus();
|
||||
await this.loadModels();
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : 'Failed to sync models';
|
||||
console.error('Failed to sync:', err);
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a model for details view
|
||||
*/
|
||||
selectModel(model: RemoteModel | null): void {
|
||||
this.selectedModel = model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear search and filters
|
||||
*/
|
||||
async clearFilters(): Promise<void> {
|
||||
this.searchQuery = '';
|
||||
this.modelType = '';
|
||||
this.selectedCapabilities = [];
|
||||
this.currentPage = 0;
|
||||
await this.loadModels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the store
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
await Promise.all([this.loadSyncStatus(), this.loadModels()]);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const modelRegistry = new ModelRegistryState();
|
||||
655
frontend/src/routes/models/+page.svelte
Normal file
655
frontend/src/routes/models/+page.svelte
Normal file
@@ -0,0 +1,655 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Model Browser Page
|
||||
* Browse and search models from ollama.com
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { modelRegistry } from '$lib/stores/model-registry.svelte';
|
||||
import { modelsState } from '$lib/stores/models.svelte';
|
||||
import { ModelCard } from '$lib/components/models';
|
||||
import { fetchTagSizes, type RemoteModel } from '$lib/api/model-registry';
|
||||
|
||||
// Search debounce
|
||||
let searchInput = $state('');
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Debounced search handler
|
||||
function handleSearchInput(e: Event): void {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
searchInput = value;
|
||||
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
modelRegistry.search(value);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Type filter handler
|
||||
function handleTypeFilter(type: 'official' | 'community' | ''): void {
|
||||
modelRegistry.filterByType(type);
|
||||
}
|
||||
|
||||
// Selected model for details panel
|
||||
let selectedModel = $state<RemoteModel | null>(null);
|
||||
let selectedTag = $state<string>('');
|
||||
let pulling = $state(false);
|
||||
let pullProgress = $state<{ status: string; completed?: number; total?: number } | null>(null);
|
||||
let pullError = $state<string | null>(null);
|
||||
let loadingSizes = $state(false);
|
||||
|
||||
async function handleSelectModel(model: RemoteModel): Promise<void> {
|
||||
selectedModel = model;
|
||||
selectedTag = model.tags[0] || '';
|
||||
pullProgress = null;
|
||||
pullError = null;
|
||||
|
||||
// Fetch tag sizes if not already loaded
|
||||
if (!model.tagSizes || Object.keys(model.tagSizes).length === 0) {
|
||||
loadingSizes = true;
|
||||
try {
|
||||
const updatedModel = await fetchTagSizes(model.slug);
|
||||
// Update the model with fetched sizes
|
||||
selectedModel = { ...model, tagSizes: updatedModel.tagSizes };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch tag sizes:', err);
|
||||
} finally {
|
||||
loadingSizes = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetails(): void {
|
||||
selectedModel = null;
|
||||
selectedTag = '';
|
||||
pullProgress = null;
|
||||
pullError = null;
|
||||
}
|
||||
|
||||
// Pull model from Ollama
|
||||
async function pullModel(): Promise<void> {
|
||||
if (!selectedModel || pulling) return;
|
||||
|
||||
const modelName = selectedTag
|
||||
? `${selectedModel.slug}:${selectedTag}`
|
||||
: selectedModel.slug;
|
||||
|
||||
pulling = true;
|
||||
pullError = null;
|
||||
pullProgress = { status: 'Starting pull...' };
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/ollama/api/pull', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: modelName })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to pull model: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Read streaming response
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.error) {
|
||||
pullError = data.error;
|
||||
break;
|
||||
}
|
||||
pullProgress = {
|
||||
status: data.status || 'Pulling...',
|
||||
completed: data.completed,
|
||||
total: data.total
|
||||
};
|
||||
} catch {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!pullError) {
|
||||
pullProgress = { status: 'Pull complete!' };
|
||||
// Refresh local model list and select the new model
|
||||
await modelsState.refresh();
|
||||
modelsState.select(modelName);
|
||||
}
|
||||
} catch (err) {
|
||||
pullError = err instanceof Error ? err.message : 'Failed to pull model';
|
||||
} finally {
|
||||
pulling = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
function formatDate(dateStr: string | undefined): string {
|
||||
if (!dateStr) return 'Never';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Format bytes for display (e.g., 1.5 GB)
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const value = bytes / Math.pow(k, i);
|
||||
return `${value.toFixed(i > 1 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
// Initialize on mount
|
||||
onMount(() => {
|
||||
modelRegistry.init();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full overflow-hidden bg-slate-900">
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Model Browser</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
Browse and search models from ollama.com
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sync Status & Button -->
|
||||
<div class="flex items-center gap-3">
|
||||
{#if modelRegistry.syncStatus}
|
||||
<div class="text-right text-xs text-slate-500">
|
||||
<div>{modelRegistry.syncStatus.modelCount} models cached</div>
|
||||
<div>Last sync: {formatDate(modelRegistry.syncStatus.lastSync ?? undefined)}</div>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => modelRegistry.sync()}
|
||||
disabled={modelRegistry.syncing}
|
||||
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if modelRegistry.syncing}
|
||||
<svg class="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>Syncing...</span>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>Sync Models</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="mb-6 flex flex-wrap items-center gap-4">
|
||||
<!-- Search Input -->
|
||||
<div class="relative flex-1 min-w-[200px]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
oninput={handleSearchInput}
|
||||
placeholder="Search models..."
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 py-2 pl-10 pr-4 text-white placeholder-slate-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Type Filter -->
|
||||
<div class="flex rounded-lg border border-slate-700 bg-slate-800 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleTypeFilter('')}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {modelRegistry.modelType === ''
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'text-slate-400 hover:text-white'}"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleTypeFilter('official')}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {modelRegistry.modelType === 'official'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-slate-400 hover:text-white'}"
|
||||
>
|
||||
Official
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleTypeFilter('community')}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {modelRegistry.modelType === 'community'
|
||||
? 'bg-slate-600 text-white'
|
||||
: 'text-slate-400 hover:text-white'}"
|
||||
>
|
||||
Community
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results Count -->
|
||||
<div class="text-sm text-slate-500">
|
||||
{modelRegistry.total} model{modelRegistry.total !== 1 ? 's' : ''} found
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capability Filters (matches ollama.com capabilities) -->
|
||||
<div class="mb-6 flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm text-slate-500">Capabilities:</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => modelRegistry.toggleCapability('vision')}
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('vision')
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white'}"
|
||||
>
|
||||
<span>👁</span>
|
||||
<span>Vision</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => modelRegistry.toggleCapability('tools')}
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('tools')
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white'}"
|
||||
>
|
||||
<span>🔧</span>
|
||||
<span>Tools</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => modelRegistry.toggleCapability('thinking')}
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('thinking')
|
||||
? 'bg-pink-600 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white'}"
|
||||
>
|
||||
<span>🧠</span>
|
||||
<span>Thinking</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => modelRegistry.toggleCapability('embedding')}
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('embedding')
|
||||
? 'bg-amber-600 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white'}"
|
||||
>
|
||||
<span>📊</span>
|
||||
<span>Embedding</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => modelRegistry.toggleCapability('cloud')}
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasCapability('cloud')
|
||||
? 'bg-cyan-600 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white'}"
|
||||
>
|
||||
<span>☁️</span>
|
||||
<span>Cloud</span>
|
||||
</button>
|
||||
|
||||
{#if modelRegistry.selectedCapabilities.length > 0 || modelRegistry.modelType || modelRegistry.searchQuery}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { modelRegistry.clearFilters(); searchInput = ''; }}
|
||||
class="ml-2 text-sm text-slate-500 hover:text-white"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
{#if modelRegistry.error}
|
||||
<div class="mb-6 rounded-lg border border-red-900/50 bg-red-900/20 p-4">
|
||||
<div class="flex items-center gap-2 text-red-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{modelRegistry.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading State -->
|
||||
{#if modelRegistry.loading}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each Array(6) as _}
|
||||
<div class="animate-pulse rounded-lg border border-slate-700 bg-slate-800 p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="h-5 w-32 rounded bg-slate-700"></div>
|
||||
<div class="h-5 w-16 rounded bg-slate-700"></div>
|
||||
</div>
|
||||
<div class="mt-3 h-4 w-full rounded bg-slate-700"></div>
|
||||
<div class="mt-2 h-4 w-2/3 rounded bg-slate-700"></div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<div class="h-6 w-16 rounded bg-slate-700"></div>
|
||||
<div class="h-6 w-16 rounded bg-slate-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if modelRegistry.models.length === 0}
|
||||
<!-- Empty State -->
|
||||
<div class="rounded-lg border border-dashed border-slate-700 bg-slate-800/50 p-12 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-slate-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611l-.628.105a9.002 9.002 0 01-9.014 0l-.628-.105c-1.717-.293-2.3-2.379-1.067-3.61L5 14.5" />
|
||||
</svg>
|
||||
<h3 class="mt-4 text-sm font-medium text-slate-400">No models found</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{#if modelRegistry.searchQuery || modelRegistry.modelType}
|
||||
Try adjusting your search or filters
|
||||
{:else}
|
||||
Click "Sync Models" to fetch models from ollama.com
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Model Grid -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each modelRegistry.models as model (model.slug)}
|
||||
<ModelCard {model} onSelect={handleSelectModel} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if modelRegistry.totalPages > 1}
|
||||
<div class="mt-6 flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => modelRegistry.prevPage()}
|
||||
disabled={!modelRegistry.hasPrevPage}
|
||||
class="rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-slate-700 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span class="text-sm text-slate-400">
|
||||
Page {modelRegistry.currentPage + 1} of {modelRegistry.totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => modelRegistry.nextPage()}
|
||||
disabled={!modelRegistry.hasNextPage}
|
||||
class="rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-slate-700 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Details Sidebar -->
|
||||
{#if selectedModel}
|
||||
<div class="w-96 flex-shrink-0 overflow-y-auto border-l border-slate-700 bg-slate-850 p-6">
|
||||
<!-- Close Button -->
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">{selectedModel.name}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeDetails}
|
||||
class="rounded p-1 text-slate-400 transition-colors hover:bg-slate-700 hover:text-white"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Type Badge -->
|
||||
<div class="mb-4">
|
||||
<span class="rounded px-2 py-1 text-xs {selectedModel.modelType === 'official' ? 'bg-blue-900/50 text-blue-300' : 'bg-slate-700 text-slate-400'}">
|
||||
{selectedModel.modelType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
{#if selectedModel.description}
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-2 text-sm font-medium text-slate-300">Description</h3>
|
||||
<p class="text-sm text-slate-400">{selectedModel.description}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Capabilities -->
|
||||
{#if selectedModel.capabilities.length > 0}
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-2 text-sm font-medium text-slate-300">Capabilities</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each selectedModel.capabilities as cap}
|
||||
<span class="rounded bg-slate-700 px-2 py-1 text-xs text-slate-300">{cap}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Technical Details -->
|
||||
<div class="mb-6 space-y-3">
|
||||
<h3 class="text-sm font-medium text-slate-300">Details</h3>
|
||||
|
||||
{#if selectedModel.architecture}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-slate-500">Architecture</span>
|
||||
<span class="text-slate-300">{selectedModel.architecture}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedModel.parameterSize}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-slate-500">Parameters</span>
|
||||
<span class="text-slate-300">{selectedModel.parameterSize}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedModel.contextLength}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-slate-500">Context Length</span>
|
||||
<span class="text-slate-300">{selectedModel.contextLength.toLocaleString()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedModel.embeddingLength}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-slate-500">Embedding Dim</span>
|
||||
<span class="text-slate-300">{selectedModel.embeddingLength.toLocaleString()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedModel.quantization}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-slate-500">Quantization</span>
|
||||
<span class="text-slate-300">{selectedModel.quantization}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedModel.license}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-slate-500">License</span>
|
||||
<span class="text-slate-300">{selectedModel.license}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-slate-500">Downloads</span>
|
||||
<span class="text-slate-300">{selectedModel.pullCount.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Sizes (Parameter counts + file sizes) -->
|
||||
{#if selectedModel.tags.length > 0}
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-2 flex items-center gap-2 text-sm font-medium text-slate-300">
|
||||
<span>Available Sizes</span>
|
||||
{#if loadingSizes}
|
||||
<svg class="h-3 w-3 animate-spin text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{/if}
|
||||
</h3>
|
||||
<div class="space-y-1">
|
||||
{#each selectedModel.tags as tag}
|
||||
{@const size = selectedModel.tagSizes?.[tag]}
|
||||
<div class="flex items-center justify-between rounded bg-slate-800 px-2 py-1.5">
|
||||
<span class="text-xs font-medium text-blue-300">{tag}</span>
|
||||
{#if size}
|
||||
<span class="text-xs text-slate-400">{formatBytes(size)}</span>
|
||||
{:else if loadingSizes}
|
||||
<span class="text-xs text-slate-500">...</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-slate-500">
|
||||
Parameter sizes (e.g., 8b = 8 billion parameters)
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pull Model Section -->
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-2 text-sm font-medium text-slate-300">Pull Model</h3>
|
||||
|
||||
<!-- Tag/Size Selector -->
|
||||
{#if selectedModel.tags.length > 0}
|
||||
<div class="mb-3">
|
||||
<label for="tag-select" class="mb-1 flex items-center gap-2 text-xs text-slate-500">
|
||||
<span>Select variant:</span>
|
||||
{#if loadingSizes}
|
||||
<svg class="h-3 w-3 animate-spin text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{/if}
|
||||
</label>
|
||||
<select
|
||||
id="tag-select"
|
||||
bind:value={selectedTag}
|
||||
disabled={pulling}
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{#each selectedModel.tags as tag}
|
||||
{@const size = selectedModel.tagSizes?.[tag]}
|
||||
<option value={tag}>
|
||||
{selectedModel.slug}:{tag}
|
||||
{#if size}
|
||||
({formatBytes(size)})
|
||||
{/if}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pull Button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={pullModel}
|
||||
disabled={pulling}
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if pulling}
|
||||
<svg class="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>Pulling...</span>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>Pull Model</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Progress Display -->
|
||||
{#if pullProgress}
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="text-xs text-slate-400">{pullProgress.status}</div>
|
||||
{#if pullProgress.completed !== undefined && pullProgress.total !== undefined && pullProgress.total > 0}
|
||||
{@const percent = Math.round((pullProgress.completed / pullProgress.total) * 100)}
|
||||
<div class="h-2 w-full overflow-hidden rounded-full bg-slate-700">
|
||||
<div
|
||||
class="h-full rounded-full bg-blue-500 transition-all duration-300"
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-slate-500">
|
||||
<span>{formatBytes(pullProgress.completed)}</span>
|
||||
<span>{percent}%</span>
|
||||
<span>{formatBytes(pullProgress.total)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Display -->
|
||||
{#if pullError}
|
||||
<div class="mt-3 rounded-lg border border-red-900/50 bg-red-900/20 p-3">
|
||||
<div class="flex items-start gap-2 text-sm text-red-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{pullError}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
href={selectedModel.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border border-slate-700 bg-slate-800 px-4 py-2 text-sm text-slate-300 transition-colors hover:bg-slate-700 hover:text-white"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
View on ollama.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user