spb/zyquo-mlx Public MIT
The local MLX foundry for your Mac — run, fine-tune, quantize, and ship models. Nothing leaves your machine.
Swift 93.4%
Python 3.8%
Makefile 2.2%
Shell 0.5%
1//2// ConvertView.swift3// Zyquo MLX4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import SwiftUI1011/// Convert section: quantize, fuse adapters, and import HF repos — every job12/// tracked with progress and a before/after size story.13struct ConvertView: View {14 @Bindable var model: AppModel15 @State private var controller = ConvertController()1617 var body: some View {18 ScrollView {19 VStack(alignment: .leading, spacing: ZyquoTheme.spacing16) {20 QuantizeCard(model: model, controller: controller)21 FuseCard(model: model, controller: controller)22 ImportCard(controller: controller)2324 if !controller.jobs.isEmpty {25 Text("Jobs")26 .font(ZyquoTheme.headlineFont)27 ForEach(controller.jobs.reversed()) { job in28 ConvertJobRow(job: job)29 }30 }31 }32 .padding(ZyquoTheme.spacing20)33 }34 .onChange(of: controller.finishedCount) {35 Task { await model.refresh() }36 }37 }38}3940// MARK: - Controller4142struct ConvertJob: Identifiable {43 let id = UUID()44 var title: String45 var stage: String46 var fraction: Double?47 var finished = false48 var error: String?49}5051@Observable52@MainActor53final class ConvertController {54 var jobs: [ConvertJob] = []55 var finishedCount = 05657 func track(title: String, events: AsyncStream<ConversionEvent>) {58 var job = ConvertJob(title: title, stage: "starting")59 jobs.append(job)60 let index = jobs.count - 161 Task {62 for await event in events {63 switch event {64 case .stage(let stage, let fraction):65 job.stage = stage66 job.fraction = fraction67 case .finished(let output):68 job.stage = "done → \(output.lastPathComponent)"69 job.finished = true70 finishedCount += 171 case .failed(let message):72 job.error = message73 job.finished = true74 }75 jobs[index] = job76 }77 }78 }79}8081private struct ConvertJobRow: View {82 let job: ConvertJob8384 var body: some View {85 HStack(spacing: ZyquoTheme.spacing12) {86 if job.error != nil {87 Image(systemName: "xmark.circle.fill").foregroundStyle(ZyquoTheme.danger)88 } else if job.finished {89 Image(systemName: "checkmark.circle.fill").foregroundStyle(ZyquoTheme.success)90 } else {91 ProgressView().controlSize(.small)92 }93 VStack(alignment: .leading, spacing: ZyquoTheme.spacing2) {94 Text(job.title)95 .font(ZyquoTheme.bodyFont.weight(.medium))96 Text(job.error ?? job.stage)97 .font(ZyquoTheme.captionFont)98 .foregroundStyle(job.error != nil ? ZyquoTheme.danger : ZyquoTheme.textSecondary)99 .lineLimit(2)100 }101 Spacer()102 if let fraction = job.fraction, !job.finished {103 ProgressView(value: fraction).frame(width: 100)104 }105 }106 .padding(ZyquoTheme.spacing12)107 .zyquoCard()108 }109}110111// MARK: - Quantize112113private struct QuantizeCard: View {114 @Bindable var model: AppModel115 let controller: ConvertController116117 @State private var modelID: String?118 @State private var config = QuantConfig()119120 private var source: LocalModel? { model.models.first { $0.id == modelID } }121 private var candidates: [LocalModel] {122 model.models.filter { $0.type == .llm && $0.quantization == nil }123 }124125 var body: some View {126 VStack(alignment: .leading, spacing: ZyquoTheme.spacing12) {127 Label("Quantize", systemImage: "square.resize.down")128 .font(ZyquoTheme.headlineFont)129 Text("Shrink an fp16/bf16 model with affine quantization — runs natively in Swift.")130 .font(ZyquoTheme.captionFont)131 .foregroundStyle(ZyquoTheme.textSecondary)132133 HStack(spacing: ZyquoTheme.spacing12) {134 Picker("Model", selection: $modelID) {135 Text(candidates.isEmpty ? "No unquantized models" : "Choose…").tag(String?.none)136 ForEach(candidates) { Text($0.name).tag(String?.some($0.id)) }137 }138 .frame(maxWidth: 280)139140 Picker("Bits", selection: $config.bits) {141 ForEach(QuantConfig.affineBits, id: \.self) { Text("\($0)-bit") }142 }143 .frame(width: 110)144145 Picker("Group", selection: $config.groupSize) {146 ForEach(QuantConfig.affineGroupSizes, id: \.self) { Text("g\($0)") }147 }148 .frame(width: 90)149150 if let source, let params = source.parameterCount {151 Text("\(ByteCountFormatter.string(fromByteCount: source.weightsSize, countStyle: .file)) → ~\(ByteCountFormatter.string(fromByteCount: config.predictedWeightBytes(parameterCount: params), countStyle: .file))")152 .font(ZyquoTheme.monoSmallFont)153 .foregroundStyle(ZyquoTheme.chartThroughput)154 }155156 Spacer()157158 Button("Quantize") { run() }159 .buttonStyle(.borderedProminent)160 .tint(ZyquoTheme.accent)161 .disabled(source == nil)162 }163 }164 .padding(ZyquoTheme.spacing16)165 .frame(maxWidth: .infinity, alignment: .leading)166 .zyquoCard()167 }168169 private func run() {170 guard let source else { return }171 let output = "\(source.name)-\(config.bits)bit"172 Task {173 let events = await ConversionService.shared.quantize(174 model: source, config: config, outputName: output)175 controller.track(title: "Quantize \(source.name) → \(config.label)", events: events)176 }177 }178}179180// MARK: - Fuse181182private struct FuseCard: View {183 @Bindable var model: AppModel184 let controller: ConvertController185186 @State private var runID: UUID?187 @State private var dequantize = true188189 private var completedRuns: [TrainingRun] {190 model.runs.filter { $0.state == .completed || !$0.checkpoints.isEmpty }191 }192 private var selectedRun: TrainingRun? { model.runs.first { $0.id == runID } }193 private var baseModel: LocalModel? {194 selectedRun.flatMap { run in model.models.first { $0.id == run.baseModelID } }195 }196197 var body: some View {198 VStack(alignment: .leading, spacing: ZyquoTheme.spacing12) {199 Label("Fuse Adapter", systemImage: "arrow.triangle.merge")200 .font(ZyquoTheme.headlineFont)201 Text("Merge a trained adapter into its base to make a standalone model. On quantized bases, de-quantizing preserves small adapters (re-quantization rounds them away).")202 .font(ZyquoTheme.captionFont)203 .foregroundStyle(ZyquoTheme.textSecondary)204205 HStack(spacing: ZyquoTheme.spacing12) {206 Picker("Run", selection: $runID) {207 Text(completedRuns.isEmpty ? "No completed runs" : "Choose…").tag(UUID?.none)208 ForEach(completedRuns) { Text($0.name).tag(UUID?.some($0.id)) }209 }210 .frame(maxWidth: 340)211212 Toggle("De-quantize", isOn: $dequantize)213 .help("Recommended for quantized bases — keeps the adapter's effect intact.")214215 Spacer()216217 Button("Fuse") { run() }218 .buttonStyle(.borderedProminent)219 .tint(ZyquoTheme.accent)220 .disabled(selectedRun == nil || baseModel == nil)221 }222 }223 .padding(ZyquoTheme.spacing16)224 .frame(maxWidth: .infinity, alignment: .leading)225 .zyquoCard()226 }227228 private func run() {229 guard let selectedRun, let baseModel else { return }230 Task {231 do {232 let adapters = await RunStore.shared.adaptersDirectory(for: selectedRun)233 let output = "\(baseModel.name)-fused-\(selectedRun.id.uuidString.prefix(6))"234 let events = try await ConversionService.shared.fuse(235 baseModel: baseModel, adapterDirectory: adapters,236 outputName: output, dequantize: dequantize)237 controller.track(title: "Fuse \(selectedRun.name)", events: events)238 } catch {239 controller.jobs.append(ConvertJob(240 title: "Fuse \(selectedRun.name)", stage: "", fraction: nil,241 finished: true, error: error.localizedDescription))242 }243 }244 }245}246247// MARK: - Import from Hugging Face248249private struct ImportCard: View {250 let controller: ConvertController251252 @State private var repoID = ""253 @State private var quantize = true254 @State private var config = QuantConfig()255256 var body: some View {257 VStack(alignment: .leading, spacing: ZyquoTheme.spacing12) {258 Label("Convert from Hugging Face", systemImage: "arrow.down.doc")259 .font(ZyquoTheme.headlineFont)260 Text("Convert any Hugging Face checkpoint to MLX format (handles PyTorch .bin too), optionally quantizing on the way.")261 .font(ZyquoTheme.captionFont)262 .foregroundStyle(ZyquoTheme.textSecondary)263264 HStack(spacing: ZyquoTheme.spacing12) {265 TextField("org/repo (e.g. Qwen/Qwen3-0.6B)", text: $repoID)266 .textFieldStyle(.roundedBorder)267 .frame(maxWidth: 340)268269 Toggle("Quantize", isOn: $quantize)270 if quantize {271 Picker("Bits", selection: $config.bits) {272 ForEach(QuantConfig.affineBits, id: \.self) { Text("\($0)-bit") }273 }274 .frame(width: 110)275 }276277 Spacer()278279 Button("Convert") { run() }280 .buttonStyle(.borderedProminent)281 .tint(ZyquoTheme.accent)282 .disabled(repoID.isEmpty)283 }284 }285 .padding(ZyquoTheme.spacing16)286 .frame(maxWidth: .infinity, alignment: .leading)287 .zyquoCard()288 }289290 private func run() {291 let repo = repoID.trimmingCharacters(in: .whitespaces)292 let output = repo.replacingOccurrences(of: "/", with: "--")293 + (quantize ? "-\(config.bits)bit" : "-mlx")294 Task {295 do {296 let events = try await ConversionService.shared.convert(297 hfPath: repo, outputName: output, quantize: quantize ? config : nil)298 controller.track(title: "Convert \(repo)", events: events)299 } catch {300 controller.jobs.append(ConvertJob(301 title: "Convert \(repo)", stage: "", fraction: nil,302 finished: true, error: error.localizedDescription))303 }304 }305 }306}307