diff --git a/.github/gallery-agent/agent.go b/.github/gallery-agent/agent.go
deleted file mode 100644
index 1d3a8d99d..000000000
--- a/.github/gallery-agent/agent.go
+++ /dev/null
@@ -1,446 +0,0 @@
-package main
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "os"
- "regexp"
- "slices"
- "strings"
-
- "github.com/ghodss/yaml"
- hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
- "github.com/mudler/cogito"
- "github.com/mudler/cogito/clients"
- "github.com/mudler/cogito/structures"
- "github.com/sashabaranov/go-openai/jsonschema"
-)
-
-var (
- openAIModel = os.Getenv("OPENAI_MODEL")
- openAIKey = os.Getenv("OPENAI_KEY")
- openAIBaseURL = os.Getenv("OPENAI_BASE_URL")
- galleryIndexPath = os.Getenv("GALLERY_INDEX_PATH")
- //defaultclient
- llm = clients.NewOpenAILLM(openAIModel, openAIKey, openAIBaseURL)
-)
-
-// cleanTextContent removes trailing spaces, tabs, and normalizes line endings
-// to prevent YAML linting issues like trailing spaces and multiple empty lines
-func cleanTextContent(text string) string {
- lines := strings.Split(text, "\n")
- var cleanedLines []string
- var prevEmpty bool
- for _, line := range lines {
- // Remove all trailing whitespace (spaces, tabs, etc.)
- trimmed := strings.TrimRight(line, " \t\r")
- // Avoid multiple consecutive empty lines
- if trimmed == "" {
- if !prevEmpty {
- cleanedLines = append(cleanedLines, "")
- }
- prevEmpty = true
- } else {
- cleanedLines = append(cleanedLines, trimmed)
- prevEmpty = false
- }
- }
- // Remove trailing empty lines from the result
- result := strings.Join(cleanedLines, "\n")
- return stripThinkingTags(strings.TrimRight(result, "\n"))
-}
-
-type galleryModel struct {
- Name string `yaml:"name"`
- Urls []string `yaml:"urls"`
-}
-
-// isModelExisting checks if a specific model ID exists in the gallery using text search
-func isModelExisting(modelID string) (bool, error) {
- indexPath := getGalleryIndexPath()
- content, err := os.ReadFile(indexPath)
- if err != nil {
- return false, fmt.Errorf("failed to read %s: %w", indexPath, err)
- }
-
- var galleryModels []galleryModel
-
- err = yaml.Unmarshal(content, &galleryModels)
- if err != nil {
- return false, fmt.Errorf("failed to unmarshal %s: %w", indexPath, err)
- }
-
- for _, galleryModel := range galleryModels {
- if slices.Contains(galleryModel.Urls, modelID) {
- return true, nil
- }
- }
-
- return false, nil
-}
-
-// filterExistingModels removes models that already exist in the gallery
-func filterExistingModels(models []ProcessedModel) ([]ProcessedModel, error) {
- var filteredModels []ProcessedModel
- for _, model := range models {
- exists, err := isModelExisting(model.ModelID)
- if err != nil {
- fmt.Printf("Error checking if model %s exists: %v, skipping\n", model.ModelID, err)
- continue
- }
-
- if !exists {
- filteredModels = append(filteredModels, model)
- } else {
- fmt.Printf("Skipping existing model: %s\n", model.ModelID)
- }
- }
-
- fmt.Printf("Filtered out %d existing models, %d new models remaining\n",
- len(models)-len(filteredModels), len(filteredModels))
-
- return filteredModels, nil
-}
-
-// getGalleryIndexPath returns the gallery index file path, with a default fallback
-func getGalleryIndexPath() string {
- if galleryIndexPath != "" {
- return galleryIndexPath
- }
- return "gallery/index.yaml"
-}
-
-func stripThinkingTags(content string) string {
- // Remove content between and (including multi-line)
- content = regexp.MustCompile(`(?s).*?`).ReplaceAllString(content, "")
- // Remove content between and (including multi-line)
- content = regexp.MustCompile(`(?s).*?`).ReplaceAllString(content, "")
- // Clean up any extra whitespace
- content = strings.TrimSpace(content)
- return content
-}
-
-func getRealReadme(ctx context.Context, repository string) (string, error) {
- // Create a conversation fragment
- fragment := cogito.NewEmptyFragment().
- AddMessage("user",
- `Your task is to get a clear description of a large language model from huggingface by using the provided tool. I will share with you a repository that might be quantized, and as such probably not by the original model author. We need to get the real description of the model, and not the one that might be quantized. You will have to call the tool to get the readme more than once by figuring out from the quantized readme which is the base model readme. This is the repository: `+repository)
-
- // Execute with tools
- result, err := cogito.ExecuteTools(llm, fragment,
- cogito.WithIterations(3),
- cogito.WithMaxAttempts(3),
- cogito.DisableSinkState,
- cogito.WithTools(&HFReadmeTool{client: hfapi.NewClient()}))
- if err != nil {
- return "", err
- }
-
- result = result.AddMessage("user", "Describe the model in a clear and concise way that can be shared in a model gallery.")
-
- // Get a response
- _, err = llm.Ask(ctx, result)
- if err != nil {
- return "", err
- }
-
- content := result.LastMessage().Content
- return cleanTextContent(content), nil
-}
-
-func selectMostInterestingModels(ctx context.Context, searchResult *SearchResult) ([]ProcessedModel, error) {
-
- if len(searchResult.Models) == 1 {
- return searchResult.Models, nil
- }
-
- // Create a conversation fragment
- fragment := cogito.NewEmptyFragment().
- AddMessage("user",
- `Your task is to analyze a list of AI models and select the most interesting ones for a model gallery. You will be given detailed information about multiple models including their metadata, file information, and README content.
-
-Consider the following criteria when selecting models:
-1. Model popularity (download count)
-2. Model recency (last modified date)
-3. Model completeness (has preferred model file, README, etc.)
-4. Model uniqueness (not duplicates or very similar models)
-5. Model quality (based on README content and description)
-6. Model utility (practical applications)
-
-You should select models that would be most valuable for users browsing a model gallery. Prioritize models that are:
-- Well-documented with clear READMEs
-- Recently updated
-- Popular (high download count)
-- Have the preferred quantization format available
-- Offer unique capabilities or are from reputable authors
-
-Return your analysis and selection reasoning.`)
-
- // Add the search results as context
- modelsInfo := fmt.Sprintf("Found %d models matching '%s' with quantization preference '%s':\n\n",
- searchResult.TotalModelsFound, searchResult.SearchTerm, searchResult.Quantization)
-
- for i, model := range searchResult.Models {
- modelsInfo += fmt.Sprintf("Model %d:\n", i+1)
- modelsInfo += fmt.Sprintf(" ID: %s\n", model.ModelID)
- modelsInfo += fmt.Sprintf(" Author: %s\n", model.Author)
- modelsInfo += fmt.Sprintf(" Downloads: %d\n", model.Downloads)
- modelsInfo += fmt.Sprintf(" Last Modified: %s\n", model.LastModified)
- modelsInfo += fmt.Sprintf(" Files: %d files\n", len(model.Files))
-
- if model.PreferredModelFile != nil {
- modelsInfo += fmt.Sprintf(" Preferred Model File: %s (%d bytes)\n",
- model.PreferredModelFile.Path, model.PreferredModelFile.Size)
- } else {
- modelsInfo += " No preferred model file found\n"
- }
-
- if model.ReadmeContent != "" {
- modelsInfo += fmt.Sprintf(" README: %s\n", model.ReadmeContent)
- }
-
- if model.ProcessingError != "" {
- modelsInfo += fmt.Sprintf(" Processing Error: %s\n", model.ProcessingError)
- }
-
- modelsInfo += "\n"
- }
-
- fragment = fragment.AddMessage("user", modelsInfo)
-
- fragment = fragment.AddMessage("user", "Based on your analysis, select the top 5 most interesting models and provide a brief explanation for each selection. Also, create a filtered SearchResult with only the selected models. Return just a list of repositories IDs, you will later be asked to output it as a JSON array with the json tool.")
-
- // Get a response
- newFragment, err := llm.Ask(ctx, fragment)
- if err != nil {
- return nil, err
- }
-
- fmt.Println(newFragment.LastMessage().Content)
- repositories := struct {
- Repositories []string `json:"repositories"`
- }{}
-
- s := structures.Structure{
- Schema: jsonschema.Definition{
- Type: jsonschema.Object,
- AdditionalProperties: false,
- Properties: map[string]jsonschema.Definition{
- "repositories": {
- Type: jsonschema.Array,
- Items: &jsonschema.Definition{Type: jsonschema.String},
- Description: "The trending repositories IDs",
- },
- },
- Required: []string{"repositories"},
- },
- Object: &repositories,
- }
-
- err = newFragment.ExtractStructure(ctx, llm, s)
- if err != nil {
- return nil, err
- }
-
- filteredModels := []ProcessedModel{}
- for _, m := range searchResult.Models {
- if slices.Contains(repositories.Repositories, m.ModelID) {
- filteredModels = append(filteredModels, m)
- }
- }
-
- return filteredModels, nil
-}
-
-// ModelMetadata represents extracted metadata from a model
-type ModelMetadata struct {
- Tags []string `json:"tags"`
- License string `json:"license"`
-}
-
-// 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 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 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.
-
-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)
- 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", 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 nil, "", err
- }
-
- // Extract structured metadata
- metadata := ModelMetadata{}
-
- 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,
- }
-
- err = newFragment.ExtractStructure(ctx, llm, s)
- if err != nil {
- return nil, "", err
- }
-
- 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]any
- 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/helpers.go b/.github/gallery-agent/helpers.go
new file mode 100644
index 000000000..e90dc6ba2
--- /dev/null
+++ b/.github/gallery-agent/helpers.go
@@ -0,0 +1,231 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "regexp"
+ "strings"
+
+ "github.com/ghodss/yaml"
+ hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
+)
+
+var galleryIndexPath = os.Getenv("GALLERY_INDEX_PATH")
+
+// getGalleryIndexPath returns the gallery index file path, with a default fallback
+func getGalleryIndexPath() string {
+ if galleryIndexPath != "" {
+ return galleryIndexPath
+ }
+ return "gallery/index.yaml"
+}
+
+type galleryModel struct {
+ Name string `yaml:"name"`
+ Urls []string `yaml:"urls"`
+}
+
+// loadGalleryURLSet parses gallery/index.yaml once and returns the set of
+// HuggingFace model URLs already present in the gallery.
+func loadGalleryURLSet() (map[string]struct{}, error) {
+ indexPath := getGalleryIndexPath()
+ content, err := os.ReadFile(indexPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read %s: %w", indexPath, err)
+ }
+
+ var galleryModels []galleryModel
+ if err := yaml.Unmarshal(content, &galleryModels); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal %s: %w", indexPath, err)
+ }
+
+ set := make(map[string]struct{}, len(galleryModels))
+ for _, gm := range galleryModels {
+ for _, u := range gm.Urls {
+ set[u] = struct{}{}
+ }
+ }
+
+ // Also skip URLs already proposed in open (unmerged) gallery-agent PRs.
+ // The workflow injects these via EXTRA_SKIP_URLS so we don't keep
+ // re-proposing the same model every run while a PR is waiting to merge.
+ for _, line := range strings.FieldsFunc(os.Getenv("EXTRA_SKIP_URLS"), func(r rune) bool {
+ return r == '\n' || r == ',' || r == ' '
+ }) {
+ u := strings.TrimSpace(line)
+ if u != "" {
+ set[u] = struct{}{}
+ }
+ }
+
+ return set, nil
+}
+
+// modelAlreadyInGallery checks whether a HuggingFace model repo is already
+// referenced in the gallery URL set.
+func modelAlreadyInGallery(set map[string]struct{}, modelID string) bool {
+ _, ok := set["https://huggingface.co/"+modelID]
+ return ok
+}
+
+// baseModelFromTags returns the first `base_model:` value found in the
+// tag list, or "" if none is present. HuggingFace surfaces the base model
+// declared in the model card's YAML frontmatter as such a tag.
+func baseModelFromTags(tags []string) string {
+ for _, t := range tags {
+ if strings.HasPrefix(t, "base_model:") {
+ return strings.TrimPrefix(t, "base_model:")
+ }
+ }
+ return ""
+}
+
+// licenseFromTags returns the `license:` value from the tag list, or "".
+func licenseFromTags(tags []string) string {
+ for _, t := range tags {
+ if strings.HasPrefix(t, "license:") {
+ return strings.TrimPrefix(t, "license:")
+ }
+ }
+ return ""
+}
+
+// curatedTags produces the gallery tag list from HuggingFace's raw tag set.
+// Always includes llm + gguf, then adds whitelisted family / capability
+// markers when they appear in the HF tag list.
+func curatedTags(hfTags []string) []string {
+ whitelist := []string{
+ "gpu", "cpu",
+ "llama", "mistral", "mixtral", "qwen", "qwen2", "qwen3",
+ "gemma", "gemma2", "gemma3", "phi", "phi3", "phi4",
+ "deepseek", "yi", "falcon", "command-r",
+ "vision", "multimodal", "code", "chat",
+ "instruction-tuned", "reasoning", "thinking",
+ }
+ seen := map[string]struct{}{}
+ out := []string{"llm", "gguf"}
+ seen["llm"] = struct{}{}
+ seen["gguf"] = struct{}{}
+
+ hfSet := map[string]struct{}{}
+ for _, t := range hfTags {
+ hfSet[strings.ToLower(t)] = struct{}{}
+ }
+ for _, w := range whitelist {
+ if _, ok := hfSet[w]; ok {
+ if _, dup := seen[w]; !dup {
+ out = append(out, w)
+ seen[w] = struct{}{}
+ }
+ }
+ }
+ return out
+}
+
+// resolveReadme fetches a description-quality README for a (possibly
+// quantized) repo: if a `base_model:` tag is present, fetch the base repo's
+// README; otherwise fall back to the repo's own README.
+func resolveReadme(client *hfapi.Client, modelID string, hfTags []string) (string, error) {
+ if base := baseModelFromTags(hfTags); base != "" && base != modelID {
+ if content, err := client.GetReadmeContent(base, "README.md"); err == nil && strings.TrimSpace(content) != "" {
+ return cleanTextContent(content), nil
+ }
+ }
+ content, err := client.GetReadmeContent(modelID, "README.md")
+ if err != nil {
+ return "", err
+ }
+ return cleanTextContent(content), nil
+}
+
+// cleanTextContent removes trailing spaces/tabs and collapses multiple empty
+// lines so README content embeds cleanly into YAML without lint noise.
+func cleanTextContent(text string) string {
+ lines := strings.Split(text, "\n")
+ var cleaned []string
+ var prevEmpty bool
+ for _, line := range lines {
+ trimmed := strings.TrimRight(line, " \t\r")
+ if trimmed == "" {
+ if !prevEmpty {
+ cleaned = append(cleaned, "")
+ }
+ prevEmpty = true
+ } else {
+ cleaned = append(cleaned, trimmed)
+ prevEmpty = false
+ }
+ }
+ return strings.TrimRight(strings.Join(cleaned, "\n"), "\n")
+}
+
+// extractIconFromReadme scans README content for an image URL usable as a
+// gallery entry icon.
+func extractIconFromReadme(readmeContent string) string {
+ if readmeContent == "" {
+ return ""
+ }
+
+ markdownImageRegex := regexp.MustCompile(`(?i)!\[[^\]]*\]\(([^)]+\.(png|jpg|jpeg|svg|webp|gif))\)`)
+ htmlImageRegex := regexp.MustCompile(`(?i)
]+src=["']([^"']+\.(png|jpg|jpeg|svg|webp|gif))["']`)
+ plainImageRegex := regexp.MustCompile(`(?i)https?://[^\s<>"']+\.(png|jpg|jpeg|svg|webp|gif)`)
+
+ if m := markdownImageRegex.FindStringSubmatch(readmeContent); len(m) > 1 && strings.HasPrefix(strings.ToLower(m[1]), "http") {
+ return strings.TrimSpace(m[1])
+ }
+ if m := htmlImageRegex.FindStringSubmatch(readmeContent); len(m) > 1 && strings.HasPrefix(strings.ToLower(m[1]), "http") {
+ return strings.TrimSpace(m[1])
+ }
+ if m := plainImageRegex.FindStringSubmatch(readmeContent); len(m) > 0 && strings.HasPrefix(strings.ToLower(m[0]), "http") {
+ return strings.TrimSpace(m[0])
+ }
+ return ""
+}
+
+// getHuggingFaceAvatarURL returns the HF avatar URL for a user, or "".
+func getHuggingFaceAvatarURL(author string) string {
+ if author == "" {
+ return ""
+ }
+ userURL := fmt.Sprintf("https://huggingface.co/api/users/%s/overview", author)
+ resp, err := http.Get(userURL)
+ if err != nil {
+ return ""
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return ""
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return ""
+ }
+ var info map[string]any
+ if err := json.Unmarshal(body, &info); err != nil {
+ return ""
+ }
+ if v, ok := info["avatarUrl"].(string); ok && v != "" {
+ return v
+ }
+ if v, ok := info["avatar"].(string); ok && v != "" {
+ return v
+ }
+ return ""
+}
+
+// extractModelIcon extracts an icon URL from the README, falling back to the
+// HuggingFace user avatar.
+func extractModelIcon(model ProcessedModel) string {
+ if icon := extractIconFromReadme(model.ReadmeContent); icon != "" {
+ return icon
+ }
+ if model.Author != "" {
+ if avatar := getHuggingFaceAvatarURL(model.Author); avatar != "" {
+ return avatar
+ }
+ }
+ return ""
+}
diff --git a/.github/gallery-agent/main.go b/.github/gallery-agent/main.go
index 1aa58a0ee..5201573b3 100644
--- a/.github/gallery-agent/main.go
+++ b/.github/gallery-agent/main.go
@@ -6,7 +6,6 @@ import (
"fmt"
"os"
"strconv"
- "strings"
"time"
hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
@@ -39,16 +38,6 @@ type ProcessedModel struct {
Icon string `json:"icon,omitempty"`
}
-// SearchResult represents the complete result of searching and processing models
-type SearchResult struct {
- SearchTerm string `json:"search_term"`
- Limit int `json:"limit"`
- Quantization string `json:"quantization"`
- TotalModelsFound int `json:"total_models_found"`
- Models []ProcessedModel `json:"models"`
- FormattedOutput string `json:"formatted_output"`
-}
-
// AddedModelSummary represents a summary of models added to the gallery
type AddedModelSummary struct {
SearchTerm string `json:"search_term"`
@@ -63,19 +52,16 @@ type AddedModelSummary struct {
func main() {
startTime := time.Now()
- // Check for synthetic mode
- syntheticMode := os.Getenv("SYNTHETIC_MODE")
- if syntheticMode == "true" || syntheticMode == "1" {
+ // Synthetic mode for local testing
+ if sm := os.Getenv("SYNTHETIC_MODE"); sm == "true" || sm == "1" {
fmt.Println("Running in SYNTHETIC MODE - generating random test data")
- err := runSyntheticMode()
- if err != nil {
+ if err := runSyntheticMode(); err != nil {
fmt.Fprintf(os.Stderr, "Error in synthetic mode: %v\n", err)
os.Exit(1)
}
return
}
- // Get configuration from environment variables
searchTerm := os.Getenv("SEARCH_TERM")
if searchTerm == "" {
searchTerm = "GGUF"
@@ -83,7 +69,7 @@ func main() {
limitStr := os.Getenv("LIMIT")
if limitStr == "" {
- limitStr = "5"
+ limitStr = "15"
}
limit, err := strconv.Atoi(limitStr)
if err != nil {
@@ -92,287 +78,191 @@ func main() {
}
quantization := os.Getenv("QUANTIZATION")
-
- maxModels := os.Getenv("MAX_MODELS")
- if maxModels == "" {
- maxModels = "1"
+ if quantization == "" {
+ quantization = "Q4_K_M"
}
- maxModelsInt, err := strconv.Atoi(maxModels)
+
+ maxModelsStr := os.Getenv("MAX_MODELS")
+ if maxModelsStr == "" {
+ maxModelsStr = "1"
+ }
+ maxModels, err := strconv.Atoi(maxModelsStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing MAX_MODELS: %v\n", err)
os.Exit(1)
}
- // Print configuration
fmt.Printf("Gallery Agent Configuration:\n")
fmt.Printf(" Search Term: %s\n", searchTerm)
fmt.Printf(" Limit: %d\n", limit)
fmt.Printf(" Quantization: %s\n", quantization)
- fmt.Printf(" Max Models to Add: %d\n", maxModelsInt)
- fmt.Printf(" Gallery Index Path: %s\n", os.Getenv("GALLERY_INDEX_PATH"))
+ fmt.Printf(" Max Models to Add: %d\n", maxModels)
+ fmt.Printf(" Gallery Index Path: %s\n", getGalleryIndexPath())
fmt.Println()
- result, err := searchAndProcessModels(searchTerm, limit, quantization)
+ // Phase 1: load current gallery and query HuggingFace.
+ gallerySet, err := loadGalleryURLSet()
if err != nil {
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ fmt.Fprintf(os.Stderr, "Error loading gallery index: %v\n", err)
os.Exit(1)
}
+ fmt.Printf("Loaded %d existing gallery entries\n", len(gallerySet))
- fmt.Println(result.FormattedOutput)
- var models []ProcessedModel
-
- if len(result.Models) > 1 {
- fmt.Println("More than one model found (", len(result.Models), "), using AI agent to select the most interesting models")
- for _, model := range result.Models {
- fmt.Println("Model: ", model.ModelID)
- }
- // Use AI agent to select the most interesting models
- fmt.Println("Using AI agent to select the most interesting models...")
- models, err = selectMostInterestingModels(context.Background(), result)
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error in model selection: %v\n", err)
- // Continue with original result if selection fails
- models = result.Models
- }
- } else if len(result.Models) == 1 {
- models = result.Models
- fmt.Println("Only one model found, using it directly")
- }
-
- fmt.Print(models)
-
- // Filter out models that already exist in the gallery
- fmt.Println("Filtering out existing models...")
- models, err = filterExistingModels(models)
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error filtering existing models: %v\n", err)
- os.Exit(1)
- }
-
- // Limit to maxModelsInt after filtering
- if len(models) > maxModelsInt {
- models = models[:maxModelsInt]
- }
-
- // Track added models for summary
- var addedModelIDs []string
- var addedModelURLs []string
-
- // Generate YAML entries and append to gallery/index.yaml
- if len(models) > 0 {
- for _, model := range models {
- addedModelIDs = append(addedModelIDs, model.ModelID)
- // Generate Hugging Face URL for the model
- modelURL := fmt.Sprintf("https://huggingface.co/%s", model.ModelID)
- addedModelURLs = append(addedModelURLs, modelURL)
- }
- fmt.Println("Generating YAML entries for selected models...")
- err = generateYAMLForModels(context.Background(), models, quantization)
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error generating YAML entries: %v\n", err)
- os.Exit(1)
- }
- } else {
- fmt.Println("No new models to add to the gallery.")
- }
-
- // Create and write summary
- processingTime := time.Since(startTime).String()
- summary := AddedModelSummary{
- SearchTerm: searchTerm,
- TotalFound: result.TotalModelsFound,
- ModelsAdded: len(addedModelIDs),
- AddedModelIDs: addedModelIDs,
- AddedModelURLs: addedModelURLs,
- Quantization: quantization,
- ProcessingTime: processingTime,
- }
-
- // Write summary to file
- summaryData, err := json.MarshalIndent(summary, "", " ")
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error marshaling summary: %v\n", err)
- } else {
- err = os.WriteFile("gallery-agent-summary.json", summaryData, 0644)
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error writing summary file: %v\n", err)
- } else {
- fmt.Printf("Summary written to gallery-agent-summary.json\n")
- }
- }
-}
-
-func searchAndProcessModels(searchTerm string, limit int, quantization string) (*SearchResult, error) {
client := hfapi.NewClient()
- var outputBuilder strings.Builder
- fmt.Println("Searching for models...")
- // Initialize the result struct
- result := &SearchResult{
- SearchTerm: searchTerm,
- Limit: limit,
- Quantization: quantization,
- Models: []ProcessedModel{},
- }
-
- models, err := client.GetLatest(searchTerm, limit)
+ fmt.Println("Searching for trending models on HuggingFace...")
+ rawModels, err := client.GetTrending(searchTerm, limit)
if err != nil {
- return nil, fmt.Errorf("failed to fetch models: %w", err)
+ fmt.Fprintf(os.Stderr, "Error fetching models: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Printf("Found %d trending models matching %q\n", len(rawModels), searchTerm)
+ totalFound := len(rawModels)
+
+ // Phase 2: drop anything already in the gallery *before* any expensive
+ // per-model work (GetModelDetails, README fetches, icon lookups).
+ fresh := rawModels[:0]
+ for _, m := range rawModels {
+ if modelAlreadyInGallery(gallerySet, m.ModelID) {
+ fmt.Printf("Skipping existing model: %s\n", m.ModelID)
+ continue
+ }
+ fresh = append(fresh, m)
+ }
+ fmt.Printf("%d candidates after gallery dedup\n", len(fresh))
+
+ // Phase 3: HuggingFace already returned these in trendingScore order —
+ // just cap to MAX_MODELS.
+ if len(fresh) > maxModels {
+ fresh = fresh[:maxModels]
+ }
+ if len(fresh) == 0 {
+ fmt.Println("No new models to add to the gallery.")
+ writeSummary(AddedModelSummary{
+ SearchTerm: searchTerm,
+ TotalFound: totalFound,
+ ModelsAdded: 0,
+ Quantization: quantization,
+ ProcessingTime: time.Since(startTime).String(),
+ })
+ return
}
- fmt.Println("Models found:", len(models))
- result.TotalModelsFound = len(models)
+ // Phase 4: fetch details and build ProcessedModel entries for survivors.
+ var processed []ProcessedModel
+ quantPrefs := []string{quantization, "Q4_K_M", "Q4_K_S", "Q3_K_M", "Q2_K", "Q8_0"}
+ for _, m := range fresh {
+ fmt.Printf("Processing model: %s (downloads=%d)\n", m.ModelID, m.Downloads)
- if len(models) == 0 {
- outputBuilder.WriteString("No models found.\n")
- result.FormattedOutput = outputBuilder.String()
- return result, nil
- }
-
- outputBuilder.WriteString(fmt.Sprintf("Found %d models matching '%s':\n\n", len(models), searchTerm))
-
- // Process each model
- for i, model := range models {
- outputBuilder.WriteString(fmt.Sprintf("%d. Processing Model: %s\n", i+1, model.ModelID))
- outputBuilder.WriteString(fmt.Sprintf(" Author: %s\n", model.Author))
- outputBuilder.WriteString(fmt.Sprintf(" Downloads: %d\n", model.Downloads))
- outputBuilder.WriteString(fmt.Sprintf(" Last Modified: %s\n", model.LastModified))
-
- // Initialize processed model struct
- processedModel := ProcessedModel{
- ModelID: model.ModelID,
- Author: model.Author,
- Downloads: model.Downloads,
- LastModified: model.LastModified,
- QuantizationPreferences: []string{quantization, "Q4_K_M", "Q4_K_S", "Q3_K_M", "Q2_K"},
+ pm := ProcessedModel{
+ ModelID: m.ModelID,
+ Author: m.Author,
+ Downloads: m.Downloads,
+ LastModified: m.LastModified,
+ QuantizationPreferences: quantPrefs,
}
- // Get detailed model information
- details, err := client.GetModelDetails(model.ModelID)
+ details, err := client.GetModelDetails(m.ModelID)
if err != nil {
- errorMsg := fmt.Sprintf(" Error getting model details: %v\n", err)
- outputBuilder.WriteString(errorMsg)
- processedModel.ProcessingError = err.Error()
- result.Models = append(result.Models, processedModel)
+ fmt.Printf(" Error getting model details: %v (skipping)\n", err)
continue
}
- // Define quantization preferences (in order of preference)
- quantizationPreferences := []string{quantization, "Q4_K_M", "Q4_K_S", "Q3_K_M", "Q2_K"}
+ preferred := hfapi.FindPreferredModelFile(details.Files, quantPrefs)
+ if preferred == nil {
+ fmt.Printf(" No GGUF file matching %v — skipping\n", quantPrefs)
+ continue
+ }
- // Find preferred model file
- preferredModelFile := hfapi.FindPreferredModelFile(details.Files, quantizationPreferences)
-
- // Process files
- processedFiles := make([]ProcessedModelFile, len(details.Files))
- for j, file := range details.Files {
+ pm.Files = make([]ProcessedModelFile, len(details.Files))
+ for j, f := range details.Files {
fileType := "other"
- if file.IsReadme {
+ if f.IsReadme {
fileType = "readme"
- } else if preferredModelFile != nil && file.Path == preferredModelFile.Path {
+ } else if f.Path == preferred.Path {
fileType = "model"
}
-
- processedFiles[j] = ProcessedModelFile{
- Path: file.Path,
- Size: file.Size,
- SHA256: file.SHA256,
- IsReadme: file.IsReadme,
+ pm.Files[j] = ProcessedModelFile{
+ Path: f.Path,
+ Size: f.Size,
+ SHA256: f.SHA256,
+ IsReadme: f.IsReadme,
FileType: fileType,
}
- }
-
- processedModel.Files = processedFiles
-
- // Set preferred model file
- if preferredModelFile != nil {
- for _, file := range processedFiles {
- if file.Path == preferredModelFile.Path {
- processedModel.PreferredModelFile = &file
- break
- }
+ if f.Path == preferred.Path {
+ copyFile := pm.Files[j]
+ pm.PreferredModelFile = ©File
+ }
+ if f.IsReadme {
+ copyFile := pm.Files[j]
+ pm.ReadmeFile = ©File
}
}
- // Print file information
- outputBuilder.WriteString(fmt.Sprintf(" Files found: %d\n", len(details.Files)))
-
- if preferredModelFile != nil {
- outputBuilder.WriteString(fmt.Sprintf(" Preferred Model File: %s (SHA256: %s)\n",
- preferredModelFile.Path,
- preferredModelFile.SHA256))
+ // Deterministic README resolution: follow base_model tag if set.
+ readme, err := resolveReadme(client, m.ModelID, m.Tags)
+ if err == nil {
+ pm.ReadmeContent = readme
+ pm.ReadmeContentPreview = truncateString(readme, 200)
} else {
- outputBuilder.WriteString(fmt.Sprintf(" No model file found with quantization preferences: %v\n", quantizationPreferences))
+ fmt.Printf(" Warning: failed to fetch README: %v\n", err)
}
- if details.ReadmeFile != nil {
- outputBuilder.WriteString(fmt.Sprintf(" README File: %s\n", details.ReadmeFile.Path))
+ pm.License = licenseFromTags(m.Tags)
+ pm.Tags = curatedTags(m.Tags)
+ pm.Icon = extractModelIcon(pm)
- // Find and set readme file
- for _, file := range processedFiles {
- if file.IsReadme {
- processedModel.ReadmeFile = &file
- break
- }
- }
-
- fmt.Println("Getting real readme for", model.ModelID, "waiting...")
- // Use agent to get the real readme and prepare the model description
- readmeContent, err := getRealReadme(context.Background(), model.ModelID)
- if err == nil {
- processedModel.ReadmeContent = readmeContent
- processedModel.ReadmeContentPreview = truncateString(readmeContent, 200)
- outputBuilder.WriteString(fmt.Sprintf(" README Content Preview: %s\n",
- processedModel.ReadmeContentPreview))
- } else {
- fmt.Printf(" Warning: Failed to get real readme: %v\n", err)
- }
- 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 {
- // processedModel.ReadmeContent = readmeContent
- // processedModel.ReadmeContentPreview = truncateString(readmeContent, 200)
- // outputBuilder.WriteString(fmt.Sprintf(" README Content Preview: %s\n",
- // processedModel.ReadmeContentPreview))
- // }
- }
-
- // Print all files with their checksums
- outputBuilder.WriteString(" All Files:\n")
- for _, file := range processedFiles {
- outputBuilder.WriteString(fmt.Sprintf(" - %s (%s, %d bytes", file.Path, file.FileType, file.Size))
- if file.SHA256 != "" {
- outputBuilder.WriteString(fmt.Sprintf(", SHA256: %s", file.SHA256))
- }
- outputBuilder.WriteString(")\n")
- }
-
- outputBuilder.WriteString("\n")
- result.Models = append(result.Models, processedModel)
+ fmt.Printf(" License: %s, Tags: %v, Icon: %s\n", pm.License, pm.Tags, pm.Icon)
+ processed = append(processed, pm)
}
- result.FormattedOutput = outputBuilder.String()
- return result, nil
+ if len(processed) == 0 {
+ fmt.Println("No processable models after detail fetch.")
+ writeSummary(AddedModelSummary{
+ SearchTerm: searchTerm,
+ TotalFound: totalFound,
+ ModelsAdded: 0,
+ Quantization: quantization,
+ ProcessingTime: time.Since(startTime).String(),
+ })
+ return
+ }
+
+ // Phase 5: write YAML entries.
+ var addedIDs, addedURLs []string
+ for _, pm := range processed {
+ addedIDs = append(addedIDs, pm.ModelID)
+ addedURLs = append(addedURLs, "https://huggingface.co/"+pm.ModelID)
+ }
+
+ fmt.Println("Generating YAML entries for selected models...")
+ if err := generateYAMLForModels(context.Background(), processed, quantization); err != nil {
+ fmt.Fprintf(os.Stderr, "Error generating YAML entries: %v\n", err)
+ os.Exit(1)
+ }
+
+ writeSummary(AddedModelSummary{
+ SearchTerm: searchTerm,
+ TotalFound: totalFound,
+ ModelsAdded: len(addedIDs),
+ AddedModelIDs: addedIDs,
+ AddedModelURLs: addedURLs,
+ Quantization: quantization,
+ ProcessingTime: time.Since(startTime).String(),
+ })
+}
+
+func writeSummary(summary AddedModelSummary) {
+ data, err := json.MarshalIndent(summary, "", " ")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error marshaling summary: %v\n", err)
+ return
+ }
+ if err := os.WriteFile("gallery-agent-summary.json", data, 0644); err != nil {
+ fmt.Fprintf(os.Stderr, "Error writing summary file: %v\n", err)
+ return
+ }
+ fmt.Println("Summary written to gallery-agent-summary.json")
}
func truncateString(s string, maxLen int) string {
@@ -381,3 +271,4 @@ func truncateString(s string, maxLen int) string {
}
return s[:maxLen] + "..."
}
+
diff --git a/.github/gallery-agent/tools.go b/.github/gallery-agent/tools.go
deleted file mode 100644
index 10d0f57fb..000000000
--- a/.github/gallery-agent/tools.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package main
-
-import (
- "fmt"
-
- hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
- openai "github.com/sashabaranov/go-openai"
- jsonschema "github.com/sashabaranov/go-openai/jsonschema"
-)
-
-// Get repository README from HF
-type HFReadmeTool struct {
- client *hfapi.Client
-}
-
-func (s *HFReadmeTool) Execute(args map[string]any) (string, any, error) {
- q, ok := args["repository"].(string)
- if !ok {
- return "", nil, fmt.Errorf("no query")
- }
- readme, err := s.client.GetReadmeContent(q, "README.md")
- if err != nil {
- return "", nil, err
- }
- return readme, nil, nil
-}
-
-func (s *HFReadmeTool) Tool() openai.Tool {
- return openai.Tool{
- Type: openai.ToolTypeFunction,
- Function: &openai.FunctionDefinition{
- Name: "hf_readme",
- Description: "A tool to get the README content of a huggingface repository",
- Parameters: jsonschema.Definition{
- Type: jsonschema.Object,
- Properties: map[string]jsonschema.Definition{
- "repository": {
- Type: jsonschema.String,
- Description: "The huggingface repository to get the README content of",
- },
- },
- Required: []string{"repository"},
- },
- },
- }
-}
diff --git a/.github/workflows/gallery-agent.yaml b/.github/workflows/gallery-agent.yaml
index 3cb24c09a..afeafcb24 100644
--- a/.github/workflows/gallery-agent.yaml
+++ b/.github/workflows/gallery-agent.yaml
@@ -48,21 +48,37 @@ jobs:
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
PATH="$PATH:$HOME/go/bin" make protogen-go
- - uses: mudler/localai-github-action@v1.1
- with:
- model: 'https://huggingface.co/unsloth/Qwen3.5-2B-GGUF'
+ - name: Collect URLs from open gallery-agent PRs
+ id: open_prs
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ # Gather model URLs already proposed in any open gallery-agent PR so
+ # the agent can skip them and not re-propose the same model while a
+ # previous PR is still waiting to be merged.
+ urls=$(gh pr list \
+ --repo ${{ github.repository }} \
+ --state open \
+ --search 'chore(model gallery) in:title :robot:' \
+ --json body \
+ --jq '[.[].body] | join("\n")' \
+ | grep -oE 'https://huggingface\.co/[^ )]+' \
+ | sort -u || true)
+ echo "Found URLs from open PRs:"
+ echo "$urls"
+ {
+ echo "urls<> "$GITHUB_OUTPUT"
- name: Run gallery agent
env:
- #OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
- OPENAI_MODEL: Qwen3.5-2B-GGUF
- OPENAI_BASE_URL: "http://localhost:8080"
- OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
- #OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
SEARCH_TERM: ${{ github.event.inputs.search_term || 'GGUF' }}
LIMIT: ${{ github.event.inputs.limit || '15' }}
QUANTIZATION: ${{ github.event.inputs.quantization || 'Q4_K_M' }}
MAX_MODELS: ${{ github.event.inputs.max_models || '1' }}
+ EXTRA_SKIP_URLS: ${{ steps.open_prs.outputs.urls }}
run: |
export GALLERY_INDEX_PATH=$PWD/gallery/index.yaml
go run ./.github/gallery-agent
diff --git a/models/.keep b/models/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/pkg/huggingface-api/client.go b/pkg/huggingface-api/client.go
index 09551c332..d4dfea18a 100644
--- a/pkg/huggingface-api/client.go
+++ b/pkg/huggingface-api/client.go
@@ -138,6 +138,22 @@ func (c *Client) GetLatest(searchTerm string, limit int) ([]Model, error) {
return c.SearchModels(params)
}
+// GetTrending fetches models sorted by HuggingFace's trendingScore — the
+// same signal the public "Trending" tab uses. Useful when picking fresh
+// candidates to add to a gallery: it biases toward repos that are gaining
+// attention right now, rather than strictly newest or strictly most
+// downloaded overall.
+func (c *Client) GetTrending(searchTerm string, limit int) ([]Model, error) {
+ params := SearchParams{
+ Sort: "trendingScore",
+ Direction: -1,
+ Limit: limit,
+ Search: searchTerm,
+ }
+
+ return c.SearchModels(params)
+}
+
// BaseURL returns the current base URL
func (c *Client) BaseURL() string {
return c.baseURL