// PR 12 — Quantization screen (oQ universal dynamic quantization). // // Mirrors the "Quantizer" tab from the HTML admin panel // (omlx/admin/templates/dashboard/_models.html:1025-1280 + dashboard.js:3437- // 3680). Wires the /admin/api/oq/* endpoints — list / estimate / start / // tasks / cancel / remove — onto a stack of sections: // // Source Model section — model picker, sensitivity picker (conditional // on the source model offering candidates), oQ // level picker, Start button, status banner. // // Estimate strip — memory / effective bpw / output size pills // (live from /api/oq/estimate, debounced at // 300 ms to match the JS dashboard). // // Advanced settings — collapsible block with text-only toggle (VLM // only), preserve-MTP toggle (only when the // source model exposes MTP heads), and the // non-quant dtype segmented control. // // Queue — every task `_oq_manager` returns. Polls at 2 Hz // while any task is active, idles otherwise. // Completed quant tasks expose an "Upload to HF" // button that opens the upload sheet. // // Upload sheet — credentials + repo + README configuration, then // POST /admin/api/upload/start. The HF token // lives only in the macOS Keychain (service // "app.omlx.hf-upload"); never persisted to // UserDefaults or files. // // Upload Tasks — mirror of the Queue section for upload jobs. // Polled in the same iteration as quant tasks // (2 s while either side is active, 6 s idle). // // About — static documentation card (matches the marketing // copy in the HTML so users get the same context // in either UI). import SwiftUI import Security struct QuantizationScreen: View { @Environment(AppServices.self) private var services @State private var vm = QuantizationScreenVM() var body: some View { VStack(alignment: .leading, spacing: 0) { ScreenHeader( eyebrow: String(localized: "quant.header.eyebrow", defaultValue: "oQ Quantization", comment: "Eyebrow text above the Quantization screen header"), title: String(localized: "quant.header.title", defaultValue: "Quantize on device", comment: "Main title for the Quantization screen header"), subtitle: String(localized: "quant.header.subtitle", defaultValue: "Pick a full-precision model, choose an oQ level, and oMLX builds a mixed-precision plan tuned to that model's per-layer sensitivity. Output is standard mlx-lm safetensors — usable in any MLX runtime.", comment: "Subtitle paragraph describing what the Quantization screen does") ) SourceModelSection( models: vm.models, sensitivityCandidates: vm.sensitivityCandidates, selectedModelPath: $vm.selectedModelPath, sensitivityModelPath: $vm.sensitivityModelPath, oqLevel: $vm.oqLevel, isStarting: vm.isStarting, modelsLoaded: vm.modelsLoaded, onStart: { vm.startQuantization(client: services.client) } ) if vm.selectedModelPath.isEmpty == false { EstimateStrip( memoryText: vm.memoryText, bpwText: vm.bpwText, outputSizeText: vm.outputSizeText ) } AdvancedSection( isOpen: $vm.advancedOpen, selectedIsVLM: vm.selectedIsVLM, selectedHasMTP: vm.selectedHasMTP, textOnly: $vm.textOnly, preserveMtp: $vm.preserveMtp, dtype: $vm.dtype ) MessageBanner(error: vm.lastError, success: vm.lastSuccess) if vm.modelsLoaded && vm.models.isEmpty { EmptyModelsBanner() } QueueSection( tasks: vm.tasks, onCancel: { id in vm.cancelTask(taskId: id, client: services.client) }, onRemove: { id in vm.removeTask(taskId: id, client: services.client) }, onUpload: { task in vm.uploadTarget = task } ) UploadTasksSection( tasks: vm.uploadTasks, onCancel: { id in vm.cancelUpload(taskId: id, client: services.client) }, onRemove: { id in vm.removeUpload(taskId: id, client: services.client) } ) AboutSection() } .task { await vm.start(client: services.client) } .onDisappear { vm.stop() } .onChange(of: vm.selectedModelPath) { _, _ in // Sensitivity choice is per-source-model; reset when source changes // so the dropdown can't dangle at a stale path. vm.sensitivityModelPath = "" vm.scheduleEstimateRefresh(client: services.client) } .onChange(of: vm.oqLevel) { _, _ in vm.scheduleEstimateRefresh(client: services.client) } .onChange(of: vm.preserveMtp) { _, _ in vm.scheduleEstimateRefresh(client: services.client) } .sheet(item: $vm.uploadTarget) { task in UploadModalView(task: task, vm: vm, client: services.client) } } } // MARK: - Source model + start private struct SourceModelSection: View { let models: [OQModelInfo] let sensitivityCandidates: [OQModelInfo] @Binding var selectedModelPath: String @Binding var sensitivityModelPath: String @Binding var oqLevel: Double let isStarting: Bool let modelsLoaded: Bool let onStart: () -> Void var body: some View { SectionHeader( String(localized: "quant.source.title", defaultValue: "Source Model", comment: "Section heading for the source-model picker on the Quantization screen"), subtitle: modelsLoaded ? String(localized: "quant.source.subtitle.available", defaultValue: "Full-precision models available: \(models.count)", comment: "Subtitle for Source Model section. Placeholder is the full-precision model count") : String(localized: "quant.source.subtitle.loading", defaultValue: "Loading…", comment: "Subtitle while the source model list is loading") ) ListGroup { Row( label: String(localized: "quant.source.row.source.label", defaultValue: "Source", comment: "Row label for the source-model picker"), sublabel: String(localized: "quant.source.row.source.sub", defaultValue: "Only full-precision models can be quantized", comment: "Row sublabel explaining the source-model picker constraint") ) { Popup( selection: $selectedModelPath, width: 320, options: modelOptions ) } if !sensitivityCandidates.isEmpty && !selectedModelPath.isEmpty { Row( label: String(localized: "quant.source.row.sensitivity.label", defaultValue: "Sensitivity model", comment: "Row label for the optional sensitivity-model picker"), sublabel: String(localized: "quant.source.row.sensitivity.sub", defaultValue: "Use a quantized variant to analyze layer sensitivity with ~4× less memory", comment: "Row sublabel for the sensitivity-model picker") ) { Popup( selection: $sensitivityModelPath, width: 320, options: sensitivityOptions ) } } Row(label: String(localized: "quant.source.row.level.label", defaultValue: "oQ level", comment: "Row label for the oQ level picker"), sublabel: String(localized: "quant.source.row.level.sub", defaultValue: "Lower bits = smaller, faster, less accurate", comment: "Row sublabel explaining the oQ level tradeoff")) { Popup( selection: $oqLevel, width: 120, options: Self.levelOptions ) } Row(isLast: true) { HStack { Spacer() Button { onStart() } label: { if isStarting { ProgressView() .controlSize(.small) .padding(.trailing, 2) Text(String(localized: "quant.button.starting", defaultValue: "Starting…", comment: "Button label shown while a quantization start request is in flight")) } else { Label(String(localized: "quant.button.start", defaultValue: "Start Quantization", comment: "Primary button label that submits a quantization job"), systemImage: "sparkles") .labelStyle(.titleAndIcon) } } .buttonStyle(.omlx(.primary)) .disabled(isStarting || selectedModelPath.isEmpty) } } } } private var modelOptions: [PopupOption] { var opts = [PopupOption(value: "", label: String(localized: "quant.source.option.select", defaultValue: "Select a model…", comment: "Placeholder option in the source-model dropdown"))] opts += models.map { m in PopupOption(value: m.path, label: "\(m.name) (\(m.sizeFormatted))") } return opts } private var sensitivityOptions: [PopupOption] { var opts = [PopupOption(value: "", label: String(localized: "quant.source.option.no_sensitivity", defaultValue: "None (use source model)", comment: "Sentinel option meaning no sensitivity-model override"))] opts += sensitivityCandidates.map { m in PopupOption(value: m.path, label: "\(m.name) (\(m.sizeFormatted))") } return opts } // Mirrors the HTML