SPB Git

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%
11.0 KB · 307 lines swift
Raw Blame History
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