spb/zyquo-local Public MIT
Native macOS AI chat that runs LLMs 100% locally on Apple Silicon with MLX — no cloud, no API keys.
Swift 97.2%
Shell 1.8%
Makefile 1%
1//2// ModelCards.swift3// Zyquo Local4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import SwiftUI1011/// Shared download-state footer: Download button that flips into a live12/// progress card (bar, MB/s, ETA, pause/cancel) and into Installed state.13struct DownloadStateFooter: View {14 let repoID: String15 /// Known download size (from catalog); live search cards fetch on demand.16 var sizeHint: String?17 @Environment(AppModel.self) private var app1819 private var installed: Bool { app.store.model(for: repoID) != nil }20 private var task: DownloadTask? { app.downloads.task(for: repoID) }2122 var body: some View {23 if installed {24 HStack(spacing: ZyquoTheme.Spacing.xs) {25 Label("Installed", systemImage: "checkmark.circle.fill")26 .font(ZyquoTheme.caption.weight(.medium))27 .foregroundStyle(ZyquoTheme.success)28 Spacer()29 Button("Load") {30 Task { await app.loadModel(repoID: repoID) }31 }32 .buttonStyle(.bordered)33 .controlSize(.small)34 }35 } else if let task, task.state == .downloading || task.state == .verifying || task.state == .queued {36 VStack(spacing: 4) {37 ProgressView(value: task.fractionCompleted)38 .tint(ZyquoTheme.accent)39 .animation(.linear(duration: 0.3), value: task.fractionCompleted)40 HStack(spacing: ZyquoTheme.Spacing.s) {41 Text("\(formatBytes(task.receivedBytes)) / \(formatBytes(task.totalBytes))")42 if let speed = app.downloads.speeds[repoID] {43 Text(formatSpeed(speed))44 }45 if let eta = app.downloads.eta(for: repoID) {46 Text(formatETA(eta))47 }48 Spacer()49 Button {50 app.downloads.pause(repoID: repoID)51 } label: {52 Image(systemName: "pause.fill")53 }54 .buttonStyle(.plain)55 .help("Pause")56 Button {57 app.downloads.cancel(repoID: repoID)58 } label: {59 Image(systemName: "xmark")60 }61 .buttonStyle(.plain)62 .help("Cancel and remove partial files")63 }64 .font(ZyquoTheme.caption.monospacedDigit())65 .foregroundStyle(ZyquoTheme.textTertiary)66 }67 } else if let task, task.state == .paused {68 HStack {69 Text("Paused at \(Int(task.fractionCompleted * 100))%")70 .font(ZyquoTheme.caption)71 .foregroundStyle(ZyquoTheme.textSecondary)72 Spacer()73 Button("Resume") { app.downloads.resume(repoID: repoID) }74 .buttonStyle(.borderedProminent)75 .tint(ZyquoTheme.accent)76 .controlSize(.small)77 Button("Cancel") { app.downloads.cancel(repoID: repoID) }78 .buttonStyle(.bordered)79 .controlSize(.small)80 }81 } else if let task, task.state == .failed {82 HStack {83 Text(task.errorDescription ?? "Download failed")84 .font(ZyquoTheme.caption)85 .foregroundStyle(ZyquoTheme.danger)86 .lineLimit(2)87 Spacer()88 Button("Retry") { app.downloads.resume(repoID: repoID) }89 .buttonStyle(.bordered)90 .controlSize(.small)91 }92 } else {93 HStack {94 if let sizeHint {95 Text(sizeHint)96 .font(ZyquoTheme.caption)97 .foregroundStyle(ZyquoTheme.textTertiary)98 }99 Spacer()100 Button {101 Task { await app.downloads.download(repoID: repoID) }102 } label: {103 Label("Download", systemImage: "arrow.down.circle")104 .font(ZyquoTheme.bodyEmphasis)105 }106 .buttonStyle(.borderedProminent)107 .tint(ZyquoTheme.accent)108 .controlSize(.small)109 }110 }111 }112}113114/// One curated catalog entry, editorial style.115struct FeaturedModelCard: View {116 let model: CatalogModel117 @Environment(AppModel.self) private var app118119 var body: some View {120 VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) {121 HStack(spacing: ZyquoTheme.Spacing.xs) {122 Text(shortModelName(model.repoID))123 .font(ZyquoTheme.bodyEmphasis)124 .foregroundStyle(ZyquoTheme.textPrimary)125 InfoBadge(text: model.params)126 InfoBadge(text: model.quant)127 Spacer()128 VerdictBadge(verdict: model.verdict)129 }130 Text(model.blurb)131 .font(ZyquoTheme.body)132 .foregroundStyle(ZyquoTheme.textSecondary)133 DownloadStateFooter(134 repoID: model.repoID,135 sizeHint: String(format: "%.1f GB download · needs %d GB+ Mac", model.sizeGB, model.minRAMGB)136 )137 }138 .padding(ZyquoTheme.Spacing.m)139 .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m))140 .overlay(141 RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)142 .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)143 )144 }145}146147/// One live Hub search result.148struct HubModelCard: View {149 let summary: HubService.ModelSummary150 @Environment(AppModel.self) private var app151 @State private var totalBytes: Int64?152153 var body: some View {154 VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) {155 HStack(spacing: ZyquoTheme.Spacing.xs) {156 VStack(alignment: .leading, spacing: 1) {157 Text(shortModelName(summary.id))158 .font(ZyquoTheme.bodyEmphasis)159 .foregroundStyle(ZyquoTheme.textPrimary)160 .lineLimit(1)161 Text(summary.id.split(separator: "/").first.map(String.init) ?? "")162 .font(ZyquoTheme.caption)163 .foregroundStyle(ZyquoTheme.textTertiary)164 }165 Spacer()166 if let downloads = summary.downloads {167 Label("\(downloads.formatted(.number.notation(.compactName)))", systemImage: "arrow.down.circle")168 .font(ZyquoTheme.caption)169 .foregroundStyle(ZyquoTheme.textTertiary)170 }171 if summary.isGated {172 Label("Gated", systemImage: "lock")173 .font(ZyquoTheme.caption)174 .foregroundStyle(ZyquoTheme.warning)175 .help("Requires an approved Hugging Face token (Settings ▸ Models & Storage)")176 }177 if !summary.isSupportedArchitecture {178 Label("Unsupported", systemImage: "exclamationmark.triangle")179 .font(ZyquoTheme.caption)180 .foregroundStyle(ZyquoTheme.danger)181 .help("Architecture “\(summary.architecture ?? "?")” is not supported by the MLX engine")182 }183 if let totalBytes {184 VerdictBadge(verdict: MemoryAdvisor.verdict(weightsBytes: totalBytes))185 }186 }187 DownloadStateFooter(repoID: summary.id, sizeHint: totalBytes.map(formatBytes))188 }189 .padding(ZyquoTheme.Spacing.m)190 .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m))191 .overlay(192 RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)193 .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)194 )195 .task(id: summary.id) {196 // Fetch true download size lazily per card.197 guard totalBytes == nil else { return }198 let hub = HubService(token: app.settings.hfToken.isEmpty ? nil : app.settings.hfToken)199 totalBytes = try? await hub.requiredFiles(of: summary.id).totalBytes200 }201 }202}203204/// Bottom drawer listing all active/queued/paused downloads.205struct DownloadsDrawer: View {206 @Environment(AppModel.self) private var app207208 var body: some View {209 VStack(spacing: 0) {210 Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline)211 ScrollView(.horizontal, showsIndicators: false) {212 HStack(spacing: ZyquoTheme.Spacing.s) {213 ForEach(app.downloads.tasks.filter { $0.state != .completed && $0.state != .cancelled }) { task in214 HStack(spacing: ZyquoTheme.Spacing.xs) {215 VStack(alignment: .leading, spacing: 2) {216 Text(shortModelName(task.repoID))217 .font(ZyquoTheme.caption.weight(.medium))218 .lineLimit(1)219 ProgressView(value: task.fractionCompleted)220 .tint(ZyquoTheme.accent)221 .frame(width: 140)222 }223 switch task.state {224 case .downloading, .queued, .verifying:225 Button {226 app.downloads.pause(repoID: task.repoID)227 } label: {228 Image(systemName: "pause.fill").font(.system(size: 10))229 }230 .buttonStyle(.plain)231 case .paused, .failed:232 Button {233 app.downloads.resume(repoID: task.repoID)234 } label: {235 Image(systemName: "play.fill").font(.system(size: 10))236 }237 .buttonStyle(.plain)238 default:239 EmptyView()240 }241 Button {242 app.downloads.cancel(repoID: task.repoID)243 } label: {244 Image(systemName: "xmark").font(.system(size: 10))245 }246 .buttonStyle(.plain)247 }248 .padding(ZyquoTheme.Spacing.xs)249 .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s))250 .overlay(251 RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)252 .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)253 )254 }255 }256 .padding(ZyquoTheme.Spacing.s)257 }258 }259 .background(ZyquoTheme.background)260 }261}262