diff --git a/.github/gallery-agent/agent.go b/.github/gallery-agent/agent.go
index a1a481c07..5efd58dd2 100644
--- a/.github/gallery-agent/agent.go
+++ b/.github/gallery-agent/agent.go
@@ -2,7 +2,10 @@ package main
import (
"context"
+ "encoding/json"
"fmt"
+ "io"
+ "net/http"
"os"
"regexp"
"slices"
@@ -229,71 +232,192 @@ Return your analysis and selection reasoning.`)
return filteredModels, nil
}
-// ModelFamily represents a YAML anchor/family
-type ModelFamily struct {
- Anchor string `json:"anchor"`
- Name string `json:"name"`
+// ModelMetadata represents extracted metadata from a model
+type ModelMetadata struct {
+ Tags []string `json:"tags"`
+ License string `json:"license"`
}
-// selectModelFamily selects the appropriate model family/anchor for a given model
-func selectModelFamily(ctx context.Context, model ProcessedModel, availableFamilies []ModelFamily) (string, error) {
+// extractModelMetadata extracts tags and license from model README and documentation
+func extractModelMetadata(ctx context.Context, model ProcessedModel) ([]string, string, error) {
// Create a conversation fragment
fragment := cogito.NewEmptyFragment().
AddMessage("user",
- `Your task is to select the most appropriate model family/anchor for a given AI model. You will be provided with:
-1. Information about the model (name, description, etc.)
-2. A list of available model families/anchors
+ `Your task is to extract metadata from an AI model's README and documentation. You will be provided with:
+1. Model information (ID, author, description)
+2. README content
-You need to select the family that best matches the model's architecture, capabilities, or characteristics. Consider:
-- Model architecture (e.g., Llama, Qwen, Mistral, etc.)
-- Model capabilities (e.g., vision, coding, chat, etc.)
-- Model size/type (e.g., small, medium, large)
-- Model purpose (e.g., general purpose, specialized, etc.)
+You need to extract:
+1. **Tags**: An array of relevant tags that describe the model. Use common tags from the gallery such as:
+ - llm, gguf, gpu, cpu, multimodal, image-to-text, text-to-text, text-to-speech, tts
+ - thinking, reasoning, chat, instruction-tuned, code, vision
+ - Model family names (e.g., llama, qwen, mistral, gemma) if applicable
+ - Any other relevant descriptive tags
+ Select 3-8 most relevant tags.
-Return the anchor name that best fits the model.`)
+2. **License**: The license identifier (e.g., "apache-2.0", "mit", "llama2", "gpl-3.0", "bsd", "cc-by-4.0").
+ If no license is found, return an empty string.
+
+Return the extracted metadata in a structured format.`)
// Add model information
modelInfo := "Model Information:\n"
modelInfo += fmt.Sprintf(" ID: %s\n", model.ModelID)
modelInfo += fmt.Sprintf(" Author: %s\n", model.Author)
modelInfo += fmt.Sprintf(" Downloads: %d\n", model.Downloads)
- modelInfo += fmt.Sprintf(" Description: %s\n", model.ReadmeContentPreview)
-
- fragment = fragment.AddMessage("user", modelInfo)
-
- // Add available families
- familiesInfo := "Available Model Families:\n"
- for _, family := range availableFamilies {
- familiesInfo += fmt.Sprintf(" - %s (%s)\n", family.Anchor, family.Name)
+ if model.ReadmeContent != "" {
+ modelInfo += fmt.Sprintf(" README Content:\n%s\n", model.ReadmeContent)
+ } else if model.ReadmeContentPreview != "" {
+ modelInfo += fmt.Sprintf(" README Preview: %s\n", model.ReadmeContentPreview)
}
- fragment = fragment.AddMessage("user", familiesInfo)
- fragment = fragment.AddMessage("user", "Select the most appropriate family anchor for this model. Return just the anchor name.")
+ fragment = fragment.AddMessage("user", modelInfo)
+ fragment = fragment.AddMessage("user", "Extract the tags and license from the model information. Return the metadata as a JSON object with 'tags' (array of strings) and 'license' (string).")
// Get a response
newFragment, err := llm.Ask(ctx, fragment)
if err != nil {
- return "", err
+ return nil, "", err
}
- // Extract the selected family
- selectedFamily := strings.TrimSpace(newFragment.LastMessage().Content)
+ // Extract structured metadata
+ metadata := ModelMetadata{}
- // Validate that the selected family exists in our list
- for _, family := range availableFamilies {
- if family.Anchor == selectedFamily {
- return selectedFamily, nil
- }
+ s := structures.Structure{
+ Schema: jsonschema.Definition{
+ Type: jsonschema.Object,
+ AdditionalProperties: false,
+ Properties: map[string]jsonschema.Definition{
+ "tags": {
+ Type: jsonschema.Array,
+ Items: &jsonschema.Definition{Type: jsonschema.String},
+ Description: "Array of relevant tags describing the model",
+ },
+ "license": {
+ Type: jsonschema.String,
+ Description: "License identifier (e.g., apache-2.0, mit, llama2). Empty string if not found.",
+ },
+ },
+ Required: []string{"tags", "license"},
+ },
+ Object: &metadata,
}
- // If no exact match, try to find a close match
- for _, family := range availableFamilies {
- if strings.Contains(strings.ToLower(family.Anchor), strings.ToLower(selectedFamily)) ||
- strings.Contains(strings.ToLower(selectedFamily), strings.ToLower(family.Anchor)) {
- return family.Anchor, nil
- }
+ err = newFragment.ExtractStructure(ctx, llm, s)
+ if err != nil {
+ return nil, "", err
}
- // Default fallback
- return "llama3", nil
+ return metadata.Tags, metadata.License, nil
+}
+
+// extractIconFromReadme scans the README content for image URLs and returns the first suitable icon URL found
+func extractIconFromReadme(readmeContent string) string {
+ if readmeContent == "" {
+ return ""
+ }
+
+ // Regular expressions to match image URLs in various formats (case-insensitive)
+ // Match markdown image syntax:  - case insensitive extensions
+ markdownImageRegex := regexp.MustCompile(`(?i)!\[[^\]]*\]\(([^)]+\.(png|jpg|jpeg|svg|webp|gif))\)`)
+ // Match HTML img tags:
+ htmlImageRegex := regexp.MustCompile(`(?i)
]+src=["']([^"']+\.(png|jpg|jpeg|svg|webp|gif))["']`)
+ // Match plain URLs ending with image extensions
+ plainImageRegex := regexp.MustCompile(`(?i)https?://[^\s<>"']+\.(png|jpg|jpeg|svg|webp|gif)`)
+
+ // Try markdown format first
+ matches := markdownImageRegex.FindStringSubmatch(readmeContent)
+ if len(matches) > 1 && matches[1] != "" {
+ url := strings.TrimSpace(matches[1])
+ // Prefer HuggingFace CDN URLs or absolute URLs
+ if strings.HasPrefix(strings.ToLower(url), "http") {
+ return url
+ }
+ }
+
+ // Try HTML img tags
+ matches = htmlImageRegex.FindStringSubmatch(readmeContent)
+ if len(matches) > 1 && matches[1] != "" {
+ url := strings.TrimSpace(matches[1])
+ if strings.HasPrefix(strings.ToLower(url), "http") {
+ return url
+ }
+ }
+
+ // Try plain URLs
+ matches = plainImageRegex.FindStringSubmatch(readmeContent)
+ if len(matches) > 0 {
+ url := strings.TrimSpace(matches[0])
+ if strings.HasPrefix(strings.ToLower(url), "http") {
+ return url
+ }
+ }
+
+ return ""
+}
+
+// getHuggingFaceAvatarURL attempts to get the HuggingFace avatar URL for a user
+func getHuggingFaceAvatarURL(author string) string {
+ if author == "" {
+ return ""
+ }
+
+ // Try to fetch user info from HuggingFace API
+ // HuggingFace API endpoint: https://huggingface.co/api/users/{username}
+ baseURL := "https://huggingface.co"
+ userURL := fmt.Sprintf("%s/api/users/%s", baseURL, author)
+
+ req, err := http.NewRequest("GET", userURL, nil)
+ if err != nil {
+ return ""
+ }
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return ""
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return ""
+ }
+
+ // Parse the response to get avatar URL
+ var userInfo map[string]interface{}
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return ""
+ }
+
+ if err := json.Unmarshal(body, &userInfo); err != nil {
+ return ""
+ }
+
+ // Try to extract avatar URL from response
+ if avatar, ok := userInfo["avatarUrl"].(string); ok && avatar != "" {
+ return avatar
+ }
+ if avatar, ok := userInfo["avatar"].(string); ok && avatar != "" {
+ return avatar
+ }
+
+ return ""
+}
+
+// extractModelIcon extracts icon URL from README or falls back to HuggingFace avatar
+func extractModelIcon(model ProcessedModel) string {
+ // First, try to extract icon from README
+ if icon := extractIconFromReadme(model.ReadmeContent); icon != "" {
+ return icon
+ }
+
+ // Fallback: Try to get HuggingFace user avatar
+ if model.Author != "" {
+ if avatar := getHuggingFaceAvatarURL(model.Author); avatar != "" {
+ return avatar
+ }
+ }
+
+ return ""
}
diff --git a/.github/gallery-agent/gallery.go b/.github/gallery-agent/gallery.go
index b2e3d1d0b..431c06c85 100644
--- a/.github/gallery-agent/gallery.go
+++ b/.github/gallery-agent/gallery.go
@@ -12,15 +12,36 @@ import (
)
func formatTextContent(text string) string {
+ return formatTextContentWithIndent(text, 4, 6)
+}
+
+// formatTextContentWithIndent formats text content with specified base and list item indentation
+func formatTextContentWithIndent(text string, baseIndent int, listItemIndent int) string {
var formattedLines []string
lines := strings.Split(text, "\n")
for _, line := range lines {
- if strings.TrimSpace(line) == "" {
+ trimmed := strings.TrimRight(line, " \t\r")
+ if trimmed == "" {
// Keep empty lines as empty (no indentation)
formattedLines = append(formattedLines, "")
} else {
- // Add indentation to non-empty lines
- formattedLines = append(formattedLines, " "+line)
+ // Preserve relative indentation from yaml.Marshal output
+ // Count existing leading spaces to preserve relative structure
+ leadingSpaces := len(trimmed) - len(strings.TrimLeft(trimmed, " \t"))
+ trimmedStripped := strings.TrimLeft(trimmed, " \t")
+
+ var totalIndent int
+ if strings.HasPrefix(trimmedStripped, "-") {
+ // List items: use listItemIndent (ignore existing leading spaces)
+ totalIndent = listItemIndent
+ } else {
+ // Regular lines: use baseIndent + preserve relative indentation
+ // This handles both top-level keys (leadingSpaces=0) and nested properties (leadingSpaces>0)
+ totalIndent = baseIndent + leadingSpaces
+ }
+
+ indentStr := strings.Repeat(" ", totalIndent)
+ formattedLines = append(formattedLines, indentStr+trimmedStripped)
}
}
formattedText := strings.Join(formattedLines, "\n")
@@ -30,7 +51,7 @@ func formatTextContent(text string) string {
}
// generateYAMLEntry generates a YAML entry for a model using the specified anchor
-func generateYAMLEntry(model ProcessedModel, familyAnchor string, quantization string) string {
+func generateYAMLEntry(model ProcessedModel, quantization string) string {
modelConfig, err := importers.DiscoverModelConfig("https://huggingface.co/"+model.ModelID, json.RawMessage(`{ "quantization": "`+quantization+`"}`))
if err != nil {
panic(err)
@@ -62,103 +83,81 @@ func generateYAMLEntry(model ProcessedModel, familyAnchor string, quantization s
filesYAML, _ := yaml.Marshal(modelConfig.Files)
- files := formatTextContent(string(filesYAML))
+ // Files section: list items need 4 spaces (not 6), since files: is at 2 spaces
+ files := formatTextContentWithIndent(string(filesYAML), 4, 4)
+
+ // Build metadata sections
+ var metadataSections []string
+
+ // Add license if present
+ if model.License != "" {
+ metadataSections = append(metadataSections, fmt.Sprintf(` license: "%s"`, model.License))
+ }
+
+ // Add tags if present
+ if len(model.Tags) > 0 {
+ tagsYAML, _ := yaml.Marshal(model.Tags)
+ tagsFormatted := formatTextContentWithIndent(string(tagsYAML), 4, 4)
+ tagsFormatted = strings.TrimRight(tagsFormatted, "\n")
+ metadataSections = append(metadataSections, fmt.Sprintf(" tags:\n%s", tagsFormatted))
+ }
+
+ // Add icon if present
+ if model.Icon != "" {
+ metadataSections = append(metadataSections, fmt.Sprintf(` icon: %s`, model.Icon))
+ }
+
+ // Build the metadata block
+ metadataBlock := ""
+ if len(metadataSections) > 0 {
+ metadataBlock = strings.Join(metadataSections, "\n") + "\n"
+ }
yamlTemplate := ""
- yamlTemplate = `- !!merge <<: *%s
- name: "%s"
+ yamlTemplate = `- name: "%s"
urls:
- https://huggingface.co/%s
description: |
-%s
+%s%s
overrides:
%s
files:
%s`
+ // Trim trailing newlines from formatted sections to prevent extra blank lines
+ formattedDescription = strings.TrimRight(formattedDescription, "\n")
+ configFile = strings.TrimRight(configFile, "\n")
+ files = strings.TrimRight(files, "\n")
+ // Add newline before metadata block if present
+ if metadataBlock != "" {
+ metadataBlock = "\n" + strings.TrimRight(metadataBlock, "\n")
+ }
return fmt.Sprintf(yamlTemplate,
- familyAnchor,
modelName,
model.ModelID,
formattedDescription,
+ metadataBlock,
configFile,
files,
)
}
-// extractModelFamilies extracts all YAML anchors from the gallery index.yaml file
-func extractModelFamilies() ([]ModelFamily, error) {
- // Read the index.yaml file
- indexPath := getGalleryIndexPath()
- content, err := os.ReadFile(indexPath)
- if err != nil {
- return nil, fmt.Errorf("failed to read %s: %w", indexPath, err)
- }
-
- lines := strings.Split(string(content), "\n")
- var families []ModelFamily
-
- for _, line := range lines {
- line = strings.TrimSpace(line)
- // Look for YAML anchors (lines starting with "- &")
- if strings.HasPrefix(line, "- &") {
- // Extract the anchor name (everything after "- &")
- anchor := strings.TrimPrefix(line, "- &")
- // Remove any trailing colon or other characters
- anchor = strings.Split(anchor, ":")[0]
- anchor = strings.Split(anchor, " ")[0]
-
- if anchor != "" {
- families = append(families, ModelFamily{
- Anchor: anchor,
- Name: anchor, // Use anchor as name for now
- })
- }
- }
- }
-
- return families, nil
-}
-
// generateYAMLForModels generates YAML entries for selected models and appends to index.yaml
func generateYAMLForModels(ctx context.Context, models []ProcessedModel, quantization string) error {
- // Extract available model families
- families, err := extractModelFamilies()
- if err != nil {
- return fmt.Errorf("failed to extract model families: %w", err)
- }
-
- fmt.Printf("Found %d model families: %v\n", len(families),
- func() []string {
- var names []string
- for _, f := range families {
- names = append(names, f.Anchor)
- }
- return names
- }())
// Generate YAML entries for each model
var yamlEntries []string
for _, model := range models {
- fmt.Printf("Selecting family for model: %s\n", model.ModelID)
-
- // Select appropriate family for this model
- familyAnchor, err := selectModelFamily(ctx, model, families)
- if err != nil {
- fmt.Printf("Error selecting family for %s: %v, using default\n", model.ModelID, err)
- familyAnchor = "llama3" // Default fallback
- }
-
- fmt.Printf("Selected family '%s' for model %s\n", familyAnchor, model.ModelID)
+ fmt.Printf("Generating YAML entry for model: %s\n", model.ModelID)
// Generate YAML entry
- yamlEntry := generateYAMLEntry(model, familyAnchor, quantization)
+ yamlEntry := generateYAMLEntry(model, quantization)
yamlEntries = append(yamlEntries, yamlEntry)
}
- // Append to index.yaml
+ // Prepend to index.yaml (write at the top)
if len(yamlEntries) > 0 {
indexPath := getGalleryIndexPath()
- fmt.Printf("Appending YAML entries to %s...\n", indexPath)
+ fmt.Printf("Prepending YAML entries to %s...\n", indexPath)
// Read current content
content, err := os.ReadFile(indexPath)
@@ -166,11 +165,26 @@ func generateYAMLForModels(ctx context.Context, models []ProcessedModel, quantiz
return fmt.Errorf("failed to read %s: %w", indexPath, err)
}
- // Append new entries
- // Remove trailing whitespace from existing content and join entries without extra newlines
- existingContent := strings.TrimRight(string(content), " \t\n\r")
+ existingContent := string(content)
yamlBlock := strings.Join(yamlEntries, "\n")
- newContent := existingContent + "\n" + yamlBlock + "\n"
+
+ // Check if file starts with "---"
+ var newContent string
+ if strings.HasPrefix(existingContent, "---\n") {
+ // File starts with "---", prepend new entries after it
+ restOfContent := strings.TrimPrefix(existingContent, "---\n")
+ // Ensure proper spacing: "---\n" + new entries + "\n" + rest of content
+ newContent = "---\n" + yamlBlock + "\n" + restOfContent
+ } else if strings.HasPrefix(existingContent, "---") {
+ // File starts with "---" but no newline after
+ restOfContent := strings.TrimPrefix(existingContent, "---")
+ newContent = "---\n" + yamlBlock + "\n" + strings.TrimPrefix(restOfContent, "\n")
+ } else {
+ // No "---" at start, prepend new entries at the very beginning
+ // Trim leading whitespace from existing content
+ existingContent = strings.TrimLeft(existingContent, " \t\n\r")
+ newContent = yamlBlock + "\n" + existingContent
+ }
// Write back to file
err = os.WriteFile(indexPath, []byte(newContent), 0644)
@@ -178,7 +192,7 @@ func generateYAMLForModels(ctx context.Context, models []ProcessedModel, quantiz
return fmt.Errorf("failed to write %s: %w", indexPath, err)
}
- fmt.Printf("Successfully added %d models to %s\n", len(yamlEntries), indexPath)
+ fmt.Printf("Successfully prepended %d models to %s\n", len(yamlEntries), indexPath)
}
return nil
diff --git a/.github/gallery-agent/main.go b/.github/gallery-agent/main.go
index 7d9a4a462..ebef996ec 100644
--- a/.github/gallery-agent/main.go
+++ b/.github/gallery-agent/main.go
@@ -34,6 +34,9 @@ type ProcessedModel struct {
ReadmeContentPreview string `json:"readme_content_preview,omitempty"`
QuantizationPreferences []string `json:"quantization_preferences"`
ProcessingError string `json:"processing_error,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ License string `json:"license,omitempty"`
+ Icon string `json:"icon,omitempty"`
}
// SearchResult represents the complete result of searching and processing models
@@ -315,6 +318,25 @@ func searchAndProcessModels(searchTerm string, limit int, quantization string) (
continue
}
fmt.Println("Real readme got", readmeContent)
+
+ // Extract metadata (tags, license) from README using LLM
+ fmt.Println("Extracting metadata for", model.ModelID, "waiting...")
+ tags, license, err := extractModelMetadata(context.Background(), processedModel)
+ if err == nil {
+ processedModel.Tags = tags
+ processedModel.License = license
+ outputBuilder.WriteString(fmt.Sprintf(" Tags: %v\n", tags))
+ outputBuilder.WriteString(fmt.Sprintf(" License: %s\n", license))
+ } else {
+ fmt.Printf(" Warning: Failed to extract metadata: %v\n", err)
+ }
+
+ // Extract icon from README or use HuggingFace avatar
+ icon := extractModelIcon(processedModel)
+ if icon != "" {
+ processedModel.Icon = icon
+ outputBuilder.WriteString(fmt.Sprintf(" Icon: %s\n", icon))
+ }
// Get README content
// readmeContent, err := client.GetReadmeContent(model.ModelID, details.ReadmeFile.Path)
// if err == nil {
diff --git a/.github/gallery-agent/testing.go b/.github/gallery-agent/testing.go
index c6155f46c..c7960a9f2 100644
--- a/.github/gallery-agent/testing.go
+++ b/.github/gallery-agent/testing.go
@@ -138,6 +138,25 @@ func (g *SyntheticDataGenerator) GenerateProcessedModel() ProcessedModel {
readmeContent := g.generateReadmeContent(modelName, author)
+ // Generate sample metadata
+ licenses := []string{"apache-2.0", "mit", "llama2", "gpl-3.0", "bsd", ""}
+ license := licenses[g.rand.Intn(len(licenses))]
+
+ sampleTags := []string{"llm", "gguf", "gpu", "cpu", "text-to-text", "chat", "instruction-tuned"}
+ numTags := g.rand.Intn(4) + 3 // 3-6 tags
+ tags := make([]string, numTags)
+ for i := 0; i < numTags; i++ {
+ tags[i] = sampleTags[g.rand.Intn(len(sampleTags))]
+ }
+ // Remove duplicates
+ tags = g.removeDuplicates(tags)
+
+ // Optionally include icon (50% chance)
+ icon := ""
+ if g.rand.Intn(2) == 0 {
+ icon = fmt.Sprintf("https://cdn-avatars.huggingface.co/v1/production/uploads/%s.png", g.randomString(24))
+ }
+
return ProcessedModel{
ModelID: modelID,
Author: author,
@@ -150,6 +169,9 @@ func (g *SyntheticDataGenerator) GenerateProcessedModel() ProcessedModel {
ReadmeContentPreview: truncateString(readmeContent, 200),
QuantizationPreferences: []string{"Q4_K_M", "Q4_K_S", "Q3_K_M", "Q2_K"},
ProcessingError: "",
+ Tags: tags,
+ License: license,
+ Icon: icon,
}
}
@@ -179,6 +201,18 @@ func (g *SyntheticDataGenerator) randomDate() string {
return pastDate.Format("2006-01-02T15:04:05.000Z")
}
+func (g *SyntheticDataGenerator) removeDuplicates(slice []string) []string {
+ keys := make(map[string]bool)
+ result := []string{}
+ for _, item := range slice {
+ if !keys[item] {
+ keys[item] = true
+ result = append(result, item)
+ }
+ }
+ return result
+}
+
func (g *SyntheticDataGenerator) generateReadmeContent(modelName, author string) string {
templates := []string{
fmt.Sprintf("# %s Model\n\nThis is a %s model developed by %s. It's designed for various natural language processing tasks including text generation, question answering, and conversation.\n\n## Features\n\n- High-quality text generation\n- Efficient inference\n- Multiple quantization options\n- Easy to use with LocalAI\n\n## Usage\n\nUse this model with LocalAI for various AI tasks.", strings.Title(modelName), modelName, author),