Files
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

586 lines
21 KiB
Swift

// PR — Downloads model card.
//
// Sheet that renders the upstream README for a model in the Downloads
// screen. Wired to two API surfaces:
// • HF → OMLXClient.getHFModelCard(repoId:)
// • MS → OMLXClient.getMSModelCard(modelId:)
//
// Both surface the same shape (model card + metadata; YAML front-matter
// stripped server-side, empty `model_card` when the upstream has no
// README), so a single sheet handles both — the `target`'s source enum
// just picks which client method to call and which upstream URL the
// "View on …" link points at.
//
// Rendering uses `MarkdownUI` (swift-markdown-ui) for full block support
// (headings, code blocks, tables, lists, images). Native
// `AttributedString(markdown:)` only handles inline formatting which
// would flatten the typical mlx-community README to plain text.
import SwiftUI
import AppKit
import MarkdownUI
// MARK: - Target / source
/// What the sheet is showing. Identifiable so `.sheet(item:)` can
/// drive presentation off the VM's `modelCardTarget` binding.
struct ModelCardTarget: Identifiable, Equatable, Sendable {
let repoId: String
let source: ModelCardSource
/// `.sheet(item:)` uses this to decide when to re-present. Composing
/// source into the id means switching HF↔MS for the same string
/// re-fires the sheet's `.task(id:)` and refetches.
var id: String { "\(source.rawValue):\(repoId)" }
}
enum ModelCardSource: String, Sendable, Equatable {
case huggingFace = "hf"
case modelScope = "ms"
var displayName: String {
switch self {
case .huggingFace: return "Hugging Face"
case .modelScope: return "ModelScope"
}
}
/// Canonical upstream URL for the repo — used by the "View on …"
/// footer link. Always points at the canonical origin even when the
/// user has a mirror configured for downloads; the mirror is for
/// transport, not browsing.
func upstreamURL(repoId: String) -> URL? {
switch self {
case .huggingFace: return URL(string: "https://huggingface.co/\(repoId)")
case .modelScope: return URL(string: "https://modelscope.cn/models/\(repoId)")
}
}
}
/// Tabs available inside the sheet. The Files tab is HF-only — matches
/// the HTML admin which hides it for MS (MS file lists are usually
/// opaque sharded blobs that don't help the user decide).
enum ModelCardTab: Hashable, CaseIterable {
case card, files, tags
var label: String {
switch self {
case .card:
return String(localized: "downloads.card.tab.card",
defaultValue: "Model Card",
comment: "Tab label in the model card sheet for the README content")
case .files:
return String(localized: "downloads.card.tab.files",
defaultValue: "Files",
comment: "Tab label in the model card sheet for the repo file list")
case .tags:
return String(localized: "downloads.card.tab.tags",
defaultValue: "Tags",
comment: "Tab label in the model card sheet for the repo tag list")
}
}
static func available(for source: ModelCardSource) -> [ModelCardTab] {
switch source {
case .huggingFace: return [.card, .files, .tags]
case .modelScope: return [.card, .tags]
}
}
}
// MARK: - Sheet
@MainActor
struct ModelCardSheet: View {
let target: ModelCardTarget
let client: OMLXClient
/// Triggered by the in-sheet Download button. The host wires this
/// to `vm.startDownload(repo:)` so the action plays nicely with
/// the existing source-routed downloader plumbing. Sheet dismisses
/// itself on tap so the user lands back on the row with the task
/// already moving in the Active section.
let onDownload: (String) -> Void
@State private var state: LoadState = .idle
/// Currently selected tab in the sheet. Reset to `.card` whenever
/// the target changes (different repo opened) so we never inherit
/// a stale Files/Tags selection from a previous model.
@State private var activeTab: ModelCardTab = .card
@Environment(\.dismiss) private var dismiss
@Environment(\.omlxTheme) private var theme
enum LoadState: Equatable {
case idle
case loading
case loaded(ModelCardDTO)
case failed(String)
}
var body: some View {
VStack(spacing: 0) {
header
stateRegion
}
.frame(width: 720, height: 600)
.background(theme.windowBg)
.task(id: target.id) {
// Reset tab selection on every (re)open so a tab the
// previous repo had (e.g. Files on HF) doesn't stick when
// the user opens an MS card next.
activeTab = .card
await load()
}
}
// MARK: Header
private var header: some View {
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(target.repoId)
.font(.omlxText(15, weight: .semibold))
.foregroundStyle(theme.text)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
Text(target.source.displayName)
.font(.omlxText(11, weight: .medium))
.foregroundStyle(theme.textSecondary)
.textCase(.uppercase)
.kerning(0.6)
}
Spacer(minLength: 12)
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(theme.textSecondary)
.padding(6)
.background(theme.controlBg)
.clipShape(Circle())
}
.buttonStyle(.plain)
.keyboardShortcut(.cancelAction)
.help(String(localized: "downloads.card.close",
defaultValue: "Close",
comment: "Tooltip on the model card sheet's close button"))
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
}
// MARK: State-dependent region
@ViewBuilder
private var stateRegion: some View {
switch state {
case .idle, .loading:
Divider().overlay(theme.groupBorder)
loadingView
case .failed(let message):
Divider().overlay(theme.groupBorder)
errorView(message: message)
case .loaded(let dto):
metadataRow(dto: dto)
if dto.isAdapter == true {
loraBanner
}
tabBar
Divider().overlay(theme.groupBorder)
tabBody(dto: dto)
footer(dto: dto)
}
}
// MARK: Tab bar
private var tabBar: some View {
let available = ModelCardTab.available(for: target.source)
return HStack(spacing: 6) {
ForEach(available, id: \.self) { tab in
tabButton(tab: tab, isSelected: activeTab == tab)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 20)
.padding(.bottom, 6)
}
private func tabButton(tab: ModelCardTab, isSelected: Bool) -> some View {
Button {
activeTab = tab
} label: {
Text(tab.label)
.font(.omlxText(11.5, weight: .semibold))
.foregroundStyle(isSelected ? theme.text : theme.textSecondary)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(
Capsule().fill(isSelected ? theme.controlBg : Color.clear)
)
.overlay(
Capsule().strokeBorder(isSelected ? theme.inputBorder : Color.clear,
lineWidth: 0.5)
)
}
.buttonStyle(.plain)
}
// MARK: Tab body switch
@ViewBuilder
private func tabBody(dto: ModelCardDTO) -> some View {
switch activeTab {
case .card:
cardBody(markdown: dto.modelCard)
case .files:
filesBody(files: dto.files ?? [])
case .tags:
tagsBody(tags: dto.tags ?? [])
}
}
// MARK: Metadata badges (gap #1)
@ViewBuilder
private func metadataRow(dto: ModelCardDTO) -> some View {
// Skip rendering the row entirely when none of the fields are
// populated — keeps the sheet from showing a hollow strip on
// metadata-poor repos.
let hasAnyMetadata = dto.paramsFormatted != nil
|| dto.sizeFormatted != nil
|| dto.pipelineTag != nil
|| (dto.downloads ?? 0) > 0
|| (dto.likes ?? 0) > 0
if hasAnyMetadata {
HStack(spacing: 8) {
if let p = dto.paramsFormatted, !p.isEmpty {
metaChip(text: p, accent: false)
}
if let s = dto.sizeFormatted, !s.isEmpty {
metaChip(text: s, accent: false)
}
if let tag = dto.pipelineTag, !tag.isEmpty {
metaChip(text: tag, accent: true)
}
Spacer(minLength: 6)
if let d = dto.downloads, d > 0 {
metaCounter(symbol: "arrow.down.circle", value: d)
}
if let l = dto.likes, l > 0 {
metaCounter(symbol: "heart", value: l)
}
}
.padding(.horizontal, 20)
.padding(.bottom, 12)
}
}
private func metaChip(text: String, accent: Bool) -> some View {
Text(text)
.font(.omlxText(10.5, weight: .heavy))
.kerning(0.4)
.textCase(.uppercase)
.foregroundStyle(accent ? theme.accent : theme.textSecondary)
.padding(.horizontal, 8)
.frame(height: 22)
.background(
Capsule().fill(accent ? theme.accent.opacity(0.12) : theme.codeBg)
)
.overlay(
Capsule().strokeBorder(accent ? theme.accent.opacity(0.25) : theme.inputBorder,
lineWidth: 0.5)
)
}
private func metaCounter(symbol: String, value: Int) -> some View {
HStack(spacing: 3) {
Image(systemName: symbol)
.font(.system(size: 10, weight: .medium))
Text(Self.compactCount(value))
.font(.omlxMono(11))
}
.foregroundStyle(theme.textSecondary)
}
// MARK: LoRA warning (gap #4)
private var loraBanner: some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 12))
.foregroundStyle(theme.warningText)
Text(String(localized: "downloads.card.lora_warning",
defaultValue: "This is a LoRA adapter. It needs a compatible base model to run — downloading on its own won't load in oMLX.",
comment: "Warning banner shown in the model card sheet when the repo is an adapter rather than a full model"))
.font(.omlxText(11))
.foregroundStyle(theme.text)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(theme.warningBg)
.overlay(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.strokeBorder(theme.warningText.opacity(0.25), lineWidth: 0.5)
)
.padding(.horizontal, 20)
.padding(.bottom, 10)
}
// MARK: Card body
@ViewBuilder
private func cardBody(markdown raw: String) -> some View {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
VStack(spacing: 10) {
Image(systemName: "doc.text.magnifyingglass")
.font(.system(size: 32, weight: .light))
.foregroundStyle(theme.textTertiary)
Text(String(localized: "downloads.card.empty",
defaultValue: "This model doesn't ship a README.",
comment: "Empty state shown when the upstream repo has no model card"))
.font(.omlxText(13))
.foregroundStyle(theme.textSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(24)
} else {
ScrollView {
Markdown(trimmed)
.markdownTheme(.docC)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(20)
.textSelection(.enabled)
}
}
}
// MARK: Files tab (gap #5)
@ViewBuilder
private func filesBody(files: [ModelCardFile]) -> some View {
if files.isEmpty {
emptyStateView(
symbol: "doc.text.below.ecg",
message: String(localized: "downloads.card.files.empty",
defaultValue: "No files reported.",
comment: "Empty state in the Files tab when the upstream API doesn't list any files")
)
} else {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(files.enumerated()), id: \.element.id) { idx, file in
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(file.name)
.font(.omlxMono(12))
.foregroundStyle(theme.text)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 8)
if let s = file.sizeFormatted, !s.isEmpty {
Text(s)
.font(.omlxMono(11))
.foregroundStyle(theme.textSecondary)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 8)
if idx < files.count - 1 {
Divider().opacity(0.4)
}
}
}
.padding(.vertical, 4)
}
}
}
// MARK: Tags tab (gap #6)
@ViewBuilder
private func tagsBody(tags: [String]) -> some View {
if tags.isEmpty {
emptyStateView(
symbol: "tag",
message: String(localized: "downloads.card.tags.empty",
defaultValue: "No tags.",
comment: "Empty state in the Tags tab when the upstream API doesn't list any tags")
)
} else {
ScrollView {
FlowLayout(spacing: 6) {
ForEach(tags, id: \.self) { tag in
tagPill(tag)
}
}
.padding(20)
}
}
}
private func tagPill(_ tag: String) -> some View {
Text(tag)
.font(.omlxMono(11))
.foregroundStyle(theme.textSecondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule().fill(theme.codeBg)
)
.overlay(
Capsule().strokeBorder(theme.inputBorder, lineWidth: 0.5)
)
.textSelection(.enabled)
}
private func emptyStateView(symbol: String, message: String) -> some View {
VStack(spacing: 10) {
Image(systemName: symbol)
.font(.system(size: 28, weight: .light))
.foregroundStyle(theme.textTertiary)
Text(message)
.font(.omlxText(13))
.foregroundStyle(theme.textSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(24)
}
// MARK: Footer (gaps #2 + #3)
@ViewBuilder
private func footer(dto: ModelCardDTO) -> some View {
Divider().overlay(theme.groupBorder)
HStack(spacing: 10) {
if let url = target.source.upstreamURL(repoId: target.repoId) {
Button {
NSWorkspace.shared.open(url)
} label: {
Label {
Text(viewUpstreamLabel)
} icon: {
Image(systemName: "arrow.up.right.square")
.font(.system(size: 11))
}
.font(.omlxText(11.5, weight: .medium))
.foregroundStyle(theme.textSecondary)
}
.buttonStyle(.plain)
}
Spacer()
if dto.isAdapter != true {
// Adapters can't be downloaded as a runnable model;
// hide the action entirely so the user doesn't kick
// off a download that won't load.
Button {
onDownload(target.repoId)
dismiss()
} label: {
Label(String(localized: "downloads.card.download",
defaultValue: "Download",
comment: "Primary button in the model card sheet that starts downloading the displayed model"),
systemImage: "icloud.and.arrow.down")
.labelStyle(.titleAndIcon)
}
.buttonStyle(.omlx(.primary))
.keyboardShortcut(.defaultAction)
}
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
}
/// Source-specific label for the "View on …" link. Localized via
/// inline interpolation of the source's display name.
private var viewUpstreamLabel: String {
String(localized: "downloads.card.view_upstream",
defaultValue: "View on \(target.source.displayName)",
comment: "Link button in the model card sheet that opens the upstream model page; placeholder is 'Hugging Face' or 'ModelScope'")
}
// MARK: Error / loading
private var loadingView: some View {
VStack(spacing: 10) {
ProgressView()
Text(String(localized: "downloads.card.loading",
defaultValue: "Loading model card…",
comment: "Status text shown while the model README is being fetched"))
.font(.omlxText(11))
.foregroundStyle(theme.textSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func errorView(message: String) -> some View {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 32, weight: .light))
.foregroundStyle(theme.redDot)
Text(String(localized: "downloads.card.error",
defaultValue: "Couldn't load model card",
comment: "Title shown in the model card sheet when the fetch failed"))
.font(.omlxText(13, weight: .semibold))
.foregroundStyle(theme.text)
Text(message)
.font(.omlxText(11))
.foregroundStyle(theme.textSecondary)
.multilineTextAlignment(.center)
.textSelection(.enabled)
Button(String(localized: "downloads.card.retry",
defaultValue: "Retry",
comment: "Retry button shown after the model card fetch failed")) {
Task { await load() }
}
.buttonStyle(.omlx(.primary))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(24)
}
// MARK: Fetch
private func load() async {
state = .loading
do {
let dto: ModelCardDTO
switch target.source {
case .huggingFace:
dto = try await client.getHFModelCard(repoId: target.repoId)
case .modelScope:
dto = try await client.getMSModelCard(modelId: target.repoId)
}
state = .loaded(dto)
} catch {
state = .failed(error.omlxDescription)
}
}
// MARK: Helpers
/// Compact-format a count for the metadata counters: 1234 → "1.2K",
/// 1_500_000 → "1.5M". Mirrors the HTML admin's display so the same
/// repos show the same numbers on both surfaces.
private static func compactCount(_ n: Int) -> String {
switch n {
case 1_000_000_000...:
return String(format: "%.1fB", Double(n) / 1_000_000_000)
case 1_000_000...:
return String(format: "%.1fM", Double(n) / 1_000_000)
case 1_000...:
return String(format: "%.1fK", Double(n) / 1_000)
default:
return String(n)
}
}
}
// `FlowLayout` (used by the Tags tab) lives in ProfileViews.swift —
// reused here for consistency. If it ever needs to specialize for the
// model-card sheet, prefer extending the shared type over forking.