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:
2026-01-01 01:02:42 +01:00
parent 1529f31d6c
commit 2241075563
11 changed files with 2149 additions and 7 deletions

View File

@@ -1,4 +1,4 @@
FROM golang:1.23-alpine AS builder
FROM golang:1.24-alpine AS builder
WORKDIR /app

View 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{
"&#39;": "'",
"&#34;": "\"",
"&quot;": "\"",
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&nbsp;": " ",
}
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, &paramSize, &ctxLen, &embedLen, &quant,
&caps, &params, &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, &paramSize, &ctxLen, &embedLen, &quant,
&caps, &params, &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)
}
}

View File

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

View File

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

View File

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

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

View File

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

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

View File

@@ -0,0 +1 @@
export { default as ModelCard } from './ModelCard.svelte';

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

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