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>·</span>
111
+ <% end %>
112
+ <% if card.library_name.present? %>
113
+ <span><%= card.library_name %></span>
114
+ <span>·</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"> "<%= full %>",</p>
28
+ <p class="text-gray-200"> 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 && git lfs track "*.safetensors" "*.gguf"</p>
104
+ <p class="text-gray-300">git add . && 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 & 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 & 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>·</span>
342
+ <% end %>
343
+ <% if card.library_name.present? %>
344
+ <span><%= card.library_name %></span>
345
+ <span>·</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."