diff --git a/backend/Dockerfile b/backend/Dockerfile index cec7969..eb02a46 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23-alpine AS builder +FROM golang:1.24-alpine AS builder WORKDIR /app diff --git a/backend/internal/api/model_registry.go b/backend/internal/api/model_registry.go new file mode 100644 index 0000000..b376d28 --- /dev/null +++ b/backend/internal/api/model_registry.go @@ -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: + // Each card contains description and pull count + // Note: [^":]+ allows / for community models like "username/modelname" + cardPattern := regexp.MustCompile(`]*href="/library/([^":]+)"[^>]*class="[^"]*group[^"]*"[^>]*>([\s\S]*?)`) + 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

+ descPattern := regexp.MustCompile(`]*class="[^"]*text-neutral-800[^"]*"[^>]*>([^<]+)

`) + desc := "" + if dm := descPattern.FindStringSubmatch(cardContent); len(dm) > 1 { + desc = decodeHTMLEntities(strings.TrimSpace(dm[1])) + } + + // Extract pull count from 60.3K + pullPattern := regexp.MustCompile(`]*x-test-pull-count[^>]*>([^<]+)`) + pullCount := int64(0) + if pm := pullPattern.FindStringSubmatch(cardContent); len(pm) > 1 { + pullCount = parsePullCount(pm[1]) + } + + // Extract size tags (8b, 70b, etc.) + sizePattern := regexp.MustCompile(`]*x-test-size[^>]*>([^<]+)`) + 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 vision + capPattern := regexp.MustCompile(`]*x-test-capability[^>]*>([^<]+)`) + 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: cloud + cloudPattern := regexp.MustCompile(`]*class="[^"]*bg-cyan-50[^"]*text-cyan-500[^"]*"[^>]*>cloud`) + 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) + `"[^>]*>([^<]*)\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) + } +} diff --git a/backend/internal/api/ollama_client.go b/backend/internal/api/ollama_client.go index 580980e..de8c18d 100644 --- a/backend/internal/api/ollama_client.go +++ b/backend/internal/api/ollama_client.go @@ -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) diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index c98debc..482b238 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -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)) } } diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index b76fb2e..4f9e966 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -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 } diff --git a/frontend/src/lib/api/model-registry.ts b/frontend/src/lib/api/model-registry.ts new file mode 100644 index 0000000..3668623 --- /dev/null +++ b/frontend/src/lib/api/model-registry.ts @@ -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; + license?: string; + pullCount: number; + tags: string[]; + tagSizes?: Record; // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/frontend/src/lib/components/layout/Sidenav.svelte b/frontend/src/lib/components/layout/Sidenav.svelte index 0941556..29188b6 100644 --- a/frontend/src/lib/components/layout/Sidenav.svelte +++ b/frontend/src/lib/components/layout/Sidenav.svelte @@ -49,6 +49,28 @@
+ + + + + + Models + + + /** + * 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 = { + 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' } + }; + + + diff --git a/frontend/src/lib/components/models/index.ts b/frontend/src/lib/components/models/index.ts new file mode 100644 index 0000000..949c0ab --- /dev/null +++ b/frontend/src/lib/components/models/index.ts @@ -0,0 +1 @@ +export { default as ModelCard } from './ModelCard.svelte'; diff --git a/frontend/src/lib/stores/model-registry.svelte.ts b/frontend/src/lib/stores/model-registry.svelte.ts new file mode 100644 index 0000000..4fd8734 --- /dev/null +++ b/frontend/src/lib/stores/model-registry.svelte.ts @@ -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([]); + total = $state(0); + loading = $state(false); + error = $state(null); + + // Search/filter state + searchQuery = $state(''); + modelType = $state<'official' | 'community' | ''>(''); + selectedCapabilities = $state([]); + currentPage = $state(0); + pageSize = $state(24); + + // Sync status + syncStatus = $state(null); + syncing = $state(false); + + // Selected model for details view + selectedModel = $state(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 { + 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 { + this.searchQuery = query; + this.currentPage = 0; + await this.loadModels(); + } + + /** + * Filter by model type + */ + async filterByType(type: 'official' | 'community' | ''): Promise { + this.modelType = type; + this.currentPage = 0; + await this.loadModels(); + } + + /** + * Toggle a capability filter + */ + async toggleCapability(capability: string): Promise { + 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 { + if (this.hasNextPage) { + this.currentPage++; + await this.loadModels(); + } + } + + /** + * Go to previous page + */ + async prevPage(): Promise { + if (this.hasPrevPage) { + this.currentPage--; + await this.loadModels(); + } + } + + /** + * Go to specific page + */ + async goToPage(page: number): Promise { + if (page >= 0 && page < this.totalPages) { + this.currentPage = page; + await this.loadModels(); + } + } + + /** + * Load sync status + */ + async loadSyncStatus(): Promise { + try { + this.syncStatus = await getSyncStatus(); + } catch (err) { + console.error('Failed to load sync status:', err); + } + } + + /** + * Sync models from ollama.com + */ + async sync(): Promise { + 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 { + this.searchQuery = ''; + this.modelType = ''; + this.selectedCapabilities = []; + this.currentPage = 0; + await this.loadModels(); + } + + /** + * Initialize the store + */ + async init(): Promise { + await Promise.all([this.loadSyncStatus(), this.loadModels()]); + } +} + +// Export singleton instance +export const modelRegistry = new ModelRegistryState(); diff --git a/frontend/src/routes/models/+page.svelte b/frontend/src/routes/models/+page.svelte new file mode 100644 index 0000000..e1e7a3e --- /dev/null +++ b/frontend/src/routes/models/+page.svelte @@ -0,0 +1,655 @@ + + +
+ +
+
+ +
+
+

Model Browser

+

+ Browse and search models from ollama.com +

+
+ + +
+ {#if modelRegistry.syncStatus} +
+
{modelRegistry.syncStatus.modelCount} models cached
+
Last sync: {formatDate(modelRegistry.syncStatus.lastSync ?? undefined)}
+
+ {/if} + +
+
+ + +
+ +
+ + + + +
+ + +
+ + + +
+ + +
+ {modelRegistry.total} model{modelRegistry.total !== 1 ? 's' : ''} found +
+
+ + +
+ Capabilities: + + + + + + + {#if modelRegistry.selectedCapabilities.length > 0 || modelRegistry.modelType || modelRegistry.searchQuery} + + {/if} +
+ + + {#if modelRegistry.error} +
+
+ + + + {modelRegistry.error} +
+
+ {/if} + + + {#if modelRegistry.loading} +
+ {#each Array(6) as _} +
+
+
+
+
+
+
+
+
+
+
+
+ {/each} +
+ {:else if modelRegistry.models.length === 0} + +
+ + + +

No models found

+

+ {#if modelRegistry.searchQuery || modelRegistry.modelType} + Try adjusting your search or filters + {:else} + Click "Sync Models" to fetch models from ollama.com + {/if} +

+
+ {:else} + +
+ {#each modelRegistry.models as model (model.slug)} + + {/each} +
+ + + {#if modelRegistry.totalPages > 1} +
+ + + + Page {modelRegistry.currentPage + 1} of {modelRegistry.totalPages} + + + +
+ {/if} + {/if} +
+
+ + + {#if selectedModel} +
+ +
+

{selectedModel.name}

+ +
+ + +
+ + {selectedModel.modelType} + +
+ + + {#if selectedModel.description} +
+

Description

+

{selectedModel.description}

+
+ {/if} + + + {#if selectedModel.capabilities.length > 0} +
+

Capabilities

+
+ {#each selectedModel.capabilities as cap} + {cap} + {/each} +
+
+ {/if} + + +
+

Details

+ + {#if selectedModel.architecture} +
+ Architecture + {selectedModel.architecture} +
+ {/if} + + {#if selectedModel.parameterSize} +
+ Parameters + {selectedModel.parameterSize} +
+ {/if} + + {#if selectedModel.contextLength} +
+ Context Length + {selectedModel.contextLength.toLocaleString()} +
+ {/if} + + {#if selectedModel.embeddingLength} +
+ Embedding Dim + {selectedModel.embeddingLength.toLocaleString()} +
+ {/if} + + {#if selectedModel.quantization} +
+ Quantization + {selectedModel.quantization} +
+ {/if} + + {#if selectedModel.license} +
+ License + {selectedModel.license} +
+ {/if} + +
+ Downloads + {selectedModel.pullCount.toLocaleString()} +
+
+ + + {#if selectedModel.tags.length > 0} +
+

+ Available Sizes + {#if loadingSizes} + + + + + {/if} +

+
+ {#each selectedModel.tags as tag} + {@const size = selectedModel.tagSizes?.[tag]} +
+ {tag} + {#if size} + {formatBytes(size)} + {:else if loadingSizes} + ... + {/if} +
+ {/each} +
+

+ Parameter sizes (e.g., 8b = 8 billion parameters) +

+
+ {/if} + + +
+

Pull Model

+ + + {#if selectedModel.tags.length > 0} +
+ + +
+ {/if} + + + + + + {#if pullProgress} +
+
{pullProgress.status}
+ {#if pullProgress.completed !== undefined && pullProgress.total !== undefined && pullProgress.total > 0} + {@const percent = Math.round((pullProgress.completed / pullProgress.total) * 100)} +
+
+
+
+ {formatBytes(pullProgress.completed)} + {percent}% + {formatBytes(pullProgress.total)} +
+ {/if} +
+ {/if} + + + {#if pullError} +
+
+ + + + {pullError} +
+
+ {/if} +
+ + +
+
+ {/if} +