Models hosting v1
Seto Elkahfi committed
Jun 17, 2026 at 10:52 UTC
7734fcb6fed6ce5a0c3a9a4166baa0620190ae4c
25 files changed
+1383
-45
.gitignore
+5
index 0fdde5a..da4a3f4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,3 +40,8 @@
# Ignore local git repos for development environment
.local-repos/*
!.local-repos/.keep
+
+# Ignore the bare git repositories the app hosts on disk (runtime data:
+# user repos, seeded models, and their LFS-backed weights). Keep the folder.
+/repos/*
+!/repos/.keep
app/controllers/models_controller.rb
+67
new file mode 100644
index 0000000..338ca3b
--- /dev/null
+++ b/app/controllers/models_controller.rb
@@ -0,0 +1,67 @@
+# Hugging Face-style discovery page for open-weights model repositories.
+class ModelsController < ApplicationController
+ def index
+ @query = params[:q].to_s.strip
+ @pipeline_tag = params[:pipeline_tag].presence
+ @library = params[:library].presence
+ @sort = params.fetch(:sort, "trending")
+
+ repos = Repository.models
+ .visible_to(current_user)
+ .includes(:user)
+ .to_a
+
+ # Decorate each repo with its parsed model card once, then filter/sort in
+ # Ruby — the card metadata lives in git, not the database.
+ cards = repos.map { |repo| [ repo, repo.model_card ] }
+
+ if @query.present?
+ q = @query.downcase
+ cards.select! do |repo, card|
+ repo.full_name.downcase.include?(q) ||
+ repo.description.to_s.downcase.include?(q) ||
+ card.tags.any? { |t| t.downcase.include?(q) }
+ end
+ end
+
+ @facets = build_facets(cards)
+
+ cards.select! { |_repo, card| card.pipeline_tag == @pipeline_tag } if @pipeline_tag
+ cards.select! { |_repo, card| card.library_name == @library } if @library
+
+ @models = sort_cards(cards)
+ end
+
+ private
+
+ def build_facets(cards)
+ {
+ pipeline_tags: tally(cards) { |_repo, card| card.pipeline_tag },
+ libraries: tally(cards) { |_repo, card| card.library_name }
+ }
+ end
+
+ def tally(cards)
+ cards.filter_map { |pair| yield(*pair) }
+ .tally
+ .sort_by { |label, count| [ -count, label ] }
+ end
+
+ def sort_cards(cards)
+ case @sort
+ when "downloads"
+ cards.sort_by { |repo, _| -repo.downloads_count }
+ when "likes"
+ cards.sort_by { |repo, _| -repo.stars_count }
+ when "recent"
+ cards.sort_by { |repo, _| repo.updated_at }.reverse
+ else # "trending" — a blend of likes, downloads and recency
+ cards.sort_by { |repo, _| -trending_score(repo) }
+ end
+ end
+
+ def trending_score(repo)
+ age_days = [ (Time.current - repo.updated_at) / 1.day, 0.5 ].max
+ ((repo.stars_count * 3) + Math.log10(repo.downloads_count + 1) * 10) / age_days
+ end
+end
app/controllers/repositories_controller.rb
+75
-5
index 3f31efa..973b2c6 100644
--- a/app/controllers/repositories_controller.rb
+++ b/app/controllers/repositories_controller.rb
@@ -4,8 +4,27 @@ class RepositoriesController < ApplicationController
before_action :load_repository, only: [ :show, :tree, :cicd ]
before_action :ensure_can_read!, only: [ :show, :tree, :cicd ]
+ # Discovery page for public code repositories — the code-side counterpart to
+ # the model library at /models.
+ def explore
+ @query = params[:q].to_s.strip
+ @sort = params.fetch(:sort, "recent")
+
+ repos = Repository.code.visible_to(current_user).includes(:user)
+ if @query.present?
+ like = "%#{@query.downcase}%"
+ repos = repos.where("LOWER(repositories.name) LIKE :q OR LOWER(repositories.description) LIKE :q", q: like)
+ end
+
+ @repositories = case @sort
+ when "stars" then repos.order(stars_count: :desc, updated_at: :desc)
+ when "name" then repos.order(Arel.sql("LOWER(repositories.name) ASC"))
+ else repos.order(updated_at: :desc)
+ end.to_a
+ end
+
def new
- @repository = Repository.new
+ @repository = Repository.new(kind: params[:kind] == "model" ? "model" : "code")
end
def create
@@ -18,10 +37,11 @@ class RepositoriesController < ApplicationController
GitRepositoryService.create_bare_repo(current_user.username, @repository.name)
GitRepositoryService.initialize_with_readme(
current_user.username, @repository.name,
- default_branch: @repository.default_branch
+ default_branch: @repository.default_branch,
+ readme: initial_readme(@repository)
)
redirect_to repository_path(current_user.username, @repository.name),
- notice: "Repository created successfully."
+ notice: "#{@repository.model? ? 'Model' : 'Repository'} created successfully."
else
render :new, status: :unprocessable_entity
end
@@ -38,7 +58,11 @@ class RepositoriesController < ApplicationController
unless @repository.initialized? && GitRepositoryService.branch_exists?(@repository.disk_path, branch)
@empty = true
- return
+ render(@repository.model? ? :model : :show) and return
+ end
+
+ if @repository.model?
+ return show_model(branch)
end
@tree = GitRepositoryService.list_tree(@repository.disk_path, branch)
@@ -106,8 +130,54 @@ class RepositoriesController < ApplicationController
private
+ # Renders the Hugging Face-style model page: parsed model card + a
+ # "Files and versions" listing with LFS-resolved sizes, all on one page with
+ # client-side tab switching.
+ def show_model(branch)
+ @card = @repository.model_card(branch: branch)
+ @card_html = render_markdown(@card.body) if @card.body.present?
+ @files = GitRepositoryService.model_files(@repository.disk_path, branch)
+ @total_size = @files.filter_map { |f| f[:size] }.sum
+ @commit_count = GitRepositoryService.commit_count(@repository.disk_path, branch)
+ @recent_commits = GitRepositoryService.commits(@repository.disk_path, branch, limit: 1)
+ render :model
+ end
+
def repository_params
- params.require(:repository).permit(:name, :description, :default_branch, :is_private)
+ permitted = params.require(:repository).permit(:name, :description, :default_branch, :is_private, :kind)
+ permitted[:kind] = "model" if permitted[:kind] == "model"
+ permitted[:kind] = "code" unless permitted[:kind] == "model"
+ permitted
+ end
+
+ # Seeds a fresh repo's README — a starter model card with YAML front-matter
+ # for models, a plain heading for code repos.
+ def initial_readme(repository)
+ return nil unless repository.model?
+
+ <<~MARKDOWN
+ ---
+ license: apache-2.0
+ pipeline_tag: text-generation
+ library_name: transformers
+ tags:
+ - #{repository.name}
+ ---
+
+ # #{repository.name}
+
+ Describe your model here. Document its architecture, training data,
+ intended use, limitations, and how to load the weights.
+
+ Push your weight files (`*.safetensors`, `*.gguf`, `*.bin`) with Git LFS:
+
+ ```bash
+ git lfs install
+ git lfs track "*.safetensors" "*.gguf"
+ git add .gitattributes
+ git push origin #{repository.default_branch}
+ ```
+ MARKDOWN
end
def load_owner
app/controllers/stars_controller.rb
+28
new file mode 100644
index 0000000..2976772
--- /dev/null
+++ b/app/controllers/stars_controller.rb
@@ -0,0 +1,28 @@
+# Toggles a star (a "like" for models, a star for code repos) for the signed-in
+# user on a repository. One endpoint flips the state in both directions.
+class StarsController < ApplicationController
+ before_action :require_sign_in!
+ before_action :load_repository
+
+ def create
+ star = current_user.stars.find_or_initialize_by(repository: @repository)
+ if star.persisted?
+ star.destroy
+ else
+ star.save
+ end
+ redirect_back fallback_location: repository_path(@repository.user.username, @repository.name)
+ end
+
+ private
+
+ def load_repository
+ owner = User.find_by!(username: params[:username])
+ @repository = owner.repositories.find_by!(name: params[:repository])
+ unless !@repository.is_private || current_user == owner
+ render file: Rails.public_path.join("404.html"), status: :not_found, layout: false
+ end
+ rescue ActiveRecord::RecordNotFound
+ render file: Rails.public_path.join("404.html"), status: :not_found, layout: false
+ end
+end
app/controllers/users_controller.rb
+6
-1
index d24e0f8..24c2959 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -11,7 +11,12 @@ class UsersController < ApplicationController
@profile_user.repositories.order(updated_at: :desc)
else
@profile_user.repositories.where(is_private: false).order(updated_at: :desc)
- end
+ end.to_a
+
+ # The profile mirrors the site nav: code repositories and models are shown
+ # on separate tabs, so split the owner's repos by kind.
+ @code_repositories = @repositories.select(&:code?)
+ @model_repositories = @repositories.select(&:model?)
if @profile_user.username == DEMO_FEED_USERNAME
# Curated showcase feed for the demo profile.
app/helpers/models_helper.rb
+53
new file mode 100644
index 0000000..73fd291
--- /dev/null
+++ b/app/helpers/models_helper.rb
@@ -0,0 +1,53 @@
+module ModelsHelper
+ # A small curated map of Hugging Face pipeline tags to human labels. Unknown
+ # tags fall back to a titleized version of the raw value.
+ PIPELINE_LABELS = {
+ "text-generation" => "Text Generation",
+ "text2text-generation" => "Text-to-Text",
+ "text-classification" => "Text Classification",
+ "token-classification" => "Token Classification",
+ "question-answering" => "Question Answering",
+ "summarization" => "Summarization",
+ "translation" => "Translation",
+ "feature-extraction" => "Feature Extraction",
+ "sentence-similarity" => "Sentence Similarity",
+ "fill-mask" => "Fill-Mask",
+ "image-classification" => "Image Classification",
+ "image-to-text" => "Image-to-Text",
+ "text-to-image" => "Text-to-Image",
+ "automatic-speech-recognition" => "Speech Recognition",
+ "text-to-speech" => "Text-to-Speech",
+ "audio-classification" => "Audio Classification"
+ }.freeze
+
+ def pipeline_label(tag)
+ return nil if tag.blank?
+ PIPELINE_LABELS[tag] || tag.tr("-_", " ").split.map(&:capitalize).join(" ")
+ end
+
+ # Compact human download count, e.g. 1240 → "1.2k", 3_400_000 → "3.4M".
+ def humanize_count(n)
+ n = n.to_i
+ return n.to_s if n < 1000
+ number_to_human(n, units: { thousand: "k", million: "M", billion: "B" },
+ format: "%n%u", precision: 2, strip_insignificant_zeros: true)
+ end
+
+ # Maps a weight-file extension to a short format badge label.
+ WEIGHT_FORMATS = {
+ "safetensors" => "Safetensors",
+ "gguf" => "GGUF",
+ "bin" => "PyTorch",
+ "pt" => "PyTorch",
+ "pth" => "PyTorch",
+ "onnx" => "ONNX",
+ "ckpt" => "Checkpoint",
+ "h5" => "Keras",
+ "msgpack" => "Flax",
+ "tflite" => "TFLite"
+ }.freeze
+
+ def weight_format(filename)
+ WEIGHT_FORMATS[File.extname(filename).delete_prefix(".").downcase]
+ end
+end
app/models/repository.rb
+35
index 8fc44b9..00e941e 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -1,5 +1,15 @@
class Repository < ApplicationRecord
+ KINDS = %w[code model].freeze
+
belongs_to :user
+ has_many :stars, dependent: :destroy
+ has_many :stargazers, through: :stars, source: :user
+
+ scope :models, -> { where(kind: "model") }
+ scope :code, -> { where(kind: "code") }
+ scope :visible_to, ->(user) {
+ user ? where("is_private = ? OR user_id = ?", false, user.id) : where(is_private: false)
+ }
validates :name, presence: true,
format: {
@@ -8,6 +18,7 @@ class Repository < ApplicationRecord
},
uniqueness: { scope: :user_id, case_sensitive: false }
validates :default_branch, presence: true
+ validates :kind, inclusion: { in: KINDS }
validate :no_consecutive_dots_in_name
validate :no_leading_hyphen_in_name
@@ -29,6 +40,30 @@ class Repository < ApplicationRecord
Dir.exist?(git_path) && File.exist?(File.join(git_path, "HEAD"))
end
+ def model?
+ kind == "model"
+ end
+
+ def code?
+ kind != "model"
+ end
+
+ def starred_by?(user)
+ return false unless user
+ stars.exists?(user_id: user.id)
+ end
+
+ # Parses the Hugging Face-style YAML front-matter from the README (the "model
+ # card") on the default branch. Memoized per instance; returns a ModelCard
+ # even when there is no front-matter so callers can use it unconditionally.
+ def model_card(branch: nil)
+ @model_card ||= begin
+ branch ||= default_branch
+ readme = (GitRepositoryService.readme_content(disk_path, branch) if initialized?)
+ ModelCard.parse(readme&.dig(:content))
+ end
+ end
+
private
def no_consecutive_dots_in_name
app/models/star.rb
+6
new file mode 100644
index 0000000..1c14f3f
--- /dev/null
+++ b/app/models/star.rb
@@ -0,0 +1,6 @@
+class Star < ApplicationRecord
+ belongs_to :user
+ belongs_to :repository, counter_cache: :stars_count
+
+ validates :user_id, uniqueness: { scope: :repository_id }
+end
app/models/user.rb
+2
index 16d6bbc..a8c2fd1 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -3,6 +3,8 @@
class User < ApplicationRecord
has_many :repositories, dependent: :destroy
has_many :ssh_keys, dependent: :destroy
+ has_many :stars, dependent: :destroy
+ has_many :starred_repositories, through: :stars, source: :repository
validates :smbcloud_id,
presence: true,
app/services/git_repository_service.rb
+38
-7
index 41e046b..edb4479 100644
--- a/app/services/git_repository_service.rb
+++ b/app/services/git_repository_service.rb
@@ -16,14 +16,14 @@ class GitRepositoryService
path
end
- def self.initialize_with_readme(username, reponame, default_branch: "main")
+ def self.initialize_with_readme(username, reponame, default_branch: "main", readme: nil)
bare_path = repo_path(username, reponame)
Dir.mktmpdir do |tmpdir|
system("git", "-C", tmpdir, "init", "-b", default_branch, exception: true)
system("git", "-C", tmpdir, "config", "user.email", "sigitsi@localhost", exception: true)
system("git", "-C", tmpdir, "config", "user.name", "siGit", exception: true)
- readme = File.join(tmpdir, "README.md")
- File.write(readme, "# #{reponame}\n\nWelcome to #{reponame}.\n")
+ readme_path = File.join(tmpdir, "README.md")
+ File.write(readme_path, readme || "# #{reponame}\n\nWelcome to #{reponame}.\n")
system("git", "-C", tmpdir, "add", ".", exception: true)
system("git", "-C", tmpdir, "commit", "-m", "Initial commit", exception: true)
system("git", "-C", tmpdir, "remote", "add", "origin", bare_path, exception: true)
@@ -43,13 +43,44 @@ class GitRepositoryService
out, _err, status = Open3.capture3(*args)
return [] unless status.success?
out.lines.filter_map do |line|
- parts = line.split
- next unless parts.length >= 5
- mode, type, _sha, _size, name = parts[0], parts[1], parts[2], parts[3], parts[4..]&.join(" ")&.strip
- { mode: mode, type: type, name: name, path: prefix + name }
+ # `ls-tree --long` columns: <mode> <type> <sha> <size> <tab> <name>
+ meta, name = line.split("\t", 2)
+ next if name.nil?
+ mode, type, _sha, size = meta.split
+ { mode: mode, type: type, name: name.strip,
+ path: prefix + name.strip,
+ size: (size == "-" ? nil : size.to_i) }
end.sort_by { |e| [e[:type] == "tree" ? 0 : 1, e[:name].downcase] }
end
+ # Git LFS / Xet pointer files are tiny text stubs that stand in for large
+ # binary weights stored out-of-band. Recognise them so the UI can show the
+ # real on-disk size and an "LFS" badge instead of the ~130-byte pointer.
+ LFS_POINTER_RE = %r{\Aversion https://(?:git-lfs\.github\.com|hf\.co)/spec/v1}
+ LFS_SIZE_RE = /^size (\d+)$/
+ POINTER_MAX = 1024
+
+ # Root-level file listing for a model repository's "Files and versions" tab,
+ # with LFS pointer sizes resolved. Folders are included but not recursed.
+ def self.model_files(path, branch)
+ list_tree(path, branch).map do |entry|
+ next entry unless entry[:type] == "blob"
+ resolve_lfs(path, branch, entry)
+ end
+ end
+
+ # If +entry+ is a small blob that turns out to be an LFS pointer, replace its
+ # size with the pointer's declared size and flag it. Otherwise return as-is.
+ def self.resolve_lfs(path, branch, entry)
+ return entry if entry[:size].nil? || entry[:size] > POINTER_MAX
+
+ content = file_content(path, branch, entry[:path])
+ return entry unless content&.match?(LFS_POINTER_RE)
+
+ size = content[LFS_SIZE_RE, 1]&.to_i
+ entry.merge(lfs: true, size: size || entry[:size])
+ end
+
def self.file_content(path, branch, file_path)
out, _err, status = Open3.capture3("git", "--git-dir", path, "show", "#{branch}:#{file_path}")
return nil unless status.success?
app/services/model_card.rb
+86
new file mode 100644
index 0000000..f47ee73
--- /dev/null
+++ b/app/services/model_card.rb
@@ -0,0 +1,86 @@
+require "yaml"
+
+# Parses a Hugging Face-style "model card": a Markdown README whose leading
+# section is a YAML front-matter block delimited by `---` lines, e.g.
+#
+# ---
+# license: apache-2.0
+# base_model: Qwen/Qwen2.5-3B-Instruct
+# pipeline_tag: text-generation
+# library_name: gguf
+# tags:
+# - text-generation
+# - gguf
+# ---
+# # Qwen2.5-3B-Instruct-GGUF
+# ...markdown body...
+#
+# The metadata drives the model page sidebar and the discovery-page facets; the
+# body is the rendered card content with the front-matter stripped out.
+class ModelCard
+ FRONT_MATTER = /\A---\s*\n(.*?\n)---\s*\n?(.*)\z/m
+
+ attr_reader :metadata, :body
+
+ def self.parse(content)
+ new(content)
+ end
+
+ def initialize(content)
+ content = content.to_s
+ if (m = content.match(FRONT_MATTER))
+ @metadata = safe_yaml(m[1])
+ @body = m[2].to_s
+ else
+ @metadata = {}
+ @body = content
+ end
+ end
+
+ def license
+ value("license")
+ end
+
+ def pipeline_tag
+ value("pipeline_tag")
+ end
+
+ def library_name
+ value("library_name") || value("library")
+ end
+
+ def base_model
+ Array(metadata["base_model"]).flatten.compact.first.presence
+ end
+
+ def language
+ Array(metadata["language"]).flatten.compact.map(&:to_s)
+ end
+
+ def datasets
+ Array(metadata["datasets"]).flatten.compact.map(&:to_s)
+ end
+
+ def tags
+ Array(metadata["tags"]).flatten.compact.map(&:to_s).uniq
+ end
+
+ # True when there is any structured metadata worth showing in the sidebar.
+ def present?
+ metadata.present?
+ end
+
+ private
+
+ def value(key)
+ v = metadata[key]
+ v.is_a?(Array) ? v.first : v
+ end
+
+ def safe_yaml(raw)
+ parsed = YAML.safe_load(raw, permitted_classes: [ Date, Time ], aliases: false)
+ parsed.is_a?(Hash) ? parsed : {}
+ rescue Psych::SyntaxError
+ {}
+ end
+end
app/views/models/index.html.erb
+144
new file mode 100644
index 0000000..6411433
--- /dev/null
+++ b/app/views/models/index.html.erb
@@ -0,0 +1,144 @@
+<% content_for :title, "Models" %>
+
+<div class="max-w-6xl mx-auto px-4 sm:px-6 py-8">
+
+ <div class="flex items-end justify-between gap-4 mb-6 flex-wrap">
+ <div>
+ <h1 class="text-2xl font-semibold text-gray-100 flex items-center gap-2">
+ <svg class="w-6 h-6 text-brand-500" viewBox="0 0 24 24" fill="none"
+ stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
+ <path d="m3.27 6.96 8.73 5.05 8.73-5.05M12 22.08V12"/>
+ </svg>
+ Models
+ <span class="badge-gray text-sm align-middle"><%= number_with_delimiter(@models.size) %></span>
+ </h1>
+ <p class="text-sm text-gray-400 mt-1">Open-weights models hosted on siGit, ready to download and run.</p>
+ </div>
+ <% if signed_in? %>
+ <%= link_to new_repository_path(kind: "model"), class: "btn-primary text-xs py-2 px-4" do %>
+ <svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>
+ New model
+ <% end %>
+ <% end %>
+ </div>
+
+ <div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
+
+ <%# ── Filter sidebar ── %>
+ <aside class="lg:col-span-1 space-y-6">
+ <% if @facets[:pipeline_tags].any? %>
+ <div>
+ <h2 class="text-xs uppercase tracking-wider text-gray-500 font-semibold mb-2">Tasks</h2>
+ <ul class="space-y-0.5">
+ <% @facets[:pipeline_tags].each do |tag, count| %>
+ <li>
+ <%= link_to models_path(request.query_parameters.merge(pipeline_tag: (@pipeline_tag == tag ? nil : tag)).compact),
+ class: "flex items-center justify-between px-2 py-1 rounded text-sm #{@pipeline_tag == tag ? 'bg-brand-500/10 text-brand-400' : 'text-gray-400 hover:bg-surface-700'}" do %>
+ <span class="truncate"><%= pipeline_label(tag) %></span>
+ <span class="text-xs text-gray-500"><%= count %></span>
+ <% end %>
+ </li>
+ <% end %>
+ </ul>
+ </div>
+ <% end %>
+
+ <% if @facets[:libraries].any? %>
+ <div>
+ <h2 class="text-xs uppercase tracking-wider text-gray-500 font-semibold mb-2">Libraries</h2>
+ <ul class="space-y-0.5">
+ <% @facets[:libraries].each do |lib, count| %>
+ <li>
+ <%= link_to models_path(request.query_parameters.merge(library: (@library == lib ? nil : lib)).compact),
+ class: "flex items-center justify-between px-2 py-1 rounded text-sm #{@library == lib ? 'bg-brand-500/10 text-brand-400' : 'text-gray-400 hover:bg-surface-700'}" do %>
+ <span class="truncate"><%= lib %></span>
+ <span class="text-xs text-gray-500"><%= count %></span>
+ <% end %>
+ </li>
+ <% end %>
+ </ul>
+ </div>
+ <% end %>
+
+ <% if @pipeline_tag || @library || @query.present? %>
+ <%= link_to "Clear filters", models_path, class: "text-xs text-brand-500 hover:underline" %>
+ <% end %>
+ </aside>
+
+ <%# ── Results ── %>
+ <main class="lg:col-span-3">
+ <div class="flex items-center gap-3 mb-4">
+ <%= form_with url: models_path, method: :get, class: "flex-1" do %>
+ <% if @pipeline_tag %><%= hidden_field_tag :pipeline_tag, @pipeline_tag %><% end %>
+ <% if @library %><%= hidden_field_tag :library, @library %><% end %>
+ <%= hidden_field_tag :sort, @sort %>
+ <div class="relative">
+ <svg class="w-4 h-4 text-gray-500 absolute left-3 top-1/2 -translate-y-1/2" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-1.06 1.06ZM11.5 7a4.5 4.5 0 1 0-9 0 4.5 4.5 0 0 0 9 0Z"/></svg>
+ <%= text_field_tag :q, @query, placeholder: "Search models by name or tag…",
+ class: "form-input pl-9", autocomplete: "off" %>
+ </div>
+ <% end %>
+ <%= form_with url: models_path, method: :get, class: "shrink-0", id: "sort-form" do %>
+ <% if @query.present? %><%= hidden_field_tag :q, @query %><% end %>
+ <% if @pipeline_tag %><%= hidden_field_tag :pipeline_tag, @pipeline_tag %><% end %>
+ <% if @library %><%= hidden_field_tag :library, @library %><% end %>
+ <%= select_tag :sort,
+ options_for_select([["Trending", "trending"], ["Most likes", "likes"], ["Most downloads", "downloads"], ["Recently updated", "recent"]], @sort),
+ class: "form-input py-2 pr-8 text-sm", onchange: "this.form.submit()" %>
+ <% end %>
+ </div>
+
+ <% if @models.any? %>
+ <div class="card divide-y divide-surface-600">
+ <% @models.each do |repo, card| %>
+ <%= link_to repository_path(repo.user.username, repo.name),
+ class: "block px-4 py-3 hover:bg-surface-600 transition-colors" do %>
+ <div class="flex items-center justify-between gap-3">
+ <div class="min-w-0">
+ <div class="flex items-center gap-2">
+ <svg class="w-4 h-4 text-brand-500 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
+ <path d="m3.27 6.96 8.73 5.05 8.73-5.05M12 22.08V12"/>
+ </svg>
+ <span class="font-medium text-gray-100 truncate"><%= repo.full_name %></span>
+ <% if repo.is_private %><span class="badge-gray">Private</span><% end %>
+ </div>
+ <div class="flex items-center gap-2 mt-1 text-xs text-gray-500 flex-wrap">
+ <% if (label = pipeline_label(card.pipeline_tag)) %>
+ <span class="text-brand-400"><%= label %></span>
+ <span>·</span>
+ <% end %>
+ <% if card.library_name.present? %>
+ <span><%= card.library_name %></span>
+ <span>·</span>
+ <% end %>
+ <span>Updated <%= time_ago_in_words(repo.updated_at) %> ago</span>
+ </div>
+ </div>
+ <div class="flex items-center gap-4 text-xs text-gray-500 shrink-0">
+ <span class="flex items-center gap-1" title="Downloads">
+ <svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor"><path d="M7.47 10.78a.75.75 0 0 0 1.06 0l3.75-3.75a.75.75 0 0 0-1.06-1.06L8.75 8.44V1.75a.75.75 0 0 0-1.5 0v6.69L4.78 5.97a.75.75 0 0 0-1.06 1.06ZM3.75 13a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5Z"/></svg>
+ <%= humanize_count(repo.downloads_count) %>
+ </span>
+ <span class="flex items-center gap-1" title="Likes">
+ <svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"/></svg>
+ <%= humanize_count(repo.stars_count) %>
+ </span>
+ </div>
+ </div>
+ <% end %>
+ <% end %>
+ </div>
+ <% else %>
+ <div class="card px-6 py-16 text-center">
+ <p class="text-sm text-gray-400 mb-1">No models found.</p>
+ <p class="text-xs text-gray-500">Try clearing filters or
+ <%= link_to "publish the first one", new_repository_path(kind: "model"), class: "text-brand-500 hover:underline" %>.
+ </p>
+ </div>
+ <% end %>
+ </main>
+
+ </div>
+</div>
app/views/pages/home.html.erb
+59
-12
index 768d9a9..094e4ab 100644
--- a/app/views/pages/home.html.erb
+++ b/app/views/pages/home.html.erb
@@ -5,18 +5,24 @@
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-20 sm:py-28">
<div class="max-w-3xl">
- <h1 class="max-w-2xl text-4xl font-semibold tracking-tight text-gray-100 sm:text-6xl">
- Git hosting that stays out of the way.
+
+ <h1 class="mt-6 max-w-2xl text-4xl font-semibold tracking-tight text-gray-100 sm:text-6xl">
+ Code and models, hosted side by side.
</h1>
<p class="mt-6 max-w-xl text-base leading-7 text-gray-400 sm:text-lg">
- siGit gives you repositories, history, diffs, and a calm place to read code.
- Nothing loud. Nothing extra.
+ siGit is git hosting built for the AI era. Version your repositories, publish
+ open-weights models with full model cards and LFS-backed files, and hand the keys
+ to your AI agent over the Agent Client Protocol.
</p>
<div class="mt-10 flex flex-col gap-3 sm:flex-row">
- <%= link_to "Sign in", signin_path, class: "btn-primary px-6 py-3 text-sm justify-center" %>
- <a href="#features" class="btn-secondary px-6 py-3 text-sm justify-center">See what it does</a>
+ <%= link_to "Explore models", models_path, class: "btn-primary px-6 py-3 text-sm justify-center" %>
+ <% if signed_in? %>
+ <%= link_to "Browse repositories", repos_path, class: "btn-secondary px-6 py-3 text-sm justify-center" %>
+ <% else %>
+ <%= link_to "Sign in", signin_path, class: "btn-secondary px-6 py-3 text-sm justify-center" %>
+ <% end %>
</div>
<p class="mt-10 text-sm text-gray-500">or check what <a href="/sigit" class="text-gray-400 underline underline-offset-2 hover:text-gray-200 transition-colors">@sigit</a> is doing</p>
@@ -36,15 +42,54 @@
</div>
</section>
+ <%# ── Two pillars: code + models ── %>
+ <section class="max-w-6xl mx-auto px-4 sm:px-6 pt-16 sm:pt-20">
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
+ <%= link_to repos_path, class: "card p-8 hover:border-surface-400 transition-colors group" do %>
+ <div class="flex items-center gap-3 mb-4">
+ <span class="flex h-10 w-10 items-center justify-center rounded-sm border border-surface-500 bg-surface-800 text-gray-300">
+ <svg class="w-5 h-5" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8Z"/></svg>
+ </span>
+ <h2 class="text-lg font-semibold text-gray-100">Code repositories</h2>
+ </div>
+ <p class="text-sm leading-6 text-gray-400">
+ Fast diffs, deep file browsing, instant repos. Public or private, just git push.
+ </p>
+ <span class="mt-4 inline-flex items-center gap-1 text-sm text-brand-500 group-hover:gap-2 transition-all">
+ Browse repositories
+ <svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor"><path d="M8.22 2.97a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L10.94 8.75H2.75a.75.75 0 0 1 0-1.5h8.19L8.22 4.03a.75.75 0 0 1 0-1.06Z"/></svg>
+ </span>
+ <% end %>
+
+ <%= link_to models_path, class: "card p-8 hover:border-surface-400 transition-colors group" do %>
+ <div class="flex items-center gap-3 mb-4">
+ <span class="flex h-10 w-10 items-center justify-center rounded-sm border border-surface-500 bg-surface-800 text-brand-500">
+ <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.27 6.96 8.73 5.05 8.73-5.05M12 22.08V12"/></svg>
+ </span>
+ <h2 class="text-lg font-semibold text-gray-100">Model library</h2>
+ </div>
+ <p class="text-sm leading-6 text-gray-400">
+ A home for open weights. Model cards, quantized GGUF and safetensors, downloads and likes.
+ </p>
+ <span class="mt-4 inline-flex items-center gap-1 text-sm text-brand-500 group-hover:gap-2 transition-all">
+ Explore models
+ <svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor"><path d="M8.22 2.97a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L10.94 8.75H2.75a.75.75 0 0 1 0-1.5h8.19L8.22 4.03a.75.75 0 0 1 0-1.06Z"/></svg>
+ </span>
+ <% end %>
+ </div>
+ </section>
+
+ <%# ── Why siGit ── %>
<section id="features" class="max-w-6xl mx-auto px-4 sm:px-6 py-16 sm:py-20">
<div class="grid grid-cols-1 gap-px overflow-hidden rounded-sm border border-surface-500 bg-surface-500 sm:grid-cols-3">
<div class="bg-surface-800 p-8 sm:p-10">
<div class="mb-6 flex h-10 w-10 items-center justify-center rounded-sm border border-surface-500 bg-surface-700 text-brand-500">
<span class="text-lg font-semibold">01</span>
</div>
- <h2 class="text-lg font-semibold text-gray-100">Code Residency</h2>
+ <h2 class="text-lg font-semibold text-gray-100">One platform for both</h2>
<p class="mt-3 text-sm leading-6 text-gray-400">
- Instant repo creation. Public or private. No friction, just git push.
+ Source code and open-weights models share the same git storage and the same calm UI.
+ No second tool to learn, no weights scattered across services.
</p>
</div>
@@ -52,9 +97,10 @@
<div class="mb-6 flex h-10 w-10 items-center justify-center rounded-sm border border-surface-500 bg-surface-700 text-brand-500">
<span class="text-lg font-semibold">02</span>
</div>
- <h2 class="text-lg font-semibold text-gray-100">Native Inspection</h2>
+ <h2 class="text-lg font-semibold text-gray-100">Built for big files</h2>
<p class="mt-3 text-sm leading-6 text-gray-400">
- A UI that gets out of the way. Blazing fast diffs and deep file browsing.
+ Model cards parsed straight from your README, Git LFS for multi-gigabyte weights, and
+ file sizes, formats, and quants surfaced at a glance.
</p>
</div>
@@ -62,9 +108,10 @@
<div class="mb-6 flex h-10 w-10 items-center justify-center rounded-sm border border-surface-500 bg-surface-700 text-brand-500">
<span class="text-lg font-semibold">03</span>
</div>
- <h2 class="text-lg font-semibold text-gray-100">The Dark Room</h2>
+ <h2 class="text-lg font-semibold text-gray-100">Agent-ready</h2>
<p class="mt-3 text-sm leading-6 text-gray-400">
- A low-latency, high-contrast workspace designed for the 2 AM flow state.
+ siGit speaks the Agent Client Protocol, so your AI coding agent can read, review, and
+ ship straight from the editor. Low latency, high contrast, made for the 2 AM flow state.
</p>
</div>
</div>
app/views/repositories/_model_use.html.erb
+34
new file mode 100644
index 0000000..a28b73c
--- /dev/null
+++ b/app/views/repositories/_model_use.html.erb
@@ -0,0 +1,34 @@
+<%#
+ Sidebar "Use this model" panel — clone/download snippets plus a
+ library-specific load example. Locals: repository, owner, card.
+%>
+<% full = "#{owner.username}/#{repository.name}" %>
+<% lib = card.library_name.to_s.downcase %>
+
+<div class="card p-4">
+ <h3 class="text-xs uppercase tracking-wider text-gray-500 font-semibold mb-3 flex items-center gap-1.5">
+ <svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor"><path d="M7.47 10.78a.75.75 0 0 0 1.06 0l3.75-3.75a.75.75 0 0 0-1.06-1.06L8.75 8.44V1.75a.75.75 0 0 0-1.5 0v6.69L4.78 5.97a.75.75 0 0 0-1.06 1.06ZM3.75 13a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5Z"/></svg>
+ Use this model
+ </h3>
+
+ <div class="code-container p-3 space-y-0.5 mb-3">
+ <p class="text-gray-500"># Download with the siGit CLI</p>
+ <p class="text-gray-200">sigit download <%= full %></p>
+ </div>
+
+ <div class="code-container p-3 space-y-0.5">
+ <% if lib.include?("gguf") || card.tags.any? { |t| t.downcase.include?("gguf") } %>
+ <p class="text-gray-500"># Run with llama.cpp</p>
+ <p class="text-gray-200">llama-cli -hf <%= full %></p>
+ <% elsif lib.include?("transformers") || lib.empty? %>
+ <p class="text-gray-500"># Load with Transformers</p>
+ <p class="text-gray-200">from transformers import AutoModel</p>
+ <p class="text-gray-200">m = AutoModel.from_pretrained(</p>
+ <p class="text-gray-200"> "<%= full %>",</p>
+ <p class="text-gray-200"> revision="<%= repository.default_branch %>")</p>
+ <% else %>
+ <p class="text-gray-500"># Clone the repository</p>
+ <p class="text-gray-200">git clone https://sigit.si/<%= full %></p>
+ <% end %>
+ </div>
+</div>
app/views/repositories/explore.html.erb
+76
new file mode 100644
index 0000000..68b386e
--- /dev/null
+++ b/app/views/repositories/explore.html.erb
@@ -0,0 +1,76 @@
+<% content_for :title, "Repositories" %>
+
+<div class="max-w-6xl mx-auto px-4 sm:px-6 py-8">
+
+ <div class="flex items-end justify-between gap-4 mb-6 flex-wrap">
+ <div>
+ <h1 class="text-2xl font-semibold text-gray-100 flex items-center gap-2">
+ <svg class="w-6 h-6 text-brand-500" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8Z"/>
+ </svg>
+ Repositories
+ <span class="badge-gray text-sm align-middle"><%= number_with_delimiter(@repositories.size) %></span>
+ </h1>
+ <p class="text-sm text-gray-400 mt-1">Source code hosted on siGit — browse, clone, and build on it.</p>
+ </div>
+ <% if signed_in? %>
+ <%= link_to new_repository_path, class: "btn-primary text-xs py-2 px-4" do %>
+ <svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>
+ New repository
+ <% end %>
+ <% end %>
+ </div>
+
+ <div class="flex items-center gap-3 mb-4">
+ <%= form_with url: repos_path, method: :get, class: "flex-1" do %>
+ <%= hidden_field_tag :sort, @sort %>
+ <div class="relative">
+ <svg class="w-4 h-4 text-gray-500 absolute left-3 top-1/2 -translate-y-1/2" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-1.06 1.06ZM11.5 7a4.5 4.5 0 1 0-9 0 4.5 4.5 0 0 0 9 0Z"/></svg>
+ <%= text_field_tag :q, @query, placeholder: "Search repositories…",
+ class: "form-input pl-9", autocomplete: "off" %>
+ </div>
+ <% end %>
+ <%= form_with url: repos_path, method: :get, class: "shrink-0" do %>
+ <% if @query.present? %><%= hidden_field_tag :q, @query %><% end %>
+ <%= select_tag :sort,
+ options_for_select([["Recently updated", "recent"], ["Most stars", "stars"], ["Name", "name"]], @sort),
+ class: "form-input py-2 pr-8 text-sm", onchange: "this.form.submit()" %>
+ <% end %>
+ </div>
+
+ <% if @repositories.any? %>
+ <div class="card divide-y divide-surface-600">
+ <% @repositories.each do |repo| %>
+ <%= link_to repository_path(repo.user.username, repo.name),
+ class: "block px-4 py-3 hover:bg-surface-600 transition-colors" do %>
+ <div class="flex items-center justify-between gap-3">
+ <div class="min-w-0">
+ <div class="flex items-center gap-2">
+ <svg class="w-4 h-4 text-gray-500 shrink-0" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8Z"/>
+ </svg>
+ <span class="font-medium text-gray-100 truncate"><%= repo.full_name %></span>
+ <% if repo.is_private %><span class="badge-gray">Private</span><% end %>
+ </div>
+ <% if repo.description.present? %>
+ <p class="text-xs text-gray-400 mt-1 truncate max-w-xl"><%= repo.description %></p>
+ <% end %>
+ <p class="text-xs text-gray-500 mt-1">Updated <%= time_ago_in_words(repo.updated_at) %> ago</p>
+ </div>
+ <div class="flex items-center gap-1 text-xs text-gray-500 shrink-0" title="Stars">
+ <svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"/></svg>
+ <%= number_with_delimiter(repo.stars_count) %>
+ </div>
+ </div>
+ <% end %>
+ <% end %>
+ </div>
+ <% else %>
+ <div class="card px-6 py-16 text-center">
+ <p class="text-sm text-gray-400 mb-1">No repositories found.</p>
+ <% if signed_in? %>
+ <p class="text-xs text-gray-500"><%= link_to "Create one", new_repository_path, class: "text-brand-500 hover:underline" %> to get started.</p>
+ <% end %>
+ </div>
+ <% end %>
+</div>
app/views/repositories/model.html.erb
+217
new file mode 100644
index 0000000..eea3c2f
--- /dev/null
+++ b/app/views/repositories/model.html.erb
@@ -0,0 +1,217 @@
+<% content_for :title, @repository.full_name %>
+<% card = @card || @repository.model_card %>
+
+<div data-controller="tabs" data-tabs-active-value="<%= params[:tab] || 'card' %>">
+
+<%# ── Model header band ── %>
+<div class="border-b border-surface-600 bg-surface-800">
+ <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
+
+ <div class="flex items-start justify-between gap-4 mb-4">
+ <div class="min-w-0">
+ <div class="flex items-center gap-2 text-sm flex-wrap">
+ <%# cube / weights glyph %>
+ <svg class="w-5 h-5 text-brand-500 shrink-0" viewBox="0 0 24 24" fill="none"
+ stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
+ <path d="m3.27 6.96 8.73 5.05 8.73-5.05M12 22.08V12"/>
+ </svg>
+ <%= link_to "@#{@owner.username}", user_profile_path(@owner.username),
+ class: "text-brand-500 hover:underline font-medium" %>
+ <span class="text-gray-500">/</span>
+ <%= link_to @repository.name, repository_path(@owner.username, @repository.name),
+ class: "text-gray-100 hover:underline font-semibold text-base" %>
+ <% if @repository.is_private %>
+ <span class="badge-gray ml-1">Private</span>
+ <% end %>
+ </div>
+
+ <% if @repository.description.present? %>
+ <p class="text-sm text-gray-400 mt-2 max-w-2xl"><%= @repository.description %></p>
+ <% end %>
+
+ <%# ── Metadata pills ── %>
+ <div class="flex items-center gap-2 flex-wrap mt-3 text-xs">
+ <% if (label = pipeline_label(card.pipeline_tag)) %>
+ <%= link_to label, models_path(pipeline_tag: card.pipeline_tag),
+ class: "badge-blue hover:bg-brand-500/20" %>
+ <% end %>
+ <% if card.library_name.present? %>
+ <%= link_to models_path(library: card.library_name),
+ class: "badge-gray hover:bg-surface-500 flex items-center gap-1" do %>
+ <svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor"><path d="M3 2.75A.75.75 0 0 1 3.75 2h8.5a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-8.5a.75.75 0 0 1-.75-.75Zm1.5.75v9h7v-9Z"/></svg>
+ <%= card.library_name %>
+ <% end %>
+ <% end %>
+ <% if card.license.present? %>
+ <span class="badge-gray">License: <%= card.license %></span>
+ <% end %>
+ <% card.tags.first(6).each do |tag| %>
+ <span class="badge-gray text-gray-400"><%= tag %></span>
+ <% end %>
+ </div>
+ </div>
+
+ <%# ── Like / download stats ── %>
+ <div class="flex items-center gap-2 shrink-0">
+ <%= button_to repository_star_path(@owner.username, @repository.name),
+ method: :post,
+ class: "btn-secondary text-xs py-1.5 px-3 #{'text-amber-300 border-amber-500/40' if @repository.starred_by?(current_user)}" do %>
+ <svg class="w-3.5 h-3.5" viewBox="0 0 16 16"
+ fill="<%= @repository.starred_by?(current_user) ? 'currentColor' : 'none' %>"
+ stroke="currentColor" stroke-width="1.5">
+ <path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"/>
+ </svg>
+ <span>Like</span>
+ <span class="badge-gray ml-0.5"><%= number_with_delimiter(@repository.stars_count) %></span>
+ <% end %>
+ </div>
+ </div>
+
+ <%# ── Tabs ── %>
+ <div class="flex items-center gap-0 -mb-px">
+ <button data-tabs-target="tab" data-tabs-key="card" data-action="click->tabs#switch"
+ class="tab-item active flex items-center gap-1.5">
+ <svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Z"/>
+ </svg>
+ Model card
+ </button>
+ <button data-tabs-target="tab" data-tabs-key="files" data-action="click->tabs#switch"
+ class="tab-item flex items-center gap-1.5">
+ <svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Z"/>
+ </svg>
+ Files and versions
+ <% if @files&.any? %><span class="badge-gray text-xs"><%= @files.size %></span><% end %>
+ </button>
+ </div>
+ </div>
+</div>
+
+<%# ── Body ── %>
+<div class="max-w-6xl mx-auto px-4 sm:px-6 py-6">
+
+ <% if @empty %>
+ <div class="card p-8 text-center max-w-2xl mx-auto">
+ <h2 class="text-lg font-semibold text-gray-100 mb-2">This model has no files yet</h2>
+ <p class="text-sm text-gray-400 mb-6">Push your model card and weights to get started.</p>
+ <div class="code-container text-left p-4 max-w-lg mx-auto text-xs font-mono space-y-0.5">
+ <p class="text-gray-500 mb-1"># Push weights with Git LFS</p>
+ <p class="text-gray-300">git clone git@sigitsi.com:<%= @owner.username %>/<%= @repository.name %></p>
+ <p class="text-gray-300">cd <%= @repository.name %></p>
+ <p class="text-gray-300">git lfs install && git lfs track "*.safetensors" "*.gguf"</p>
+ <p class="text-gray-300">git add . && git commit -m "Add weights"</p>
+ <p class="text-gray-300">git push origin <%= @repository.default_branch %></p>
+ </div>
+ </div>
+ <% else %>
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
+
+ <%# ── Main column ── %>
+ <div class="lg:col-span-2 min-w-0">
+ <%# Model card panel %>
+ <div data-tabs-target="panel" data-tabs-key="card">
+ <% if @card_html.present? %>
+ <div class="card">
+ <div class="px-6 py-6 prose-readme"><%= @card_html %></div>
+ </div>
+ <% else %>
+ <div class="card px-6 py-12 text-center text-sm text-gray-500">
+ No model card yet. Add a <code>README.md</code> to describe this model.
+ </div>
+ <% end %>
+ </div>
+
+ <%# Files and versions panel %>
+ <div data-tabs-target="panel" data-tabs-key="files" class="hidden">
+ <div class="card rounded-b-none border-b-0 px-4 py-2.5 flex items-center justify-between gap-4 bg-surface-600">
+ <span class="text-xs text-gray-400 flex items-center gap-2">
+ <span class="btn-secondary py-1 px-2.5 text-xs font-mono cursor-default"><%= @branch %></span>
+ <%= pluralize(@files.size, "file") %>
+ </span>
+ <span class="text-xs text-gray-500">
+ Total: <span class="text-gray-300"><%= number_to_human_size(@total_size) %></span>
+ </span>
+ </div>
+ <div class="card rounded-t-none">
+ <% @files.each do |entry| %>
+ <div class="file-row">
+ <% if entry[:type] == "tree" %>
+ <svg class="w-4 h-4 text-brand-400 shrink-0" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1Z"/>
+ </svg>
+ <%= link_to entry[:name],
+ repository_tree_path(@owner.username, @repository.name, @branch, entry[:path]),
+ class: "text-brand-500 hover:underline font-medium flex-1" %>
+ <% else %>
+ <svg class="w-4 h-4 text-gray-500 shrink-0" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Z"/>
+ </svg>
+ <%= link_to entry[:name],
+ repository_blob_path(@owner.username, @repository.name, @branch, entry[:path]),
+ class: "text-gray-200 hover:text-brand-500 hover:underline flex-1 truncate" %>
+ <div class="flex items-center gap-2 shrink-0">
+ <% if (fmt = weight_format(entry[:name])) %>
+ <span class="badge-blue text-[10px]"><%= fmt %></span>
+ <% end %>
+ <% if entry[:lfs] %>
+ <span class="badge-gray text-[10px] text-gray-400" title="Stored with Git LFS">LFS</span>
+ <% end %>
+ <% if entry[:size] %>
+ <span class="text-xs text-gray-500 font-mono w-20 text-right"><%= number_to_human_size(entry[:size]) %></span>
+ <% end %>
+ <%= link_to repository_blob_raw_path(@owner.username, @repository.name, @branch, entry[:path]),
+ class: "text-gray-500 hover:text-brand-500", title: "Download" do %>
+ <svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor"><path d="M7.47 10.78a.75.75 0 0 0 1.06 0l3.75-3.75a.75.75 0 0 0-1.06-1.06L8.75 8.44V1.75a.75.75 0 0 0-1.5 0v6.69L4.78 5.97a.75.75 0 0 0-1.06 1.06ZM3.75 13a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5Z"/></svg>
+ <% end %>
+ </div>
+ <% end %>
+ </div>
+ <% end %>
+ </div>
+ </div>
+ </div>
+
+ <%# ── Sidebar ── %>
+ <aside class="lg:col-span-1 space-y-4">
+ <%= render "repositories/model_use", repository: @repository, owner: @owner, card: card %>
+
+ <div class="card p-4 space-y-3 text-sm">
+ <h3 class="text-xs uppercase tracking-wider text-gray-500 font-semibold">Model details</h3>
+ <% if card.base_model.present? %>
+ <div class="flex items-center justify-between gap-2">
+ <span class="text-gray-500">Base model</span>
+ <span class="text-gray-300 truncate font-mono text-xs"><%= card.base_model %></span>
+ </div>
+ <% end %>
+ <% if card.library_name.present? %>
+ <div class="flex items-center justify-between"><span class="text-gray-500">Library</span><span class="text-gray-300"><%= card.library_name %></span></div>
+ <% end %>
+ <% if card.license.present? %>
+ <div class="flex items-center justify-between"><span class="text-gray-500">License</span><span class="text-gray-300"><%= card.license %></span></div>
+ <% end %>
+ <% if card.language.any? %>
+ <div class="flex items-center justify-between"><span class="text-gray-500">Languages</span><span class="text-gray-300"><%= card.language.first(4).join(", ") %></span></div>
+ <% end %>
+ <div class="flex items-center justify-between"><span class="text-gray-500">Downloads</span><span class="text-gray-300"><%= number_with_delimiter(@repository.downloads_count) %></span></div>
+ <div class="flex items-center justify-between"><span class="text-gray-500">Likes</span><span class="text-gray-300"><%= number_with_delimiter(@repository.stars_count) %></span></div>
+ <div class="flex items-center justify-between"><span class="text-gray-500">Updated</span><span class="text-gray-300"><%= time_ago_in_words(@repository.updated_at) %> ago</span></div>
+ </div>
+
+ <% if card.tags.any? %>
+ <div class="card p-4">
+ <h3 class="text-xs uppercase tracking-wider text-gray-500 font-semibold mb-3">Tags</h3>
+ <div class="flex flex-wrap gap-1.5">
+ <% card.tags.each do |tag| %>
+ <span class="badge-gray text-[11px] text-gray-400"><%= tag %></span>
+ <% end %>
+ </div>
+ </div>
+ <% end %>
+ </aside>
+
+ </div>
+ <% end %>
+</div>
+</div>
app/views/repositories/new.html.erb
+27
index 1b6a33f..1f50a66 100644
--- a/app/views/repositories/new.html.erb
+++ b/app/views/repositories/new.html.erb
@@ -5,6 +5,33 @@
<p class="text-sm text-gray-400 mb-8">All your files and their history, in one place.</p>
<%= form_with model: @repository, url: "/new", method: :post, class: "space-y-6" do |f| %>
+ <%# ── Repository type ── %>
+ <div>
+ <label class="form-label mb-2">Type</label>
+ <div class="grid grid-cols-2 gap-3">
+ <label class="card px-4 py-3 cursor-pointer hover:border-surface-400 flex items-start gap-3">
+ <%= f.radio_button :kind, "code", class: "mt-0.5", checked: @repository.code? %>
+ <div>
+ <div class="flex items-center gap-1.5 text-sm font-medium text-gray-100">
+ <svg class="w-4 h-4 text-gray-400" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Z"/></svg>
+ Code
+ </div>
+ <p class="text-xs text-gray-400 mt-0.5">Source code with full git history.</p>
+ </div>
+ </label>
+ <label class="card px-4 py-3 cursor-pointer hover:border-surface-400 flex items-start gap-3">
+ <%= f.radio_button :kind, "model", class: "mt-0.5", checked: @repository.model? %>
+ <div>
+ <div class="flex items-center gap-1.5 text-sm font-medium text-gray-100">
+ <svg class="w-4 h-4 text-brand-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.27 6.96 8.73 5.05 8.73-5.05M12 22.08V12"/></svg>
+ Model
+ </div>
+ <p class="text-xs text-gray-400 mt-0.5">Open weights with a model card & LFS files.</p>
+ </div>
+ </label>
+ </div>
+ </div>
+
<% if @repository.errors.any? %>
<div class="border border-red-800/40 bg-red-900/20 rounded px-4 py-3">
<p class="text-sm font-medium text-red-300 mb-1">Please fix the following errors:</p>
app/views/shared/_navbar.html.erb
+4
-5
index ba64dbb..f419f4c 100644
--- a/app/views/shared/_navbar.html.erb
+++ b/app/views/shared/_navbar.html.erb
@@ -7,11 +7,10 @@
<span class="font-semibold text-base tracking-tight">Code & Deploy</span>
<% end %>
- <% if signed_in? %>
- <div class="hidden sm:flex items-center gap-1">
- <%= link_to "Explore", "#", class: "nav-link px-2 py-1 rounded hover:bg-surface-600" %>
- </div>
- <% end %>
+ <div class="hidden sm:flex items-center gap-1">
+ <%= link_to "Models", models_path, class: "nav-link px-2 py-1 rounded hover:bg-surface-600" %>
+ <%= link_to "Repos", repos_path, class: "nav-link px-2 py-1 rounded hover:bg-surface-600" %>
+ </div>
</div>
<div class="flex items-center gap-3">
app/views/users/_activity_item.html.erb
+1
-1
index 5b5861f..9d226b0 100644
--- a/app/views/users/_activity_item.html.erb
+++ b/app/views/users/_activity_item.html.erb
@@ -35,7 +35,7 @@
<div class="card px-4 py-3">
<p class="text-xs text-gray-500 mb-2"><%= time_ago_in_words(item[:at]) %> ago</p>
<p class="text-sm">
- <span class="text-gray-400">Created repository </span>
+ <span class="text-gray-400">Created <%= item[:repo].model? ? "model" : "repository" %> </span>
<%= link_to "@#{profile_user.username}/#{item[:repo].name}", repository_path(profile_user.username, item[:repo].name), class: "text-brand-500 hover:underline font-medium" %>
</p>
</div>
app/views/users/show.html.erb
+86
-6
index 986f3d4..5b2c91d 100644
--- a/app/views/users/show.html.erb
+++ b/app/views/users/show.html.erb
@@ -122,7 +122,19 @@
<path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8Z"/>
</svg>
Repositories
- <span class="badge-gray ml-1.5 align-middle"><%= @repositories.count %></span>
+ <span class="badge-gray ml-1.5 align-middle"><%= @code_repositories.count %></span>
+ </button>
+ <button data-tabs-target="tab"
+ data-tabs-key="models"
+ data-action="click->tabs#switch"
+ class="tab-item">
+ <svg class="inline-block w-3.5 h-3.5 mr-1.5 -mt-0.5" viewBox="0 0 24 24" fill="none"
+ stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
+ <path d="m3.27 6.96 8.73 5.05 8.73-5.05M12 22.08V12"/>
+ </svg>
+ Models
+ <span class="badge-gray ml-1.5 align-middle"><%= @model_repositories.count %></span>
</button>
</div>
@@ -230,7 +242,7 @@
<p class="text-xs text-gray-500 mb-2">2 weeks ago</p>
<p class="text-sm">
<span class="text-gray-400">Starred </span>
- <%= link_to "bartowski/Qwen2.5-3B-Instruct-GGUF", "https://huggingface.co/bartowski/Qwen2.5-3B-Instruct-GGUF", class: "text-brand-500 hover:underline font-medium", target: "_blank", rel: "noopener noreferrer" %>
+ <%= link_to "bartowski/Qwen2.5-3B-Instruct-GGUF", repository_path("bartowski", "Qwen2.5-3B-Instruct-GGUF"), class: "text-brand-500 hover:underline font-medium" %>
</p>
</div>
</li>
@@ -247,21 +259,21 @@
<% end %>
</div>
- <%# ── Repositories panel ── %>
+ <%# ── Repositories panel (code) ── %>
<div data-tabs-target="panel" data-tabs-key="repositories" class="hidden">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-gray-100">
Repositories
- <span class="badge-gray ml-1 align-middle"><%= @repositories.count %></span>
+ <span class="badge-gray ml-1 align-middle"><%= @code_repositories.count %></span>
</h2>
<% if signed_in? && current_user == @profile_user %>
<%= link_to new_repository_path, class: "btn-secondary text-xs" do %>New<% end %>
<% end %>
</div>
- <% if @repositories.any? %>
+ <% if @code_repositories.any? %>
<div class="space-y-3">
- <% @repositories.each do |repo| %>
+ <% @code_repositories.each do |repo| %>
<div class="card px-5 py-4 hover:border-surface-400 transition-colors">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
@@ -291,6 +303,74 @@
<% end %>
</div>
+ <%# ── Models panel ── %>
+ <div data-tabs-target="panel" data-tabs-key="models" class="hidden">
+ <div class="flex items-center justify-between mb-4">
+ <h2 class="text-base font-semibold text-gray-100">
+ Models
+ <span class="badge-gray ml-1 align-middle"><%= @model_repositories.count %></span>
+ </h2>
+ <% if signed_in? && current_user == @profile_user %>
+ <%= link_to new_repository_path(kind: "model"), class: "btn-secondary text-xs" do %>New<% end %>
+ <% end %>
+ </div>
+
+ <% if @model_repositories.any? %>
+ <div class="space-y-3">
+ <% @model_repositories.each do |repo| %>
+ <% card = repo.model_card %>
+ <div class="card px-5 py-4 hover:border-surface-400 transition-colors">
+ <div class="flex items-start justify-between gap-4">
+ <div class="min-w-0 flex-1">
+ <div class="flex items-center gap-2 mb-1">
+ <svg class="w-4 h-4 text-brand-500 shrink-0" viewBox="0 0 24 24" fill="none"
+ stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
+ <path d="m3.27 6.96 8.73 5.05 8.73-5.05M12 22.08V12"/>
+ </svg>
+ <%= link_to repo.name,
+ repository_path(@profile_user.username, repo.name),
+ class: "font-semibold text-brand-500 hover:underline" %>
+ <% if repo.is_private %>
+ <span class="badge-gray">Private</span>
+ <% end %>
+ </div>
+ <div class="flex items-center gap-2 text-xs text-gray-500 flex-wrap">
+ <% if (label = pipeline_label(card.pipeline_tag)) %>
+ <span class="text-brand-400"><%= label %></span>
+ <span>·</span>
+ <% end %>
+ <% if card.library_name.present? %>
+ <span><%= card.library_name %></span>
+ <span>·</span>
+ <% end %>
+ <span class="flex items-center gap-1">
+ <svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor"><path d="M7.47 10.78a.75.75 0 0 0 1.06 0l3.75-3.75a.75.75 0 0 0-1.06-1.06L8.75 8.44V1.75a.75.75 0 0 0-1.5 0v6.69L4.78 5.97a.75.75 0 0 0-1.06 1.06ZM3.75 13a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5Z"/></svg>
+ <%= humanize_count(repo.downloads_count) %>
+ </span>
+ <span class="flex items-center gap-1">
+ <svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"/></svg>
+ <%= humanize_count(repo.stars_count) %>
+ </span>
+ </div>
+ </div>
+ <div class="text-xs text-gray-500 shrink-0">
+ <%= time_ago_in_words(repo.updated_at) %> ago
+ </div>
+ </div>
+ </div>
+ <% end %>
+ </div>
+ <% else %>
+ <div class="card px-6 py-12 text-center">
+ <p class="text-sm text-gray-500 mb-1">No models yet.</p>
+ <% if signed_in? && current_user == @profile_user %>
+ <p class="text-xs text-gray-500"><%= link_to "Publish a model", new_repository_path(kind: "model"), class: "text-brand-500 hover:underline" %> to share open weights.</p>
+ <% end %>
+ </div>
+ <% end %>
+ </div>
+
</main>
</div>
</div>
config/routes.rb
+11
-1
index 66b7b9b..e0d304a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -22,6 +22,11 @@ Rails.application.routes.draw do
get "/auth/confirmation/resend", to: "confirmations#new", as: :resend_confirmation
post "/auth/confirmation/resend", to: "confirmations#create"
+ # Discovery surfaces — declared before the "/:username" matcher so they
+ # aren't read as profiles.
+ get "/models", to: "models#index", as: :models # open-weights model library
+ get "/repos", to: "repositories#explore", as: :repos # public code repositories
+
# Repository creation
get "/new", to: "repositories#new", as: :new_repository
post "/new", to: "repositories#create"
@@ -56,7 +61,12 @@ Rails.application.routes.draw do
get "/:username/:repository/raw/:branch/*path", to: "blobs#raw", as: :repository_blob_raw, format: false
get "/:username/:repository/tree/:branch/*path", to: "repositories#tree", as: :repository_tree
get "/:username/:repository/tree/:branch", to: "repositories#tree", as: :repository_branch
- get "/:username/:repository", to: "repositories#show", as: :repository
+ post "/:username/:repository/star", to: "stars#create", as: :repository_star,
+ constraints: { repository: /[^\/.][^\/]*/ }, format: false
+ # `format: false` keeps dotted names (e.g. "Qwen2.5-3B-Instruct-GGUF") intact
+ # rather than treating the dot as a response-format separator.
+ get "/:username/:repository", to: "repositories#show", as: :repository,
+ constraints: { repository: /[^\/.][^\/]*/ }, format: false
# Health check
get "up" => "rails/health#show", as: :rails_health_check
db/migrate/20250101000004_add_model_support_and_stars.rb
+22
new file mode 100644
index 0000000..7cb2975
--- /dev/null
+++ b/db/migrate/20250101000004_add_model_support_and_stars.rb
@@ -0,0 +1,22 @@
+class AddModelSupportAndStars < ActiveRecord::Migration[8.1]
+ def change
+ # A repository is either regular source code ("code") or a published set of
+ # open weights ("model") — the Hugging Face-style model library lives on the
+ # same git storage as everything else, distinguished only by this kind.
+ add_column :repositories, :kind, :string, null: false, default: "code"
+ add_index :repositories, :kind
+
+ # Cached counters so the discovery index can sort/show without extra queries.
+ add_column :repositories, :stars_count, :integer, null: false, default: 0
+ add_column :repositories, :downloads_count, :integer, null: false, default: 0
+
+ # Stars double as Hugging Face "likes" for models and GitHub-style stars for
+ # code repos — one row per (user, repository).
+ create_table :stars do |t|
+ t.references :user, null: false, foreign_key: true
+ t.references :repository, null: false, foreign_key: true
+ t.timestamps
+ end
+ add_index :stars, [ :user_id, :repository_id ], unique: true
+ end
+end
db/schema.rb
+17
-1
index 64c28ec..057d71f 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2025_01_01_000003) do
+ActiveRecord::Schema[8.1].define(version: 2025_01_01_000004) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -19,10 +19,14 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000003) do
t.string "default_branch", default: "main", null: false
t.text "description"
t.string "disk_path", null: false
+ t.integer "downloads_count", default: 0, null: false
t.boolean "is_private", default: false, null: false
+ t.string "kind", default: "code", null: false
t.string "name", null: false
+ t.integer "stars_count", default: 0, null: false
t.datetime "updated_at", null: false
t.bigint "user_id", null: false
+ t.index ["kind"], name: "index_repositories_on_kind"
t.index ["user_id", "name"], name: "index_repositories_on_user_id_and_name", unique: true
t.index ["user_id"], name: "index_repositories_on_user_id"
end
@@ -37,6 +41,16 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000003) do
t.index ["user_id"], name: "index_ssh_keys_on_user_id"
end
+ create_table "stars", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "repository_id", null: false
+ t.datetime "updated_at", null: false
+ t.bigint "user_id", null: false
+ t.index ["repository_id"], name: "index_stars_on_repository_id"
+ t.index ["user_id", "repository_id"], name: "index_stars_on_user_id_and_repository_id", unique: true
+ t.index ["user_id"], name: "index_stars_on_user_id"
+ end
+
create_table "users", force: :cascade do |t|
t.string "access_token"
t.string "avatar_url"
@@ -53,4 +67,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000003) do
add_foreign_key "repositories", "users"
add_foreign_key "ssh_keys", "users"
+ add_foreign_key "stars", "repositories"
+ add_foreign_key "stars", "users"
end
db/seeds.rb
+284
-6
index 4fbd6ed..811db75 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -1,9 +1,287 @@
# This file should ensure the existence of records required to run the application in every environment (production,
# development, test). The code here should be idempotent so that it can be executed at any point in every environment.
# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
-#
-# Example:
-#
-# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
-# MovieGenre.find_or_create_by!(name: genre_name)
-# end
+
+require "digest"
+require "open3"
+require "fileutils"
+require "tmpdir"
+
+# ---------------------------------------------------------------------------
+# Demo model library — seeds a handful of Hugging Face-style model repositories
+# so the /models discovery page and model pages work out of the box.
+# ---------------------------------------------------------------------------
+
+# A Git LFS pointer file: the tiny text stub that stands in for a large weight
+# file. The UI resolves the real `size` and shows an "LFS" badge.
+def lfs_pointer(size_bytes, seed)
+ oid = Digest::SHA256.hexdigest("#{seed}-#{size_bytes}")
+ "version https://git-lfs.github.com/spec/v1\noid sha256:#{oid}\nsize #{size_bytes}\n"
+end
+
+GB = 1024 * 1024 * 1024
+MB = 1024 * 1024
+
+# Pushes README + files into the model's bare repo (idempotent: skips if the
+# default branch already has commits).
+def seed_model_files(repo, card:, files:)
+ path = repo.disk_path
+ GitRepositoryService.create_bare_repo(repo.user.username, repo.name) unless repo.initialized?
+ return if GitRepositoryService.branch_exists?(path, repo.default_branch)
+
+ Dir.mktmpdir do |dir|
+ system("git", "-C", dir, "init", "-b", repo.default_branch, exception: true)
+ system("git", "-C", dir, "config", "user.email", "sigitsi@localhost", exception: true)
+ system("git", "-C", dir, "config", "user.name", "siGit", exception: true)
+ # The seed writes Git LFS *pointer* text directly — we do not want the local
+ # git-lfs filter (if installed) to try to clean/upload them as real objects.
+ # Neutralise the LFS filters so the pointer bytes are committed verbatim.
+ %w[clean smudge].each { |f| system("git", "-C", dir, "config", "filter.lfs.#{f}", "cat", exception: true) }
+ system("git", "-C", dir, "config", "filter.lfs.process", "", exception: true)
+ system("git", "-C", dir, "config", "filter.lfs.required", "false", exception: true)
+
+ File.write(File.join(dir, "README.md"), card)
+ File.write(File.join(dir, ".gitattributes"),
+ "*.gguf filter=lfs diff=lfs merge=lfs -text\n*.safetensors filter=lfs diff=lfs merge=lfs -text\n")
+ files.each do |f|
+ full = File.join(dir, f[:path])
+ FileUtils.mkdir_p(File.dirname(full))
+ File.write(full, f[:content])
+ end
+
+ system("git", "-C", dir, "add", ".", exception: true)
+ system("git", "-C", dir, "commit", "-m", "Upload model", exception: true)
+ system("git", "-C", dir, "remote", "add", "origin", path, exception: true)
+ system("git", "-C", dir, "push", "origin", repo.default_branch, exception: true)
+ end
+end
+
+def seed_model(username:, email:, smbcloud_id:, name:, description:, card:, files:, downloads:, likes:, updated_at: Time.current)
+ user = User.find_or_create_by!(smbcloud_id: smbcloud_id) do |u|
+ u.username = username
+ u.email = email
+ end
+
+ repo = user.repositories.find_or_initialize_by(name: name)
+ repo.assign_attributes(
+ kind: "model",
+ description: description,
+ default_branch: "main",
+ disk_path: GitRepositoryService.repo_path(username, name),
+ downloads_count: downloads,
+ stars_count: likes,
+ updated_at: updated_at
+ )
+ repo.save!
+
+ seed_model_files(repo, card: card, files: files)
+ repo
+rescue => e
+ warn " ! Skipped #{username}/#{name}: #{e.message}"
+ nil
+end
+
+# ── bartowski/Qwen2.5-3B-Instruct-GGUF (the showcase model) ──
+qwen_card = <<~MD
+ ---
+ license: apache-2.0
+ base_model: Qwen/Qwen2.5-3B-Instruct
+ pipeline_tag: text-generation
+ library_name: gguf
+ language:
+ - en
+ tags:
+ - text-generation
+ - gguf
+ - quantized
+ - qwen2.5
+ ---
+
+ # Qwen2.5-3B-Instruct-GGUF
+
+ GGUF quantizations of [Qwen/Qwen2.5-3B-Instruct](https://sigit.si/qwen/Qwen2.5-3B-Instruct),
+ produced with `llama.cpp`. Pick a quant that fits your hardware — higher bits
+ mean better quality, lower bits mean a smaller file and faster inference.
+
+ ## Quant table
+
+ | File | Quant | Size | Notes |
+ | ---- | ----- | ---- | ----- |
+ | `Qwen2.5-3B-Instruct-Q8_0.gguf` | Q8_0 | 3.3 GB | Near-lossless, recommended for quality |
+ | `Qwen2.5-3B-Instruct-Q5_K_M.gguf` | Q5_K_M | 2.2 GB | Balanced quality / size |
+ | `Qwen2.5-3B-Instruct-Q4_K_M.gguf` | Q4_K_M | 1.9 GB | Good default for most setups |
+ | `Qwen2.5-3B-Instruct-Q3_K_M.gguf` | Q3_K_M | 1.6 GB | Smallest, some quality loss |
+
+ ## Run it
+
+ ```bash
+ llama-cli -hf bartowski/Qwen2.5-3B-Instruct-GGUF -p "Hello!"
+ ```
+
+ ## Prompt format
+
+ ```
+ <|im_start|>system
+ You are a helpful assistant.<|im_end|>
+ <|im_start|>user
+ {prompt}<|im_end|>
+ <|im_start|>assistant
+ ```
+MD
+
+qwen_files = [
+ { path: "Qwen2.5-3B-Instruct-Q8_0.gguf", content: lfs_pointer((3.3 * GB).to_i, "q8") },
+ { path: "Qwen2.5-3B-Instruct-Q5_K_M.gguf", content: lfs_pointer((2.2 * GB).to_i, "q5") },
+ { path: "Qwen2.5-3B-Instruct-Q4_K_M.gguf", content: lfs_pointer((1.9 * GB).to_i, "q4") },
+ { path: "Qwen2.5-3B-Instruct-Q3_K_M.gguf", content: lfs_pointer((1.6 * GB).to_i, "q3") },
+ { path: "config.json", content: %({\n "model_type": "qwen2",\n "quantized_by": "bartowski"\n}\n) }
+]
+
+seed_model(
+ username: "bartowski", email: "bartowski@example.com", smbcloud_id: 900_001,
+ name: "Qwen2.5-3B-Instruct-GGUF",
+ description: "GGUF quants of Qwen2.5-3B-Instruct for llama.cpp.",
+ card: qwen_card, files: qwen_files,
+ downloads: 1_284_530, likes: 412, updated_at: 2.weeks.ago
+)
+
+# ── meta-llama/Llama-3.2-1B-Instruct (safetensors / transformers) ──
+llama_card = <<~MD
+ ---
+ license: llama3.2
+ base_model: meta-llama/Llama-3.2-1B
+ pipeline_tag: text-generation
+ library_name: transformers
+ language:
+ - en
+ tags:
+ - text-generation
+ - llama
+ - instruct
+ ---
+
+ # Llama-3.2-1B-Instruct
+
+ A compact 1B instruction-tuned model. Load it with 🤗 Transformers:
+
+ ```python
+ from transformers import AutoModelForCausalLM, AutoTokenizer
+
+ model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.2-1B-Instruct")
+ tok = AutoTokenizer.from_pretrained("meta-llama/Llama-3.2-1B-Instruct")
+ ```
+MD
+
+llama_files = [
+ { path: "model.safetensors", content: lfs_pointer((2.5 * GB).to_i, "llama-st") },
+ { path: "config.json", content: %({\n "model_type": "llama",\n "hidden_size": 2048\n}\n) },
+ { path: "tokenizer.json", content: lfs_pointer((9 * MB).to_i, "llama-tok") },
+ { path: "generation_config.json", content: %({\n "temperature": 0.6,\n "top_p": 0.9\n}\n) }
+]
+
+seed_model(
+ username: "meta-llama", email: "meta-llama@example.com", smbcloud_id: 900_002,
+ name: "Llama-3.2-1B-Instruct",
+ description: "Lightweight 1B instruction-tuned text model.",
+ card: llama_card, files: llama_files,
+ downloads: 3_902_117, likes: 1_287, updated_at: 5.days.ago
+)
+
+# ── sentence-transformers/all-MiniLM-L6-v2 (embeddings) ──
+minilm_card = <<~MD
+ ---
+ license: apache-2.0
+ pipeline_tag: sentence-similarity
+ library_name: sentence-transformers
+ tags:
+ - sentence-similarity
+ - feature-extraction
+ - embeddings
+ ---
+
+ # all-MiniLM-L6-v2
+
+ Maps sentences and paragraphs to a 384-dimensional dense vector space for
+ semantic search, clustering, and retrieval.
+
+ ```python
+ from sentence_transformers import SentenceTransformer
+ model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
+ emb = model.encode(["A sentence to embed."])
+ ```
+MD
+
+minilm_files = [
+ { path: "model.safetensors", content: lfs_pointer((90 * MB).to_i, "minilm-st") },
+ { path: "config.json", content: %({\n "model_type": "bert",\n "hidden_size": 384\n}\n) },
+ { path: "tokenizer.json", content: lfs_pointer((700 * 1024).to_i, "minilm-tok") }
+]
+
+seed_model(
+ username: "sentence-transformers", email: "st@example.com", smbcloud_id: 900_003,
+ name: "all-MiniLM-L6-v2",
+ description: "384-dim sentence embeddings for semantic search.",
+ card: minilm_card, files: minilm_files,
+ downloads: 8_450_990, likes: 2_034, updated_at: 1.month.ago
+)
+
+# ── A model published by the demo @sigit profile, so its Models tab has content ──
+# Attaches to the existing "sigit" user rather than creating one. On-brand: a
+# small code-completion model the platform ships itself.
+if (sigit = User.find_by(username: "sigit"))
+ sigit_card = <<~MD
+ ---
+ license: apache-2.0
+ base_model: Qwen/Qwen2.5-Coder-1.5B
+ pipeline_tag: text-generation
+ library_name: gguf
+ language:
+ - en
+ tags:
+ - text-generation
+ - code
+ - code-completion
+ - gguf
+ ---
+
+ # SiGit-Coder-1.5B-GGUF
+
+ A small, fast code-completion model that powers inline suggestions in the
+ siGit Code & Deploy editor. GGUF quantizations run locally with `llama.cpp`,
+ so completions stay on your machine.
+
+ ## Quant table
+
+ | File | Quant | Size | Notes |
+ | ---- | ----- | ---- | ----- |
+ | `SiGit-Coder-1.5B-Q8_0.gguf` | Q8_0 | 1.6 GB | Best quality |
+ | `SiGit-Coder-1.5B-Q5_K_M.gguf` | Q5_K_M | 1.1 GB | Balanced |
+ | `SiGit-Coder-1.5B-Q4_K_M.gguf` | Q4_K_M | 1.0 GB | Recommended default |
+
+ ## Run it
+
+ ```bash
+ llama-cli -hf sigit/SiGit-Coder-1.5B-GGUF -p "def fib(n):"
+ ```
+ MD
+
+ sigit_files = [
+ { path: "SiGit-Coder-1.5B-Q8_0.gguf", content: lfs_pointer((1.6 * GB).to_i, "sigit-q8") },
+ { path: "SiGit-Coder-1.5B-Q5_K_M.gguf", content: lfs_pointer((1.1 * GB).to_i, "sigit-q5") },
+ { path: "SiGit-Coder-1.5B-Q4_K_M.gguf", content: lfs_pointer((1.0 * GB).to_i, "sigit-q4") },
+ { path: "config.json", content: %({\n "model_type": "qwen2",\n "quantized_by": "sigit"\n}\n) }
+ ]
+
+ repo = sigit.repositories.find_or_initialize_by(name: "SiGit-Coder-1.5B-GGUF")
+ repo.assign_attributes(
+ kind: "model",
+ description: "Local code-completion model behind the siGit editor's inline suggestions.",
+ default_branch: "main",
+ disk_path: GitRepositoryService.repo_path(sigit.username, "SiGit-Coder-1.5B-GGUF"),
+ downloads_count: 47_213, stars_count: 96, updated_at: 4.days.ago
+ )
+ repo.save!
+ seed_model_files(repo, card: sigit_card, files: sigit_files)
+end
+
+puts "Seeded #{Repository.models.count} model repositories."
repos/.keep
new file mode 100644
index 0000000..e69de29