Models hosting v1

Seto Elkahfi committed Jun 17, 2026 at 10:52 UTC 7734fcb6fed6ce5a0c3a9a4166baa0620190ae4c
25 files changed +1383 -45
.gitignore
+5
@@ -40,3 +40,8 @@
40 # Ignore local git repos for development environment
41 .local-repos/*
42 !.local-repos/.keep
43 +
44 +# Ignore the bare git repositories the app hosts on disk (runtime data:
45 +# user repos, seeded models, and their LFS-backed weights). Keep the folder.
46 +/repos/*
47 +!/repos/.keep
app/controllers/models_controller.rb new
+67
@@ -0,0 +1,67 @@
1 +# Hugging Face-style discovery page for open-weights model repositories.
2 +class ModelsController < ApplicationController
3 + def index
4 + @query = params[:q].to_s.strip
5 + @pipeline_tag = params[:pipeline_tag].presence
6 + @library = params[:library].presence
7 + @sort = params.fetch(:sort, "trending")
8 +
9 + repos = Repository.models
10 + .visible_to(current_user)
11 + .includes(:user)
12 + .to_a
13 +
14 + # Decorate each repo with its parsed model card once, then filter/sort in
15 + # Ruby — the card metadata lives in git, not the database.
16 + cards = repos.map { |repo| [ repo, repo.model_card ] }
17 +
18 + if @query.present?
19 + q = @query.downcase
20 + cards.select! do |repo, card|
21 + repo.full_name.downcase.include?(q) ||
22 + repo.description.to_s.downcase.include?(q) ||
23 + card.tags.any? { |t| t.downcase.include?(q) }
24 + end
25 + end
26 +
27 + @facets = build_facets(cards)
28 +
29 + cards.select! { |_repo, card| card.pipeline_tag == @pipeline_tag } if @pipeline_tag
30 + cards.select! { |_repo, card| card.library_name == @library } if @library
31 +
32 + @models = sort_cards(cards)
33 + end
34 +
35 + private
36 +
37 + def build_facets(cards)
38 + {
39 + pipeline_tags: tally(cards) { |_repo, card| card.pipeline_tag },
40 + libraries: tally(cards) { |_repo, card| card.library_name }
41 + }
42 + end
43 +
44 + def tally(cards)
45 + cards.filter_map { |pair| yield(*pair) }
46 + .tally
47 + .sort_by { |label, count| [ -count, label ] }
48 + end
49 +
50 + def sort_cards(cards)
51 + case @sort
52 + when "downloads"
53 + cards.sort_by { |repo, _| -repo.downloads_count }
54 + when "likes"
55 + cards.sort_by { |repo, _| -repo.stars_count }
56 + when "recent"
57 + cards.sort_by { |repo, _| repo.updated_at }.reverse
58 + else # "trending" — a blend of likes, downloads and recency
59 + cards.sort_by { |repo, _| -trending_score(repo) }
60 + end
61 + end
62 +
63 + def trending_score(repo)
64 + age_days = [ (Time.current - repo.updated_at) / 1.day, 0.5 ].max
65 + ((repo.stars_count * 3) + Math.log10(repo.downloads_count + 1) * 10) / age_days
66 + end
67 +end
app/controllers/repositories_controller.rb
+75 -5
@@ -4,8 +4,27 @@ class RepositoriesController < ApplicationController
4 before_action :load_repository, only: [ :show, :tree, :cicd ]
5 before_action :ensure_can_read!, only: [ :show, :tree, :cicd ]
6
7 + # Discovery page for public code repositories — the code-side counterpart to
8 + # the model library at /models.
9 + def explore
10 + @query = params[:q].to_s.strip
11 + @sort = params.fetch(:sort, "recent")
12 +
13 + repos = Repository.code.visible_to(current_user).includes(:user)
14 + if @query.present?
15 + like = "%#{@query.downcase}%"
16 + repos = repos.where("LOWER(repositories.name) LIKE :q OR LOWER(repositories.description) LIKE :q", q: like)
17 + end
18 +
19 + @repositories = case @sort
20 + when "stars" then repos.order(stars_count: :desc, updated_at: :desc)
21 + when "name" then repos.order(Arel.sql("LOWER(repositories.name) ASC"))
22 + else repos.order(updated_at: :desc)
23 + end.to_a
24 + end
25 +
26 def new
8 - @repository = Repository.new
27 + @repository = Repository.new(kind: params[:kind] == "model" ? "model" : "code")
28 end
29
30 def create
@@ -18,10 +37,11 @@ class RepositoriesController < ApplicationController
37 GitRepositoryService.create_bare_repo(current_user.username, @repository.name)
38 GitRepositoryService.initialize_with_readme(
39 current_user.username, @repository.name,
21 - default_branch: @repository.default_branch
40 + default_branch: @repository.default_branch,
41 + readme: initial_readme(@repository)
42 )
43 redirect_to repository_path(current_user.username, @repository.name),
24 - notice: "Repository created successfully."
44 + notice: "#{@repository.model? ? 'Model' : 'Repository'} created successfully."
45 else
46 render :new, status: :unprocessable_entity
47 end
@@ -38,7 +58,11 @@ class RepositoriesController < ApplicationController
58
59 unless @repository.initialized? && GitRepositoryService.branch_exists?(@repository.disk_path, branch)
60 @empty = true
41 - return
61 + render(@repository.model? ? :model : :show) and return
62 + end
63 +
64 + if @repository.model?
65 + return show_model(branch)
66 end
67
68 @tree = GitRepositoryService.list_tree(@repository.disk_path, branch)
@@ -106,8 +130,54 @@ class RepositoriesController < ApplicationController
130
131 private
132
133 + # Renders the Hugging Face-style model page: parsed model card + a
134 + # "Files and versions" listing with LFS-resolved sizes, all on one page with
135 + # client-side tab switching.
136 + def show_model(branch)
137 + @card = @repository.model_card(branch: branch)
138 + @card_html = render_markdown(@card.body) if @card.body.present?
139 + @files = GitRepositoryService.model_files(@repository.disk_path, branch)
140 + @total_size = @files.filter_map { |f| f[:size] }.sum
141 + @commit_count = GitRepositoryService.commit_count(@repository.disk_path, branch)
142 + @recent_commits = GitRepositoryService.commits(@repository.disk_path, branch, limit: 1)
143 + render :model
144 + end
145 +
146 def repository_params
110 - params.require(:repository).permit(:name, :description, :default_branch, :is_private)
147 + permitted = params.require(:repository).permit(:name, :description, :default_branch, :is_private, :kind)
148 + permitted[:kind] = "model" if permitted[:kind] == "model"
149 + permitted[:kind] = "code" unless permitted[:kind] == "model"
150 + permitted
151 + end
152 +
153 + # Seeds a fresh repo's README — a starter model card with YAML front-matter
154 + # for models, a plain heading for code repos.
155 + def initial_readme(repository)
156 + return nil unless repository.model?
157 +
158 + <<~MARKDOWN
159 + ---
160 + license: apache-2.0
161 + pipeline_tag: text-generation
162 + library_name: transformers
163 + tags:
164 + - #{repository.name}
165 + ---
166 +
167 + # #{repository.name}
168 +
169 + Describe your model here. Document its architecture, training data,
170 + intended use, limitations, and how to load the weights.
171 +
172 + Push your weight files (`*.safetensors`, `*.gguf`, `*.bin`) with Git LFS:
173 +
174 + ```bash
175 + git lfs install
176 + git lfs track "*.safetensors" "*.gguf"
177 + git add .gitattributes
178 + git push origin #{repository.default_branch}
179 + ```
180 + MARKDOWN
181 end
182
183 def load_owner
app/controllers/stars_controller.rb new
+28
@@ -0,0 +1,28 @@
1 +# Toggles a star (a "like" for models, a star for code repos) for the signed-in
2 +# user on a repository. One endpoint flips the state in both directions.
3 +class StarsController < ApplicationController
4 + before_action :require_sign_in!
5 + before_action :load_repository
6 +
7 + def create
8 + star = current_user.stars.find_or_initialize_by(repository: @repository)
9 + if star.persisted?
10 + star.destroy
11 + else
12 + star.save
13 + end
14 + redirect_back fallback_location: repository_path(@repository.user.username, @repository.name)
15 + end
16 +
17 + private
18 +
19 + def load_repository
20 + owner = User.find_by!(username: params[:username])
21 + @repository = owner.repositories.find_by!(name: params[:repository])
22 + unless !@repository.is_private || current_user == owner
23 + render file: Rails.public_path.join("404.html"), status: :not_found, layout: false
24 + end
25 + rescue ActiveRecord::RecordNotFound
26 + render file: Rails.public_path.join("404.html"), status: :not_found, layout: false
27 + end
28 +end
app/controllers/users_controller.rb
+6 -1
@@ -11,7 +11,12 @@ class UsersController < ApplicationController
11 @profile_user.repositories.order(updated_at: :desc)
12 else
13 @profile_user.repositories.where(is_private: false).order(updated_at: :desc)
14 - end
14 + end.to_a
15 +
16 + # The profile mirrors the site nav: code repositories and models are shown
17 + # on separate tabs, so split the owner's repos by kind.
18 + @code_repositories = @repositories.select(&:code?)
19 + @model_repositories = @repositories.select(&:model?)
20
21 if @profile_user.username == DEMO_FEED_USERNAME
22 # Curated showcase feed for the demo profile.
app/helpers/models_helper.rb new
+53
@@ -0,0 +1,53 @@
1 +module ModelsHelper
2 + # A small curated map of Hugging Face pipeline tags to human labels. Unknown
3 + # tags fall back to a titleized version of the raw value.
4 + PIPELINE_LABELS = {
5 + "text-generation" => "Text Generation",
6 + "text2text-generation" => "Text-to-Text",
7 + "text-classification" => "Text Classification",
8 + "token-classification" => "Token Classification",
9 + "question-answering" => "Question Answering",
10 + "summarization" => "Summarization",
11 + "translation" => "Translation",
12 + "feature-extraction" => "Feature Extraction",
13 + "sentence-similarity" => "Sentence Similarity",
14 + "fill-mask" => "Fill-Mask",
15 + "image-classification" => "Image Classification",
16 + "image-to-text" => "Image-to-Text",
17 + "text-to-image" => "Text-to-Image",
18 + "automatic-speech-recognition" => "Speech Recognition",
19 + "text-to-speech" => "Text-to-Speech",
20 + "audio-classification" => "Audio Classification"
21 + }.freeze
22 +
23 + def pipeline_label(tag)
24 + return nil if tag.blank?
25 + PIPELINE_LABELS[tag] || tag.tr("-_", " ").split.map(&:capitalize).join(" ")
26 + end
27 +
28 + # Compact human download count, e.g. 1240 → "1.2k", 3_400_000 → "3.4M".
29 + def humanize_count(n)
30 + n = n.to_i
31 + return n.to_s if n < 1000
32 + number_to_human(n, units: { thousand: "k", million: "M", billion: "B" },
33 + format: "%n%u", precision: 2, strip_insignificant_zeros: true)
34 + end
35 +
36 + # Maps a weight-file extension to a short format badge label.
37 + WEIGHT_FORMATS = {
38 + "safetensors" => "Safetensors",
39 + "gguf" => "GGUF",
40 + "bin" => "PyTorch",
41 + "pt" => "PyTorch",
42 + "pth" => "PyTorch",
43 + "onnx" => "ONNX",
44 + "ckpt" => "Checkpoint",
45 + "h5" => "Keras",
46 + "msgpack" => "Flax",
47 + "tflite" => "TFLite"
48 + }.freeze
49 +
50 + def weight_format(filename)
51 + WEIGHT_FORMATS[File.extname(filename).delete_prefix(".").downcase]
52 + end
53 +end
app/models/repository.rb
+35
@@ -1,5 +1,15 @@
1 class Repository < ApplicationRecord
2 + KINDS = %w[code model].freeze
3 +
4 belongs_to :user
5 + has_many :stars, dependent: :destroy
6 + has_many :stargazers, through: :stars, source: :user
7 +
8 + scope :models, -> { where(kind: "model") }
9 + scope :code, -> { where(kind: "code") }
10 + scope :visible_to, ->(user) {
11 + user ? where("is_private = ? OR user_id = ?", false, user.id) : where(is_private: false)
12 + }
13
14 validates :name, presence: true,
15 format: {
@@ -8,6 +18,7 @@ class Repository < ApplicationRecord
18 },
19 uniqueness: { scope: :user_id, case_sensitive: false }
20 validates :default_branch, presence: true
21 + validates :kind, inclusion: { in: KINDS }
22
23 validate :no_consecutive_dots_in_name
24 validate :no_leading_hyphen_in_name
@@ -29,6 +40,30 @@ class Repository < ApplicationRecord
40 Dir.exist?(git_path) && File.exist?(File.join(git_path, "HEAD"))
41 end
42
43 + def model?
44 + kind == "model"
45 + end
46 +
47 + def code?
48 + kind != "model"
49 + end
50 +
51 + def starred_by?(user)
52 + return false unless user
53 + stars.exists?(user_id: user.id)
54 + end
55 +
56 + # Parses the Hugging Face-style YAML front-matter from the README (the "model
57 + # card") on the default branch. Memoized per instance; returns a ModelCard
58 + # even when there is no front-matter so callers can use it unconditionally.
59 + def model_card(branch: nil)
60 + @model_card ||= begin
61 + branch ||= default_branch
62 + readme = (GitRepositoryService.readme_content(disk_path, branch) if initialized?)
63 + ModelCard.parse(readme&.dig(:content))
64 + end
65 + end
66 +
67 private
68
69 def no_consecutive_dots_in_name
app/models/star.rb new
+6
@@ -0,0 +1,6 @@
1 +class Star < ApplicationRecord
2 + belongs_to :user
3 + belongs_to :repository, counter_cache: :stars_count
4 +
5 + validates :user_id, uniqueness: { scope: :repository_id }
6 +end
app/models/user.rb
+2
@@ -3,6 +3,8 @@
3 class User < ApplicationRecord
4 has_many :repositories, dependent: :destroy
5 has_many :ssh_keys, dependent: :destroy
6 + has_many :stars, dependent: :destroy
7 + has_many :starred_repositories, through: :stars, source: :repository
8
9 validates :smbcloud_id,
10 presence: true,
app/services/git_repository_service.rb
+38 -7
@@ -16,14 +16,14 @@ class GitRepositoryService
16 path
17 end
18
19 - def self.initialize_with_readme(username, reponame, default_branch: "main")
19 + def self.initialize_with_readme(username, reponame, default_branch: "main", readme: nil)
20 bare_path = repo_path(username, reponame)
21 Dir.mktmpdir do |tmpdir|
22 system("git", "-C", tmpdir, "init", "-b", default_branch, exception: true)
23 system("git", "-C", tmpdir, "config", "user.email", "sigitsi@localhost", exception: true)
24 system("git", "-C", tmpdir, "config", "user.name", "siGit", exception: true)
25 - readme = File.join(tmpdir, "README.md")
26 - File.write(readme, "# #{reponame}\n\nWelcome to #{reponame}.\n")
25 + readme_path = File.join(tmpdir, "README.md")
26 + File.write(readme_path, readme || "# #{reponame}\n\nWelcome to #{reponame}.\n")
27 system("git", "-C", tmpdir, "add", ".", exception: true)
28 system("git", "-C", tmpdir, "commit", "-m", "Initial commit", exception: true)
29 system("git", "-C", tmpdir, "remote", "add", "origin", bare_path, exception: true)
@@ -43,13 +43,44 @@ class GitRepositoryService
43 out, _err, status = Open3.capture3(*args)
44 return [] unless status.success?
45 out.lines.filter_map do |line|
46 - parts = line.split
47 - next unless parts.length >= 5
48 - mode, type, _sha, _size, name = parts[0], parts[1], parts[2], parts[3], parts[4..]&.join(" ")&.strip
49 - { mode: mode, type: type, name: name, path: prefix + name }
46 + # `ls-tree --long` columns: <mode> <type> <sha> <size> <tab> <name>
47 + meta, name = line.split("\t", 2)
48 + next if name.nil?
49 + mode, type, _sha, size = meta.split
50 + { mode: mode, type: type, name: name.strip,
51 + path: prefix + name.strip,
52 + size: (size == "-" ? nil : size.to_i) }
53 end.sort_by { |e| [e[:type] == "tree" ? 0 : 1, e[:name].downcase] }
54 end
55
56 + # Git LFS / Xet pointer files are tiny text stubs that stand in for large
57 + # binary weights stored out-of-band. Recognise them so the UI can show the
58 + # real on-disk size and an "LFS" badge instead of the ~130-byte pointer.
59 + LFS_POINTER_RE = %r{\Aversion https://(?:git-lfs\.github\.com|hf\.co)/spec/v1}
60 + LFS_SIZE_RE = /^size (\d+)$/
61 + POINTER_MAX = 1024
62 +
63 + # Root-level file listing for a model repository's "Files and versions" tab,
64 + # with LFS pointer sizes resolved. Folders are included but not recursed.
65 + def self.model_files(path, branch)
66 + list_tree(path, branch).map do |entry|
67 + next entry unless entry[:type] == "blob"
68 + resolve_lfs(path, branch, entry)
69 + end
70 + end
71 +
72 + # If +entry+ is a small blob that turns out to be an LFS pointer, replace its
73 + # size with the pointer's declared size and flag it. Otherwise return as-is.
74 + def self.resolve_lfs(path, branch, entry)
75 + return entry if entry[:size].nil? || entry[:size] > POINTER_MAX
76 +
77 + content = file_content(path, branch, entry[:path])
78 + return entry unless content&.match?(LFS_POINTER_RE)
79 +
80 + size = content[LFS_SIZE_RE, 1]&.to_i
81 + entry.merge(lfs: true, size: size || entry[:size])
82 + end
83 +
84 def self.file_content(path, branch, file_path)
85 out, _err, status = Open3.capture3("git", "--git-dir", path, "show", "#{branch}:#{file_path}")
86 return nil unless status.success?
app/services/model_card.rb new
+86
@@ -0,0 +1,86 @@
1 +require "yaml"
2 +
3 +# Parses a Hugging Face-style "model card": a Markdown README whose leading
4 +# section is a YAML front-matter block delimited by `---` lines, e.g.
5 +#
6 +# ---
7 +# license: apache-2.0
8 +# base_model: Qwen/Qwen2.5-3B-Instruct
9 +# pipeline_tag: text-generation
10 +# library_name: gguf
11 +# tags:
12 +# - text-generation
13 +# - gguf
14 +# ---
15 +# # Qwen2.5-3B-Instruct-GGUF
16 +# ...markdown body...
17 +#
18 +# The metadata drives the model page sidebar and the discovery-page facets; the
19 +# body is the rendered card content with the front-matter stripped out.
20 +class ModelCard
21 + FRONT_MATTER = /\A---\s*\n(.*?\n)---\s*\n?(.*)\z/m
22 +
23 + attr_reader :metadata, :body
24 +
25 + def self.parse(content)
26 + new(content)
27 + end
28 +
29 + def initialize(content)
30 + content = content.to_s
31 + if (m = content.match(FRONT_MATTER))
32 + @metadata = safe_yaml(m[1])
33 + @body = m[2].to_s
34 + else
35 + @metadata = {}
36 + @body = content
37 + end
38 + end
39 +
40 + def license
41 + value("license")
42 + end
43 +
44 + def pipeline_tag
45 + value("pipeline_tag")
46 + end
47 +
48 + def library_name
49 + value("library_name") || value("library")
50 + end
51 +
52 + def base_model
53 + Array(metadata["base_model"]).flatten.compact.first.presence
54 + end
55 +
56 + def language
57 + Array(metadata["language"]).flatten.compact.map(&:to_s)
58 + end
59 +
60 + def datasets
61 + Array(metadata["datasets"]).flatten.compact.map(&:to_s)
62 + end
63 +
64 + def tags
65 + Array(metadata["tags"]).flatten.compact.map(&:to_s).uniq
66 + end
67 +
68 + # True when there is any structured metadata worth showing in the sidebar.
69 + def present?
70 + metadata.present?
71 + end
72 +
73 + private
74 +
75 + def value(key)
76 + v = metadata[key]
77 + v.is_a?(Array) ? v.first : v
78 + end
79 +
80 + def safe_yaml(raw)
81 + parsed = YAML.safe_load(raw, permitted_classes: [ Date, Time ], aliases: false)
82 + parsed.is_a?(Hash) ? parsed : {}
83 + rescue Psych::SyntaxError
84 + {}
85 + end
86 +end
app/views/models/index.html.erb new
+144
@@ -0,0 +1,144 @@
1 +<% content_for :title, "Models" %>
2 +
3 +<div class="max-w-6xl mx-auto px-4 sm:px-6 py-8">
4 +
5 + <div class="flex items-end justify-between gap-4 mb-6 flex-wrap">
6 + <div>
7 + <h1 class="text-2xl font-semibold text-gray-100 flex items-center gap-2">
8 + <svg class="w-6 h-6 text-brand-500" viewBox="0 0 24 24" fill="none"
9 + stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
10 + <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"/>
11 + <path d="m3.27 6.96 8.73 5.05 8.73-5.05M12 22.08V12"/>
12 + </svg>
13 + Models
14 + <span class="badge-gray text-sm align-middle"><%= number_with_delimiter(@models.size) %></span>
15 + </h1>
16 + <p class="text-sm text-gray-400 mt-1">Open-weights models hosted on siGit, ready to download and run.</p>
17 + </div>
18 + <% if signed_in? %>
19 + <%= link_to new_repository_path(kind: "model"), class: "btn-primary text-xs py-2 px-4" do %>
20 + <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>
21 + New model
22 + <% end %>
23 + <% end %>
24 + </div>
25 +
26 + <div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
27 +
28 + <%# ── Filter sidebar ── %>
29 + <aside class="lg:col-span-1 space-y-6">
30 + <% if @facets[:pipeline_tags].any? %>
31 + <div>
32 + <h2 class="text-xs uppercase tracking-wider text-gray-500 font-semibold mb-2">Tasks</h2>
33 + <ul class="space-y-0.5">
34 + <% @facets[:pipeline_tags].each do |tag, count| %>
35 + <li>
36 + <%= link_to models_path(request.query_parameters.merge(pipeline_tag: (@pipeline_tag == tag ? nil : tag)).compact),
37 + 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 %>
38 + <span class="truncate"><%= pipeline_label(tag) %></span>
39 + <span class="text-xs text-gray-500"><%= count %></span>
40 + <% end %>
41 + </li>
42 + <% end %>
43 + </ul>
44 + </div>
45 + <% end %>
46 +
47 + <% if @facets[:libraries].any? %>
48 + <div>
49 + <h2 class="text-xs uppercase tracking-wider text-gray-500 font-semibold mb-2">Libraries</h2>
50 + <ul class="space-y-0.5">
51 + <% @facets[:libraries].each do |lib, count| %>
52 + <li>
53 + <%= link_to models_path(request.query_parameters.merge(library: (@library == lib ? nil : lib)).compact),
54 + 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 %>
55 + <span class="truncate"><%= lib %></span>
56 + <span class="text-xs text-gray-500"><%= count %></span>
57 + <% end %>
58 + </li>
59 + <% end %>
60 + </ul>
61 + </div>
62 + <% end %>
63 +
64 + <% if @pipeline_tag || @library || @query.present? %>
65 + <%= link_to "Clear filters", models_path, class: "text-xs text-brand-500 hover:underline" %>
66 + <% end %>
67 + </aside>
68 +
69 + <%# ── Results ── %>
70 + <main class="lg:col-span-3">
71 + <div class="flex items-center gap-3 mb-4">
72 + <%= form_with url: models_path, method: :get, class: "flex-1" do %>
73 + <% if @pipeline_tag %><%= hidden_field_tag :pipeline_tag, @pipeline_tag %><% end %>
74 + <% if @library %><%= hidden_field_tag :library, @library %><% end %>
75 + <%= hidden_field_tag :sort, @sort %>
76 + <div class="relative">
77 + <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>
78 + <%= text_field_tag :q, @query, placeholder: "Search models by name or tag…",
79 + class: "form-input pl-9", autocomplete: "off" %>
80 + </div>
81 + <% end %>
82 + <%= form_with url: models_path, method: :get, class: "shrink-0", id: "sort-form" do %>
83 + <% if @query.present? %><%= hidden_field_tag :q, @query %><% end %>
84 + <% if @pipeline_tag %><%= hidden_field_tag :pipeline_tag, @pipeline_tag %><% end %>
85 + <% if @library %><%= hidden_field_tag :library, @library %><% end %>
86 + <%= select_tag :sort,
87 + options_for_select([["Trending", "trending"], ["Most likes", "likes"], ["Most downloads", "downloads"], ["Recently updated", "recent"]], @sort),
88 + class: "form-input py-2 pr-8 text-sm", onchange: "this.form.submit()" %>
89 + <% end %>
90 + </div>
91 +
92 + <% if @models.any? %>
93 + <div class="card divide-y divide-surface-600">
94 + <% @models.each do |repo, card| %>
95 + <%= link_to repository_path(repo.user.username, repo.name),
96 + class: "block px-4 py-3 hover:bg-surface-600 transition-colors" do %>
97 + <div class="flex items-center justify-between gap-3">
98 + <div class="min-w-0">
99 + <div class="flex items-center gap-2">
100 + <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">
101 + <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"/>
102 + <path d="m3.27 6.96 8.73 5.05 8.73-5.05M12 22.08V12"/>
103 + </svg>
104 + <span class="font-medium text-gray-100 truncate"><%= repo.full_name %></span>
105 + <% if repo.is_private %><span class="badge-gray">Private</span><% end %>
106 + </div>
107 + <div class="flex items-center gap-2 mt-1 text-xs text-gray-500 flex-wrap">
108 + <% if (label = pipeline_label(card.pipeline_tag)) %>
109 + <span class="text-brand-400"><%= label %></span>
110 + <span>&middot;</span>
111 + <% end %>
112 + <% if card.library_name.present? %>
113 + <span><%= card.library_name %></span>
114 + <span>&middot;</span>
115 + <% end %>
116 + <span>Updated <%= time_ago_in_words(repo.updated_at) %> ago</span>
117 + </div>
118 + </div>
119 + <div class="flex items-center gap-4 text-xs text-gray-500 shrink-0">
120 + <span class="flex items-center gap-1" title="Downloads">
121 + <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>
122 + <%= humanize_count(repo.downloads_count) %>
123 + </span>
124 + <span class="flex items-center gap-1" title="Likes">
125 + <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>
126 + <%= humanize_count(repo.stars_count) %>
127 + </span>
128 + </div>
129 + </div>
130 + <% end %>
131 + <% end %>
132 + </div>
133 + <% else %>
134 + <div class="card px-6 py-16 text-center">
135 + <p class="text-sm text-gray-400 mb-1">No models found.</p>
136 + <p class="text-xs text-gray-500">Try clearing filters or
137 + <%= link_to "publish the first one", new_repository_path(kind: "model"), class: "text-brand-500 hover:underline" %>.
138 + </p>
139 + </div>
140 + <% end %>
141 + </main>
142 +
143 + </div>
144 +</div>
app/views/pages/home.html.erb
+59 -12
@@ -5,18 +5,24 @@
5 <div class="max-w-6xl mx-auto px-4 sm:px-6 py-20 sm:py-28">
6 <div class="max-w-3xl">
7
8 - <h1 class="max-w-2xl text-4xl font-semibold tracking-tight text-gray-100 sm:text-6xl">
9 - Git hosting that stays out of the way.
8 +
9 + <h1 class="mt-6 max-w-2xl text-4xl font-semibold tracking-tight text-gray-100 sm:text-6xl">
10 + Code and models, hosted side by side.
11 </h1>
12
13 <p class="mt-6 max-w-xl text-base leading-7 text-gray-400 sm:text-lg">
13 - siGit gives you repositories, history, diffs, and a calm place to read code.
14 - Nothing loud. Nothing extra.
14 + siGit is git hosting built for the AI era. Version your repositories, publish
15 + open-weights models with full model cards and LFS-backed files, and hand the keys
16 + to your AI agent over the Agent Client Protocol.
17 </p>
18
19 <div class="mt-10 flex flex-col gap-3 sm:flex-row">
18 - <%= link_to "Sign in", signin_path, class: "btn-primary px-6 py-3 text-sm justify-center" %>
19 - <a href="#features" class="btn-secondary px-6 py-3 text-sm justify-center">See what it does</a>
20 + <%= link_to "Explore models", models_path, class: "btn-primary px-6 py-3 text-sm justify-center" %>
21 + <% if signed_in? %>
22 + <%= link_to "Browse repositories", repos_path, class: "btn-secondary px-6 py-3 text-sm justify-center" %>
23 + <% else %>
24 + <%= link_to "Sign in", signin_path, class: "btn-secondary px-6 py-3 text-sm justify-center" %>
25 + <% end %>
26 </div>
27 <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>
28
@@ -36,15 +42,54 @@
42 </div>
43 </section>
44
45 + <%# ── Two pillars: code + models ── %>
46 + <section class="max-w-6xl mx-auto px-4 sm:px-6 pt-16 sm:pt-20">
47 + <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
48 + <%= link_to repos_path, class: "card p-8 hover:border-surface-400 transition-colors group" do %>
49 + <div class="flex items-center gap-3 mb-4">
50 + <span class="flex h-10 w-10 items-center justify-center rounded-sm border border-surface-500 bg-surface-800 text-gray-300">
51 + <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>
52 + </span>
53 + <h2 class="text-lg font-semibold text-gray-100">Code repositories</h2>
54 + </div>
55 + <p class="text-sm leading-6 text-gray-400">
56 + Fast diffs, deep file browsing, instant repos. Public or private, just git push.
57 + </p>
58 + <span class="mt-4 inline-flex items-center gap-1 text-sm text-brand-500 group-hover:gap-2 transition-all">
59 + Browse repositories
60 + <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>
61 + </span>
62 + <% end %>
63 +
64 + <%= link_to models_path, class: "card p-8 hover:border-surface-400 transition-colors group" do %>
65 + <div class="flex items-center gap-3 mb-4">
66 + <span class="flex h-10 w-10 items-center justify-center rounded-sm border border-surface-500 bg-surface-800 text-brand-500">
67 + <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>
68 + </span>
69 + <h2 class="text-lg font-semibold text-gray-100">Model library</h2>
70 + </div>
71 + <p class="text-sm leading-6 text-gray-400">
72 + A home for open weights. Model cards, quantized GGUF and safetensors, downloads and likes.
73 + </p>
74 + <span class="mt-4 inline-flex items-center gap-1 text-sm text-brand-500 group-hover:gap-2 transition-all">
75 + Explore models
76 + <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>
77 + </span>
78 + <% end %>
79 + </div>
80 + </section>
81 +
82 + <%# ── Why siGit ── %>
83 <section id="features" class="max-w-6xl mx-auto px-4 sm:px-6 py-16 sm:py-20">
84 <div class="grid grid-cols-1 gap-px overflow-hidden rounded-sm border border-surface-500 bg-surface-500 sm:grid-cols-3">
85 <div class="bg-surface-800 p-8 sm:p-10">
86 <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">
87 <span class="text-lg font-semibold">01</span>
88 </div>
45 - <h2 class="text-lg font-semibold text-gray-100">Code Residency</h2>
89 + <h2 class="text-lg font-semibold text-gray-100">One platform for both</h2>
90 <p class="mt-3 text-sm leading-6 text-gray-400">
47 - Instant repo creation. Public or private. No friction, just git push.
91 + Source code and open-weights models share the same git storage and the same calm UI.
92 + No second tool to learn, no weights scattered across services.
93 </p>
94 </div>
95
@@ -52,9 +97,10 @@
97 <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">
98 <span class="text-lg font-semibold">02</span>
99 </div>
55 - <h2 class="text-lg font-semibold text-gray-100">Native Inspection</h2>
100 + <h2 class="text-lg font-semibold text-gray-100">Built for big files</h2>
101 <p class="mt-3 text-sm leading-6 text-gray-400">
57 - A UI that gets out of the way. Blazing fast diffs and deep file browsing.
102 + Model cards parsed straight from your README, Git LFS for multi-gigabyte weights, and
103 + file sizes, formats, and quants surfaced at a glance.
104 </p>
105 </div>
106
@@ -62,9 +108,10 @@
108 <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">
109 <span class="text-lg font-semibold">03</span>
110 </div>
65 - <h2 class="text-lg font-semibold text-gray-100">The Dark Room</h2>
111 + <h2 class="text-lg font-semibold text-gray-100">Agent-ready</h2>
112 <p class="mt-3 text-sm leading-6 text-gray-400">
67 - A low-latency, high-contrast workspace designed for the 2 AM flow state.
113 + siGit speaks the Agent Client Protocol, so your AI coding agent can read, review, and
114 + ship straight from the editor. Low latency, high contrast, made for the 2 AM flow state.
115 </p>
116 </div>
117 </div>
app/views/repositories/_model_use.html.erb new
+34
@@ -0,0 +1,34 @@
1 +<%#
2 + Sidebar "Use this model" panel — clone/download snippets plus a
3 + library-specific load example. Locals: repository, owner, card.
4 +%>
5 +<% full = "#{owner.username}/#{repository.name}" %>
6 +<% lib = card.library_name.to_s.downcase %>
7 +
8 +<div class="card p-4">
9 + <h3 class="text-xs uppercase tracking-wider text-gray-500 font-semibold mb-3 flex items-center gap-1.5">
10 + <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>
11 + Use this model
12 + </h3>
13 +
14 + <div class="code-container p-3 space-y-0.5 mb-3">
15 + <p class="text-gray-500"># Download with the siGit CLI</p>
16 + <p class="text-gray-200">sigit download <%= full %></p>
17 + </div>
18 +
19 + <div class="code-container p-3 space-y-0.5">
20 + <% if lib.include?("gguf") || card.tags.any? { |t| t.downcase.include?("gguf") } %>
21 + <p class="text-gray-500"># Run with llama.cpp</p>
22 + <p class="text-gray-200">llama-cli -hf <%= full %></p>
23 + <% elsif lib.include?("transformers") || lib.empty? %>
24 + <p class="text-gray-500"># Load with Transformers</p>
25 + <p class="text-gray-200">from transformers import AutoModel</p>
26 + <p class="text-gray-200">m = AutoModel.from_pretrained(</p>
27 + <p class="text-gray-200">&nbsp;&nbsp;"<%= full %>",</p>
28 + <p class="text-gray-200">&nbsp;&nbsp;revision="<%= repository.default_branch %>")</p>
29 + <% else %>
30 + <p class="text-gray-500"># Clone the repository</p>
31 + <p class="text-gray-200">git clone https://sigit.si/<%= full %></p>
32 + <% end %>
33 + </div>
34 +</div>
app/views/repositories/explore.html.erb new
+76
@@ -0,0 +1,76 @@
1 +<% content_for :title, "Repositories" %>
2 +
3 +<div class="max-w-6xl mx-auto px-4 sm:px-6 py-8">
4 +
5 + <div class="flex items-end justify-between gap-4 mb-6 flex-wrap">
6 + <div>
7 + <h1 class="text-2xl font-semibold text-gray-100 flex items-center gap-2">
8 + <svg class="w-6 h-6 text-brand-500" viewBox="0 0 16 16" fill="currentColor">
9 + <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"/>
10 + </svg>
11 + Repositories
12 + <span class="badge-gray text-sm align-middle"><%= number_with_delimiter(@repositories.size) %></span>
13 + </h1>
14 + <p class="text-sm text-gray-400 mt-1">Source code hosted on siGit — browse, clone, and build on it.</p>
15 + </div>
16 + <% if signed_in? %>
17 + <%= link_to new_repository_path, class: "btn-primary text-xs py-2 px-4" do %>
18 + <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>
19 + New repository
20 + <% end %>
21 + <% end %>
22 + </div>
23 +
24 + <div class="flex items-center gap-3 mb-4">
25 + <%= form_with url: repos_path, method: :get, class: "flex-1" do %>
26 + <%= hidden_field_tag :sort, @sort %>
27 + <div class="relative">
28 + <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>
29 + <%= text_field_tag :q, @query, placeholder: "Search repositories…",
30 + class: "form-input pl-9", autocomplete: "off" %>
31 + </div>
32 + <% end %>
33 + <%= form_with url: repos_path, method: :get, class: "shrink-0" do %>
34 + <% if @query.present? %><%= hidden_field_tag :q, @query %><% end %>
35 + <%= select_tag :sort,
36 + options_for_select([["Recently updated", "recent"], ["Most stars", "stars"], ["Name", "name"]], @sort),
37 + class: "form-input py-2 pr-8 text-sm", onchange: "this.form.submit()" %>
38 + <% end %>
39 + </div>
40 +
41 + <% if @repositories.any? %>
42 + <div class="card divide-y divide-surface-600">
43 + <% @repositories.each do |repo| %>
44 + <%= link_to repository_path(repo.user.username, repo.name),
45 + class: "block px-4 py-3 hover:bg-surface-600 transition-colors" do %>
46 + <div class="flex items-center justify-between gap-3">
47 + <div class="min-w-0">
48 + <div class="flex items-center gap-2">
49 + <svg class="w-4 h-4 text-gray-500 shrink-0" viewBox="0 0 16 16" fill="currentColor">
50 + <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"/>
51 + </svg>
52 + <span class="font-medium text-gray-100 truncate"><%= repo.full_name %></span>
53 + <% if repo.is_private %><span class="badge-gray">Private</span><% end %>
54 + </div>
55 + <% if repo.description.present? %>
56 + <p class="text-xs text-gray-400 mt-1 truncate max-w-xl"><%= repo.description %></p>
57 + <% end %>
58 + <p class="text-xs text-gray-500 mt-1">Updated <%= time_ago_in_words(repo.updated_at) %> ago</p>
59 + </div>
60 + <div class="flex items-center gap-1 text-xs text-gray-500 shrink-0" title="Stars">
61 + <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>
62 + <%= number_with_delimiter(repo.stars_count) %>
63 + </div>
64 + </div>
65 + <% end %>
66 + <% end %>
67 + </div>
68 + <% else %>
69 + <div class="card px-6 py-16 text-center">
70 + <p class="text-sm text-gray-400 mb-1">No repositories found.</p>
71 + <% if signed_in? %>
72 + <p class="text-xs text-gray-500"><%= link_to "Create one", new_repository_path, class: "text-brand-500 hover:underline" %> to get started.</p>
73 + <% end %>
74 + </div>
75 + <% end %>
76 +</div>
app/views/repositories/model.html.erb new
+217
@@ -0,0 +1,217 @@
1 +<% content_for :title, @repository.full_name %>
2 +<% card = @card || @repository.model_card %>
3 +
4 +<div data-controller="tabs" data-tabs-active-value="<%= params[:tab] || 'card' %>">
5 +
6 +<%# ── Model header band ── %>
7 +<div class="border-b border-surface-600 bg-surface-800">
8 + <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
9 +
10 + <div class="flex items-start justify-between gap-4 mb-4">
11 + <div class="min-w-0">
12 + <div class="flex items-center gap-2 text-sm flex-wrap">
13 + <%# cube / weights glyph %>
14 + <svg class="w-5 h-5 text-brand-500 shrink-0" viewBox="0 0 24 24" fill="none"
15 + stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
16 + <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"/>
17 + <path d="m3.27 6.96 8.73 5.05 8.73-5.05M12 22.08V12"/>
18 + </svg>
19 + <%= link_to "@#{@owner.username}", user_profile_path(@owner.username),
20 + class: "text-brand-500 hover:underline font-medium" %>
21 + <span class="text-gray-500">/</span>
22 + <%= link_to @repository.name, repository_path(@owner.username, @repository.name),
23 + class: "text-gray-100 hover:underline font-semibold text-base" %>
24 + <% if @repository.is_private %>
25 + <span class="badge-gray ml-1">Private</span>
26 + <% end %>
27 + </div>
28 +
29 + <% if @repository.description.present? %>
30 + <p class="text-sm text-gray-400 mt-2 max-w-2xl"><%= @repository.description %></p>
31 + <% end %>
32 +
33 + <%# ── Metadata pills ── %>
34 + <div class="flex items-center gap-2 flex-wrap mt-3 text-xs">
35 + <% if (label = pipeline_label(card.pipeline_tag)) %>
36 + <%= link_to label, models_path(pipeline_tag: card.pipeline_tag),
37 + class: "badge-blue hover:bg-brand-500/20" %>
38 + <% end %>
39 + <% if card.library_name.present? %>
40 + <%= link_to models_path(library: card.library_name),
41 + class: "badge-gray hover:bg-surface-500 flex items-center gap-1" do %>
42 + <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>
43 + <%= card.library_name %>
44 + <% end %>
45 + <% end %>
46 + <% if card.license.present? %>
47 + <span class="badge-gray">License: <%= card.license %></span>
48 + <% end %>
49 + <% card.tags.first(6).each do |tag| %>
50 + <span class="badge-gray text-gray-400"><%= tag %></span>
51 + <% end %>
52 + </div>
53 + </div>
54 +
55 + <%# ── Like / download stats ── %>
56 + <div class="flex items-center gap-2 shrink-0">
57 + <%= button_to repository_star_path(@owner.username, @repository.name),
58 + method: :post,
59 + class: "btn-secondary text-xs py-1.5 px-3 #{'text-amber-300 border-amber-500/40' if @repository.starred_by?(current_user)}" do %>
60 + <svg class="w-3.5 h-3.5" viewBox="0 0 16 16"
61 + fill="<%= @repository.starred_by?(current_user) ? 'currentColor' : 'none' %>"
62 + stroke="currentColor" stroke-width="1.5">
63 + <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"/>
64 + </svg>
65 + <span>Like</span>
66 + <span class="badge-gray ml-0.5"><%= number_with_delimiter(@repository.stars_count) %></span>
67 + <% end %>
68 + </div>
69 + </div>
70 +
71 + <%# ── Tabs ── %>
72 + <div class="flex items-center gap-0 -mb-px">
73 + <button data-tabs-target="tab" data-tabs-key="card" data-action="click->tabs#switch"
74 + class="tab-item active flex items-center gap-1.5">
75 + <svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
76 + <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"/>
77 + </svg>
78 + Model card
79 + </button>
80 + <button data-tabs-target="tab" data-tabs-key="files" data-action="click->tabs#switch"
81 + class="tab-item flex items-center gap-1.5">
82 + <svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
83 + <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"/>
84 + </svg>
85 + Files and versions
86 + <% if @files&.any? %><span class="badge-gray text-xs"><%= @files.size %></span><% end %>
87 + </button>
88 + </div>
89 + </div>
90 +</div>
91 +
92 +<%# ── Body ── %>
93 +<div class="max-w-6xl mx-auto px-4 sm:px-6 py-6">
94 +
95 + <% if @empty %>
96 + <div class="card p-8 text-center max-w-2xl mx-auto">
97 + <h2 class="text-lg font-semibold text-gray-100 mb-2">This model has no files yet</h2>
98 + <p class="text-sm text-gray-400 mb-6">Push your model card and weights to get started.</p>
99 + <div class="code-container text-left p-4 max-w-lg mx-auto text-xs font-mono space-y-0.5">
100 + <p class="text-gray-500 mb-1"># Push weights with Git LFS</p>
101 + <p class="text-gray-300">git clone git@sigitsi.com:<%= @owner.username %>/<%= @repository.name %></p>
102 + <p class="text-gray-300">cd <%= @repository.name %></p>
103 + <p class="text-gray-300">git lfs install &amp;&amp; git lfs track "*.safetensors" "*.gguf"</p>
104 + <p class="text-gray-300">git add . &amp;&amp; git commit -m "Add weights"</p>
105 + <p class="text-gray-300">git push origin <%= @repository.default_branch %></p>
106 + </div>
107 + </div>
108 + <% else %>
109 + <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
110 +
111 + <%# ── Main column ── %>
112 + <div class="lg:col-span-2 min-w-0">
113 + <%# Model card panel %>
114 + <div data-tabs-target="panel" data-tabs-key="card">
115 + <% if @card_html.present? %>
116 + <div class="card">
117 + <div class="px-6 py-6 prose-readme"><%= @card_html %></div>
118 + </div>
119 + <% else %>
120 + <div class="card px-6 py-12 text-center text-sm text-gray-500">
121 + No model card yet. Add a <code>README.md</code> to describe this model.
122 + </div>
123 + <% end %>
124 + </div>
125 +
126 + <%# Files and versions panel %>
127 + <div data-tabs-target="panel" data-tabs-key="files" class="hidden">
128 + <div class="card rounded-b-none border-b-0 px-4 py-2.5 flex items-center justify-between gap-4 bg-surface-600">
129 + <span class="text-xs text-gray-400 flex items-center gap-2">
130 + <span class="btn-secondary py-1 px-2.5 text-xs font-mono cursor-default"><%= @branch %></span>
131 + <%= pluralize(@files.size, "file") %>
132 + </span>
133 + <span class="text-xs text-gray-500">
134 + Total: <span class="text-gray-300"><%= number_to_human_size(@total_size) %></span>
135 + </span>
136 + </div>
137 + <div class="card rounded-t-none">
138 + <% @files.each do |entry| %>
139 + <div class="file-row">
140 + <% if entry[:type] == "tree" %>
141 + <svg class="w-4 h-4 text-brand-400 shrink-0" viewBox="0 0 16 16" fill="currentColor">
142 + <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"/>
143 + </svg>
144 + <%= link_to entry[:name],
145 + repository_tree_path(@owner.username, @repository.name, @branch, entry[:path]),
146 + class: "text-brand-500 hover:underline font-medium flex-1" %>
147 + <% else %>
148 + <svg class="w-4 h-4 text-gray-500 shrink-0" viewBox="0 0 16 16" fill="currentColor">
149 + <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"/>
150 + </svg>
151 + <%= link_to entry[:name],
152 + repository_blob_path(@owner.username, @repository.name, @branch, entry[:path]),
153 + class: "text-gray-200 hover:text-brand-500 hover:underline flex-1 truncate" %>
154 + <div class="flex items-center gap-2 shrink-0">
155 + <% if (fmt = weight_format(entry[:name])) %>
156 + <span class="badge-blue text-[10px]"><%= fmt %></span>
157 + <% end %>
158 + <% if entry[:lfs] %>
159 + <span class="badge-gray text-[10px] text-gray-400" title="Stored with Git LFS">LFS</span>
160 + <% end %>
161 + <% if entry[:size] %>
162 + <span class="text-xs text-gray-500 font-mono w-20 text-right"><%= number_to_human_size(entry[:size]) %></span>
163 + <% end %>
164 + <%= link_to repository_blob_raw_path(@owner.username, @repository.name, @branch, entry[:path]),
165 + class: "text-gray-500 hover:text-brand-500", title: "Download" do %>
166 + <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>
167 + <% end %>
168 + </div>
169 + <% end %>
170 + </div>
171 + <% end %>
172 + </div>
173 + </div>
174 + </div>
175 +
176 + <%# ── Sidebar ── %>
177 + <aside class="lg:col-span-1 space-y-4">
178 + <%= render "repositories/model_use", repository: @repository, owner: @owner, card: card %>
179 +
180 + <div class="card p-4 space-y-3 text-sm">
181 + <h3 class="text-xs uppercase tracking-wider text-gray-500 font-semibold">Model details</h3>
182 + <% if card.base_model.present? %>
183 + <div class="flex items-center justify-between gap-2">
184 + <span class="text-gray-500">Base model</span>
185 + <span class="text-gray-300 truncate font-mono text-xs"><%= card.base_model %></span>
186 + </div>
187 + <% end %>
188 + <% if card.library_name.present? %>
189 + <div class="flex items-center justify-between"><span class="text-gray-500">Library</span><span class="text-gray-300"><%= card.library_name %></span></div>
190 + <% end %>
191 + <% if card.license.present? %>
192 + <div class="flex items-center justify-between"><span class="text-gray-500">License</span><span class="text-gray-300"><%= card.license %></span></div>
193 + <% end %>
194 + <% if card.language.any? %>
195 + <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>
196 + <% end %>
197 + <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>
198 + <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>
199 + <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>
200 + </div>
201 +
202 + <% if card.tags.any? %>
203 + <div class="card p-4">
204 + <h3 class="text-xs uppercase tracking-wider text-gray-500 font-semibold mb-3">Tags</h3>
205 + <div class="flex flex-wrap gap-1.5">
206 + <% card.tags.each do |tag| %>
207 + <span class="badge-gray text-[11px] text-gray-400"><%= tag %></span>
208 + <% end %>
209 + </div>
210 + </div>
211 + <% end %>
212 + </aside>
213 +
214 + </div>
215 + <% end %>
216 +</div>
217 +</div>
app/views/repositories/new.html.erb
+27
@@ -5,6 +5,33 @@
5 <p class="text-sm text-gray-400 mb-8">All your files and their history, in one place.</p>
6
7 <%= form_with model: @repository, url: "/new", method: :post, class: "space-y-6" do |f| %>
8 + <%# ── Repository type ── %>
9 + <div>
10 + <label class="form-label mb-2">Type</label>
11 + <div class="grid grid-cols-2 gap-3">
12 + <label class="card px-4 py-3 cursor-pointer hover:border-surface-400 flex items-start gap-3">
13 + <%= f.radio_button :kind, "code", class: "mt-0.5", checked: @repository.code? %>
14 + <div>
15 + <div class="flex items-center gap-1.5 text-sm font-medium text-gray-100">
16 + <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>
17 + Code
18 + </div>
19 + <p class="text-xs text-gray-400 mt-0.5">Source code with full git history.</p>
20 + </div>
21 + </label>
22 + <label class="card px-4 py-3 cursor-pointer hover:border-surface-400 flex items-start gap-3">
23 + <%= f.radio_button :kind, "model", class: "mt-0.5", checked: @repository.model? %>
24 + <div>
25 + <div class="flex items-center gap-1.5 text-sm font-medium text-gray-100">
26 + <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>
27 + Model
28 + </div>
29 + <p class="text-xs text-gray-400 mt-0.5">Open weights with a model card &amp; LFS files.</p>
30 + </div>
31 + </label>
32 + </div>
33 + </div>
34 +
35 <% if @repository.errors.any? %>
36 <div class="border border-red-800/40 bg-red-900/20 rounded px-4 py-3">
37 <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
@@ -7,11 +7,10 @@
7 <span class="font-semibold text-base tracking-tight">Code &amp; Deploy</span>
8 <% end %>
9
10 - <% if signed_in? %>
11 - <div class="hidden sm:flex items-center gap-1">
12 - <%= link_to "Explore", "#", class: "nav-link px-2 py-1 rounded hover:bg-surface-600" %>
13 - </div>
14 - <% end %>
10 + <div class="hidden sm:flex items-center gap-1">
11 + <%= link_to "Models", models_path, class: "nav-link px-2 py-1 rounded hover:bg-surface-600" %>
12 + <%= link_to "Repos", repos_path, class: "nav-link px-2 py-1 rounded hover:bg-surface-600" %>
13 + </div>
14 </div>
15
16 <div class="flex items-center gap-3">
app/views/users/_activity_item.html.erb
+1 -1
@@ -35,7 +35,7 @@
35 <div class="card px-4 py-3">
36 <p class="text-xs text-gray-500 mb-2"><%= time_ago_in_words(item[:at]) %> ago</p>
37 <p class="text-sm">
38 - <span class="text-gray-400">Created repository </span>
38 + <span class="text-gray-400">Created <%= item[:repo].model? ? "model" : "repository" %> </span>
39 <%= link_to "@#{profile_user.username}/#{item[:repo].name}", repository_path(profile_user.username, item[:repo].name), class: "text-brand-500 hover:underline font-medium" %>
40 </p>
41 </div>
app/views/users/show.html.erb
+86 -6
@@ -122,7 +122,19 @@
122 <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"/>
123 </svg>
124 Repositories
125 - <span class="badge-gray ml-1.5 align-middle"><%= @repositories.count %></span>
125 + <span class="badge-gray ml-1.5 align-middle"><%= @code_repositories.count %></span>
126 + </button>
127 + <button data-tabs-target="tab"
128 + data-tabs-key="models"
129 + data-action="click->tabs#switch"
130 + class="tab-item">
131 + <svg class="inline-block w-3.5 h-3.5 mr-1.5 -mt-0.5" viewBox="0 0 24 24" fill="none"
132 + stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
133 + <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"/>
134 + <path d="m3.27 6.96 8.73 5.05 8.73-5.05M12 22.08V12"/>
135 + </svg>
136 + Models
137 + <span class="badge-gray ml-1.5 align-middle"><%= @model_repositories.count %></span>
138 </button>
139 </div>
140
@@ -230,7 +242,7 @@
242 <p class="text-xs text-gray-500 mb-2">2 weeks ago</p>
243 <p class="text-sm">
244 <span class="text-gray-400">Starred </span>
233 - <%= 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" %>
245 + <%= link_to "bartowski/Qwen2.5-3B-Instruct-GGUF", repository_path("bartowski", "Qwen2.5-3B-Instruct-GGUF"), class: "text-brand-500 hover:underline font-medium" %>
246 </p>
247 </div>
248 </li>
@@ -247,21 +259,21 @@
259 <% end %>
260 </div>
261
250 - <%# ── Repositories panel ── %>
262 + <%# ── Repositories panel (code) ── %>
263 <div data-tabs-target="panel" data-tabs-key="repositories" class="hidden">
264 <div class="flex items-center justify-between mb-4">
265 <h2 class="text-base font-semibold text-gray-100">
266 Repositories
255 - <span class="badge-gray ml-1 align-middle"><%= @repositories.count %></span>
267 + <span class="badge-gray ml-1 align-middle"><%= @code_repositories.count %></span>
268 </h2>
269 <% if signed_in? && current_user == @profile_user %>
270 <%= link_to new_repository_path, class: "btn-secondary text-xs" do %>New<% end %>
271 <% end %>
272 </div>
273
262 - <% if @repositories.any? %>
274 + <% if @code_repositories.any? %>
275 <div class="space-y-3">
264 - <% @repositories.each do |repo| %>
276 + <% @code_repositories.each do |repo| %>
277 <div class="card px-5 py-4 hover:border-surface-400 transition-colors">
278 <div class="flex items-start justify-between gap-4">
279 <div class="min-w-0 flex-1">
@@ -291,6 +303,74 @@
303 <% end %>
304 </div>
305
306 + <%# ── Models panel ── %>
307 + <div data-tabs-target="panel" data-tabs-key="models" class="hidden">
308 + <div class="flex items-center justify-between mb-4">
309 + <h2 class="text-base font-semibold text-gray-100">
310 + Models
311 + <span class="badge-gray ml-1 align-middle"><%= @model_repositories.count %></span>
312 + </h2>
313 + <% if signed_in? && current_user == @profile_user %>
314 + <%= link_to new_repository_path(kind: "model"), class: "btn-secondary text-xs" do %>New<% end %>
315 + <% end %>
316 + </div>
317 +
318 + <% if @model_repositories.any? %>
319 + <div class="space-y-3">
320 + <% @model_repositories.each do |repo| %>
321 + <% card = repo.model_card %>
322 + <div class="card px-5 py-4 hover:border-surface-400 transition-colors">
323 + <div class="flex items-start justify-between gap-4">
324 + <div class="min-w-0 flex-1">
325 + <div class="flex items-center gap-2 mb-1">
326 + <svg class="w-4 h-4 text-brand-500 shrink-0" viewBox="0 0 24 24" fill="none"
327 + stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
328 + <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"/>
329 + <path d="m3.27 6.96 8.73 5.05 8.73-5.05M12 22.08V12"/>
330 + </svg>
331 + <%= link_to repo.name,
332 + repository_path(@profile_user.username, repo.name),
333 + class: "font-semibold text-brand-500 hover:underline" %>
334 + <% if repo.is_private %>
335 + <span class="badge-gray">Private</span>
336 + <% end %>
337 + </div>
338 + <div class="flex items-center gap-2 text-xs text-gray-500 flex-wrap">
339 + <% if (label = pipeline_label(card.pipeline_tag)) %>
340 + <span class="text-brand-400"><%= label %></span>
341 + <span>&middot;</span>
342 + <% end %>
343 + <% if card.library_name.present? %>
344 + <span><%= card.library_name %></span>
345 + <span>&middot;</span>
346 + <% end %>
347 + <span class="flex items-center gap-1">
348 + <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>
349 + <%= humanize_count(repo.downloads_count) %>
350 + </span>
351 + <span class="flex items-center gap-1">
352 + <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>
353 + <%= humanize_count(repo.stars_count) %>
354 + </span>
355 + </div>
356 + </div>
357 + <div class="text-xs text-gray-500 shrink-0">
358 + <%= time_ago_in_words(repo.updated_at) %> ago
359 + </div>
360 + </div>
361 + </div>
362 + <% end %>
363 + </div>
364 + <% else %>
365 + <div class="card px-6 py-12 text-center">
366 + <p class="text-sm text-gray-500 mb-1">No models yet.</p>
367 + <% if signed_in? && current_user == @profile_user %>
368 + <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>
369 + <% end %>
370 + </div>
371 + <% end %>
372 + </div>
373 +
374 </main>
375 </div>
376 </div>
config/routes.rb
+11 -1
@@ -22,6 +22,11 @@ Rails.application.routes.draw do
22 get "/auth/confirmation/resend", to: "confirmations#new", as: :resend_confirmation
23 post "/auth/confirmation/resend", to: "confirmations#create"
24
25 + # Discovery surfaces — declared before the "/:username" matcher so they
26 + # aren't read as profiles.
27 + get "/models", to: "models#index", as: :models # open-weights model library
28 + get "/repos", to: "repositories#explore", as: :repos # public code repositories
29 +
30 # Repository creation
31 get "/new", to: "repositories#new", as: :new_repository
32 post "/new", to: "repositories#create"
@@ -56,7 +61,12 @@ Rails.application.routes.draw do
61 get "/:username/:repository/raw/:branch/*path", to: "blobs#raw", as: :repository_blob_raw, format: false
62 get "/:username/:repository/tree/:branch/*path", to: "repositories#tree", as: :repository_tree
63 get "/:username/:repository/tree/:branch", to: "repositories#tree", as: :repository_branch
59 - get "/:username/:repository", to: "repositories#show", as: :repository
64 + post "/:username/:repository/star", to: "stars#create", as: :repository_star,
65 + constraints: { repository: /[^\/.][^\/]*/ }, format: false
66 + # `format: false` keeps dotted names (e.g. "Qwen2.5-3B-Instruct-GGUF") intact
67 + # rather than treating the dot as a response-format separator.
68 + get "/:username/:repository", to: "repositories#show", as: :repository,
69 + constraints: { repository: /[^\/.][^\/]*/ }, format: false
70
71 # Health check
72 get "up" => "rails/health#show", as: :rails_health_check
db/migrate/20250101000004_add_model_support_and_stars.rb new
+22
@@ -0,0 +1,22 @@
1 +class AddModelSupportAndStars < ActiveRecord::Migration[8.1]
2 + def change
3 + # A repository is either regular source code ("code") or a published set of
4 + # open weights ("model") — the Hugging Face-style model library lives on the
5 + # same git storage as everything else, distinguished only by this kind.
6 + add_column :repositories, :kind, :string, null: false, default: "code"
7 + add_index :repositories, :kind
8 +
9 + # Cached counters so the discovery index can sort/show without extra queries.
10 + add_column :repositories, :stars_count, :integer, null: false, default: 0
11 + add_column :repositories, :downloads_count, :integer, null: false, default: 0
12 +
13 + # Stars double as Hugging Face "likes" for models and GitHub-style stars for
14 + # code repos — one row per (user, repository).
15 + create_table :stars do |t|
16 + t.references :user, null: false, foreign_key: true
17 + t.references :repository, null: false, foreign_key: true
18 + t.timestamps
19 + end
20 + add_index :stars, [ :user_id, :repository_id ], unique: true
21 + end
22 +end
db/schema.rb
+17 -1
@@ -10,7 +10,7 @@
10 #
11 # It's strongly recommended that you check this file into your version control system.
12
13 -ActiveRecord::Schema[8.1].define(version: 2025_01_01_000003) do
13 +ActiveRecord::Schema[8.1].define(version: 2025_01_01_000004) do
14 # These are extensions that must be enabled in order to support this database
15 enable_extension "pg_catalog.plpgsql"
16
@@ -19,10 +19,14 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000003) do
19 t.string "default_branch", default: "main", null: false
20 t.text "description"
21 t.string "disk_path", null: false
22 + t.integer "downloads_count", default: 0, null: false
23 t.boolean "is_private", default: false, null: false
24 + t.string "kind", default: "code", null: false
25 t.string "name", null: false
26 + t.integer "stars_count", default: 0, null: false
27 t.datetime "updated_at", null: false
28 t.bigint "user_id", null: false
29 + t.index ["kind"], name: "index_repositories_on_kind"
30 t.index ["user_id", "name"], name: "index_repositories_on_user_id_and_name", unique: true
31 t.index ["user_id"], name: "index_repositories_on_user_id"
32 end
@@ -37,6 +41,16 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000003) do
41 t.index ["user_id"], name: "index_ssh_keys_on_user_id"
42 end
43
44 + create_table "stars", force: :cascade do |t|
45 + t.datetime "created_at", null: false
46 + t.bigint "repository_id", null: false
47 + t.datetime "updated_at", null: false
48 + t.bigint "user_id", null: false
49 + t.index ["repository_id"], name: "index_stars_on_repository_id"
50 + t.index ["user_id", "repository_id"], name: "index_stars_on_user_id_and_repository_id", unique: true
51 + t.index ["user_id"], name: "index_stars_on_user_id"
52 + end
53 +
54 create_table "users", force: :cascade do |t|
55 t.string "access_token"
56 t.string "avatar_url"
@@ -53,4 +67,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000003) do
67
68 add_foreign_key "repositories", "users"
69 add_foreign_key "ssh_keys", "users"
70 + add_foreign_key "stars", "repositories"
71 + add_foreign_key "stars", "users"
72 end
db/seeds.rb
+284 -6
@@ -1,9 +1,287 @@
1 # This file should ensure the existence of records required to run the application in every environment (production,
2 # development, test). The code here should be idempotent so that it can be executed at any point in every environment.
3 # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
4 -#
5 -# Example:
6 -#
7 -# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
8 -# MovieGenre.find_or_create_by!(name: genre_name)
9 -# end
4 +
5 +require "digest"
6 +require "open3"
7 +require "fileutils"
8 +require "tmpdir"
9 +
10 +# ---------------------------------------------------------------------------
11 +# Demo model library — seeds a handful of Hugging Face-style model repositories
12 +# so the /models discovery page and model pages work out of the box.
13 +# ---------------------------------------------------------------------------
14 +
15 +# A Git LFS pointer file: the tiny text stub that stands in for a large weight
16 +# file. The UI resolves the real `size` and shows an "LFS" badge.
17 +def lfs_pointer(size_bytes, seed)
18 + oid = Digest::SHA256.hexdigest("#{seed}-#{size_bytes}")
19 + "version https://git-lfs.github.com/spec/v1\noid sha256:#{oid}\nsize #{size_bytes}\n"
20 +end
21 +
22 +GB = 1024 * 1024 * 1024
23 +MB = 1024 * 1024
24 +
25 +# Pushes README + files into the model's bare repo (idempotent: skips if the
26 +# default branch already has commits).
27 +def seed_model_files(repo, card:, files:)
28 + path = repo.disk_path
29 + GitRepositoryService.create_bare_repo(repo.user.username, repo.name) unless repo.initialized?
30 + return if GitRepositoryService.branch_exists?(path, repo.default_branch)
31 +
32 + Dir.mktmpdir do |dir|
33 + system("git", "-C", dir, "init", "-b", repo.default_branch, exception: true)
34 + system("git", "-C", dir, "config", "user.email", "sigitsi@localhost", exception: true)
35 + system("git", "-C", dir, "config", "user.name", "siGit", exception: true)
36 + # The seed writes Git LFS *pointer* text directly — we do not want the local
37 + # git-lfs filter (if installed) to try to clean/upload them as real objects.
38 + # Neutralise the LFS filters so the pointer bytes are committed verbatim.
39 + %w[clean smudge].each { |f| system("git", "-C", dir, "config", "filter.lfs.#{f}", "cat", exception: true) }
40 + system("git", "-C", dir, "config", "filter.lfs.process", "", exception: true)
41 + system("git", "-C", dir, "config", "filter.lfs.required", "false", exception: true)
42 +
43 + File.write(File.join(dir, "README.md"), card)
44 + File.write(File.join(dir, ".gitattributes"),
45 + "*.gguf filter=lfs diff=lfs merge=lfs -text\n*.safetensors filter=lfs diff=lfs merge=lfs -text\n")
46 + files.each do |f|
47 + full = File.join(dir, f[:path])
48 + FileUtils.mkdir_p(File.dirname(full))
49 + File.write(full, f[:content])
50 + end
51 +
52 + system("git", "-C", dir, "add", ".", exception: true)
53 + system("git", "-C", dir, "commit", "-m", "Upload model", exception: true)
54 + system("git", "-C", dir, "remote", "add", "origin", path, exception: true)
55 + system("git", "-C", dir, "push", "origin", repo.default_branch, exception: true)
56 + end
57 +end
58 +
59 +def seed_model(username:, email:, smbcloud_id:, name:, description:, card:, files:, downloads:, likes:, updated_at: Time.current)
60 + user = User.find_or_create_by!(smbcloud_id: smbcloud_id) do |u|
61 + u.username = username
62 + u.email = email
63 + end
64 +
65 + repo = user.repositories.find_or_initialize_by(name: name)
66 + repo.assign_attributes(
67 + kind: "model",
68 + description: description,
69 + default_branch: "main",
70 + disk_path: GitRepositoryService.repo_path(username, name),
71 + downloads_count: downloads,
72 + stars_count: likes,
73 + updated_at: updated_at
74 + )
75 + repo.save!
76 +
77 + seed_model_files(repo, card: card, files: files)
78 + repo
79 +rescue => e
80 + warn " ! Skipped #{username}/#{name}: #{e.message}"
81 + nil
82 +end
83 +
84 +# ── bartowski/Qwen2.5-3B-Instruct-GGUF (the showcase model) ──
85 +qwen_card = <<~MD
86 + ---
87 + license: apache-2.0
88 + base_model: Qwen/Qwen2.5-3B-Instruct
89 + pipeline_tag: text-generation
90 + library_name: gguf
91 + language:
92 + - en
93 + tags:
94 + - text-generation
95 + - gguf
96 + - quantized
97 + - qwen2.5
98 + ---
99 +
100 + # Qwen2.5-3B-Instruct-GGUF
101 +
102 + GGUF quantizations of [Qwen/Qwen2.5-3B-Instruct](https://sigit.si/qwen/Qwen2.5-3B-Instruct),
103 + produced with `llama.cpp`. Pick a quant that fits your hardware — higher bits
104 + mean better quality, lower bits mean a smaller file and faster inference.
105 +
106 + ## Quant table
107 +
108 + | File | Quant | Size | Notes |
109 + | ---- | ----- | ---- | ----- |
110 + | `Qwen2.5-3B-Instruct-Q8_0.gguf` | Q8_0 | 3.3 GB | Near-lossless, recommended for quality |
111 + | `Qwen2.5-3B-Instruct-Q5_K_M.gguf` | Q5_K_M | 2.2 GB | Balanced quality / size |
112 + | `Qwen2.5-3B-Instruct-Q4_K_M.gguf` | Q4_K_M | 1.9 GB | Good default for most setups |
113 + | `Qwen2.5-3B-Instruct-Q3_K_M.gguf` | Q3_K_M | 1.6 GB | Smallest, some quality loss |
114 +
115 + ## Run it
116 +
117 + ```bash
118 + llama-cli -hf bartowski/Qwen2.5-3B-Instruct-GGUF -p "Hello!"
119 + ```
120 +
121 + ## Prompt format
122 +
123 + ```
124 + <|im_start|>system
125 + You are a helpful assistant.<|im_end|>
126 + <|im_start|>user
127 + {prompt}<|im_end|>
128 + <|im_start|>assistant
129 + ```
130 +MD
131 +
132 +qwen_files = [
133 + { path: "Qwen2.5-3B-Instruct-Q8_0.gguf", content: lfs_pointer((3.3 * GB).to_i, "q8") },
134 + { path: "Qwen2.5-3B-Instruct-Q5_K_M.gguf", content: lfs_pointer((2.2 * GB).to_i, "q5") },
135 + { path: "Qwen2.5-3B-Instruct-Q4_K_M.gguf", content: lfs_pointer((1.9 * GB).to_i, "q4") },
136 + { path: "Qwen2.5-3B-Instruct-Q3_K_M.gguf", content: lfs_pointer((1.6 * GB).to_i, "q3") },
137 + { path: "config.json", content: %({\n "model_type": "qwen2",\n "quantized_by": "bartowski"\n}\n) }
138 +]
139 +
140 +seed_model(
141 + username: "bartowski", email: "bartowski@example.com", smbcloud_id: 900_001,
142 + name: "Qwen2.5-3B-Instruct-GGUF",
143 + description: "GGUF quants of Qwen2.5-3B-Instruct for llama.cpp.",
144 + card: qwen_card, files: qwen_files,
145 + downloads: 1_284_530, likes: 412, updated_at: 2.weeks.ago
146 +)
147 +
148 +# ── meta-llama/Llama-3.2-1B-Instruct (safetensors / transformers) ──
149 +llama_card = <<~MD
150 + ---
151 + license: llama3.2
152 + base_model: meta-llama/Llama-3.2-1B
153 + pipeline_tag: text-generation
154 + library_name: transformers
155 + language:
156 + - en
157 + tags:
158 + - text-generation
159 + - llama
160 + - instruct
161 + ---
162 +
163 + # Llama-3.2-1B-Instruct
164 +
165 + A compact 1B instruction-tuned model. Load it with 🤗 Transformers:
166 +
167 + ```python
168 + from transformers import AutoModelForCausalLM, AutoTokenizer
169 +
170 + model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.2-1B-Instruct")
171 + tok = AutoTokenizer.from_pretrained("meta-llama/Llama-3.2-1B-Instruct")
172 + ```
173 +MD
174 +
175 +llama_files = [
176 + { path: "model.safetensors", content: lfs_pointer((2.5 * GB).to_i, "llama-st") },
177 + { path: "config.json", content: %({\n "model_type": "llama",\n "hidden_size": 2048\n}\n) },
178 + { path: "tokenizer.json", content: lfs_pointer((9 * MB).to_i, "llama-tok") },
179 + { path: "generation_config.json", content: %({\n "temperature": 0.6,\n "top_p": 0.9\n}\n) }
180 +]
181 +
182 +seed_model(
183 + username: "meta-llama", email: "meta-llama@example.com", smbcloud_id: 900_002,
184 + name: "Llama-3.2-1B-Instruct",
185 + description: "Lightweight 1B instruction-tuned text model.",
186 + card: llama_card, files: llama_files,
187 + downloads: 3_902_117, likes: 1_287, updated_at: 5.days.ago
188 +)
189 +
190 +# ── sentence-transformers/all-MiniLM-L6-v2 (embeddings) ──
191 +minilm_card = <<~MD
192 + ---
193 + license: apache-2.0
194 + pipeline_tag: sentence-similarity
195 + library_name: sentence-transformers
196 + tags:
197 + - sentence-similarity
198 + - feature-extraction
199 + - embeddings
200 + ---
201 +
202 + # all-MiniLM-L6-v2
203 +
204 + Maps sentences and paragraphs to a 384-dimensional dense vector space for
205 + semantic search, clustering, and retrieval.
206 +
207 + ```python
208 + from sentence_transformers import SentenceTransformer
209 + model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
210 + emb = model.encode(["A sentence to embed."])
211 + ```
212 +MD
213 +
214 +minilm_files = [
215 + { path: "model.safetensors", content: lfs_pointer((90 * MB).to_i, "minilm-st") },
216 + { path: "config.json", content: %({\n "model_type": "bert",\n "hidden_size": 384\n}\n) },
217 + { path: "tokenizer.json", content: lfs_pointer((700 * 1024).to_i, "minilm-tok") }
218 +]
219 +
220 +seed_model(
221 + username: "sentence-transformers", email: "st@example.com", smbcloud_id: 900_003,
222 + name: "all-MiniLM-L6-v2",
223 + description: "384-dim sentence embeddings for semantic search.",
224 + card: minilm_card, files: minilm_files,
225 + downloads: 8_450_990, likes: 2_034, updated_at: 1.month.ago
226 +)
227 +
228 +# ── A model published by the demo @sigit profile, so its Models tab has content ──
229 +# Attaches to the existing "sigit" user rather than creating one. On-brand: a
230 +# small code-completion model the platform ships itself.
231 +if (sigit = User.find_by(username: "sigit"))
232 + sigit_card = <<~MD
233 + ---
234 + license: apache-2.0
235 + base_model: Qwen/Qwen2.5-Coder-1.5B
236 + pipeline_tag: text-generation
237 + library_name: gguf
238 + language:
239 + - en
240 + tags:
241 + - text-generation
242 + - code
243 + - code-completion
244 + - gguf
245 + ---
246 +
247 + # SiGit-Coder-1.5B-GGUF
248 +
249 + A small, fast code-completion model that powers inline suggestions in the
250 + siGit Code & Deploy editor. GGUF quantizations run locally with `llama.cpp`,
251 + so completions stay on your machine.
252 +
253 + ## Quant table
254 +
255 + | File | Quant | Size | Notes |
256 + | ---- | ----- | ---- | ----- |
257 + | `SiGit-Coder-1.5B-Q8_0.gguf` | Q8_0 | 1.6 GB | Best quality |
258 + | `SiGit-Coder-1.5B-Q5_K_M.gguf` | Q5_K_M | 1.1 GB | Balanced |
259 + | `SiGit-Coder-1.5B-Q4_K_M.gguf` | Q4_K_M | 1.0 GB | Recommended default |
260 +
261 + ## Run it
262 +
263 + ```bash
264 + llama-cli -hf sigit/SiGit-Coder-1.5B-GGUF -p "def fib(n):"
265 + ```
266 + MD
267 +
268 + sigit_files = [
269 + { path: "SiGit-Coder-1.5B-Q8_0.gguf", content: lfs_pointer((1.6 * GB).to_i, "sigit-q8") },
270 + { path: "SiGit-Coder-1.5B-Q5_K_M.gguf", content: lfs_pointer((1.1 * GB).to_i, "sigit-q5") },
271 + { path: "SiGit-Coder-1.5B-Q4_K_M.gguf", content: lfs_pointer((1.0 * GB).to_i, "sigit-q4") },
272 + { path: "config.json", content: %({\n "model_type": "qwen2",\n "quantized_by": "sigit"\n}\n) }
273 + ]
274 +
275 + repo = sigit.repositories.find_or_initialize_by(name: "SiGit-Coder-1.5B-GGUF")
276 + repo.assign_attributes(
277 + kind: "model",
278 + description: "Local code-completion model behind the siGit editor's inline suggestions.",
279 + default_branch: "main",
280 + disk_path: GitRepositoryService.repo_path(sigit.username, "SiGit-Coder-1.5B-GGUF"),
281 + downloads_count: 47_213, stars_count: 96, updated_at: 4.days.ago
282 + )
283 + repo.save!
284 + seed_model_files(repo, card: sigit_card, files: sigit_files)
285 +end
286 +
287 +puts "Seeded #{Repository.models.count} model repositories."
repos/.keep