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

1243 lines
57 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<String>] {
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<String>] {
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 <option>s.
static let levelOptions: [PopupOption<Double>] = [
PopupOption(value: 2, label: "oQ2"),
PopupOption(value: 2.5, label: "oQ2.5"),
PopupOption(value: 2.7, label: "oQ2.7"),
PopupOption(value: 2.8, label: "oQ2.8"),
PopupOption(value: 3, label: "oQ3"),
PopupOption(value: 3.5, label: "oQ3.5"),
PopupOption(value: 4, label: "oQ4"),
PopupOption(value: 5, label: "oQ5"),
PopupOption(value: 6, label: "oQ6"),
PopupOption(value: 8, label: "oQ8"),
]
}
// MARK: - Estimate strip
private struct EstimateStrip: View {
let memoryText: String
let bpwText: String
let outputSizeText: String
@Environment(\.omlxTheme) private var theme
var body: some View {
HStack(spacing: 18) {
pill(icon: "memorychip",
text: String(localized: "quant.estimate.memory",
defaultValue: "Est. memory: ~\(memoryText.isEmpty ? "—" : memoryText)",
comment: "Estimate pill: peak memory required to quantize. Placeholder is the formatted byte string"))
pill(icon: "gauge.with.dots.needle.50percent",
text: bpwText.isEmpty
? String(localized: "quant.estimate.calculating",
defaultValue: "Calculating…",
comment: "Estimate pill placeholder shown while values are being computed")
: String(localized: "quant.estimate.bpw",
defaultValue: "Effective \(bpwText) bpw",
comment: "Estimate pill: effective bits-per-weight. Placeholder is the formatted bpw value"))
pill(icon: "shippingbox",
text: outputSizeText.isEmpty
? String(localized: "quant.estimate.calculating",
defaultValue: "Calculating…",
comment: "Estimate pill placeholder shown while values are being computed")
: String(localized: "quant.estimate.output_size",
defaultValue: "Output size: ~\(outputSizeText)",
comment: "Estimate pill: predicted on-disk size of the quantized output. Placeholder is the formatted byte string"))
}
.padding(.horizontal, 18)
.padding(.top, 4)
.padding(.bottom, 10)
}
private func pill(icon: String, text: String) -> some View {
HStack(spacing: 5) {
Image(systemName: icon)
.font(.system(size: 10.5))
.foregroundStyle(theme.textTertiary)
Text(text)
.font(.omlxText(11))
.foregroundStyle(theme.textSecondary)
}
}
}
// MARK: - Advanced
private struct AdvancedSection: View {
@Binding var isOpen: Bool
let selectedIsVLM: Bool
let selectedHasMTP: Bool
@Binding var textOnly: Bool
@Binding var preserveMtp: Bool
@Binding var dtype: String
@Environment(\.omlxTheme) private var theme
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
withAnimation(.easeOut(duration: 0.15)) { isOpen.toggle() }
} label: {
HStack(spacing: 6) {
Image(systemName: isOpen ? "chevron.down" : "chevron.right")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(theme.textSecondary)
Text(String(localized: "quant.advanced.title",
defaultValue: "Advanced settings",
comment: "Collapsible header for the Quantization advanced-settings block"))
.font(.omlxText(11, weight: .semibold))
.foregroundStyle(theme.textSecondary)
.textCase(.uppercase)
.kerning(0.6)
Spacer(minLength: 0)
}
.padding(.horizontal, 14)
.padding(.top, 10)
.padding(.bottom, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isOpen {
ListGroup {
if selectedIsVLM {
Row(
label: String(localized: "quant.advanced.text_only.label",
defaultValue: "Text only",
comment: "Toggle row label: drop vision encoder weights when quantizing a VLM"),
sublabel: String(localized: "quant.advanced.text_only.sub",
defaultValue: "Exclude vision encoder weights (~2-3% smaller, text-only output)",
comment: "Toggle row sublabel: text-only quantization effect")
) {
Toggle("", isOn: $textOnly).labelsHidden().toggleStyle(.switch)
}
}
Row(
label: String(localized: "quant.advanced.preserve_mtp.label",
defaultValue: "Preserve MTP",
comment: "Toggle row label: keep multi-token prediction heads"),
sublabel: selectedHasMTP
? String(localized: "quant.advanced.preserve_mtp.sub.available",
defaultValue: "Keep multi-token prediction heads in the quantized output",
comment: "Toggle row sublabel when MTP is available")
: String(localized: "quant.advanced.preserve_mtp.sub.unavailable",
defaultValue: "Unavailable — source model has no MTP heads",
comment: "Toggle row sublabel when MTP isn't supported by the chosen source")
) {
Toggle("", isOn: $preserveMtp)
.labelsHidden()
.toggleStyle(.switch)
.disabled(!selectedHasMTP)
}
Row(
label: String(localized: "quant.advanced.dtype.label",
defaultValue: "Non-quant dtype",
comment: "Segmented row label: precision for tensors not getting quantized"),
sublabel: String(localized: "quant.advanced.dtype.sub",
defaultValue: "Precision for tensors that stay un-quantized (norms, scales)",
comment: "Segmented row sublabel explaining what dtype controls"),
isLast: true
) {
Segmented(selection: $dtype, options: [
("bfloat16", "bfloat16"),
("float16", "float16"),
])
}
}
}
}
}
}
private struct EmptyModelsBanner: View {
@Environment(\.omlxTheme) private var theme
var body: some View {
HStack(spacing: 8) {
Image(systemName: "info.circle")
.font(.system(size: 11))
.foregroundStyle(theme.textTertiary)
Text(String(localized: "quant.empty_models",
defaultValue: "No full-precision models found on disk. Download one from the Downloads tab first.",
comment: "Banner shown when no full-precision models are available to quantize"))
.font(.omlxText(11.5))
.foregroundStyle(theme.textSecondary)
Spacer(minLength: 0)
}
.padding(10)
.background(theme.codeBg)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.padding(.horizontal, 18)
.padding(.top, 6)
}
}
// MARK: - Queue
private struct QueueSection: View {
let tasks: [OQTaskDTO]
let onCancel: (String) -> Void
let onRemove: (String) -> Void
let onUpload: (OQTaskDTO) -> Void
@Environment(\.omlxTheme) private var theme
var body: some View {
if tasks.isEmpty {
EmptyView()
} else {
SectionHeader(String(localized: "quant.queue.title",
defaultValue: "Queue",
comment: "Section heading for the quantization task queue"),
subtitle: String(localized: "quant.queue.subtitle",
defaultValue: "Tasks: \(tasks.count)",
comment: "Subtitle for the Queue section. Placeholder is the task count"))
ListGroup {
ForEach(Array(tasks.enumerated()), id: \.element.id) { idx, task in
FreeRow(isLast: idx == tasks.count - 1) {
QueueRow(
task: task,
onCancel: { onCancel(task.taskId) },
onRemove: { onRemove(task.taskId) },
onUpload: { onUpload(task) }
)
}
}
}
}
}
}
private struct QueueRow: View {
let task: OQTaskDTO
let onCancel: () -> Void
let onRemove: () -> Void
let onUpload: () -> Void
@Environment(\.omlxTheme) private var theme
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.system(size: 12))
.foregroundStyle(theme.blueDot)
Text(task.outputName)
.font(.omlxMono(12))
.foregroundStyle(theme.text)
.lineLimit(1)
.truncationMode(.middle)
StatusChip(status: task.statusEnum)
Spacer(minLength: 4)
Text(elapsedText)
.font(.omlxMono(11))
.foregroundStyle(theme.textTertiary)
if task.statusEnum == .completed {
Button {
onUpload()
} label: {
Label(String(localized: "quant.queue.upload",
defaultValue: "Upload to HF",
comment: "Button label on a completed quant task that opens the HF upload sheet"),
systemImage: "arrow.up.circle")
.labelStyle(.titleAndIcon)
}
.buttonStyle(.omlx(.normal, size: .small))
.help(String(localized: "quant.queue.upload.help",
defaultValue: "Upload to Hugging Face Hub",
comment: "Tooltip on the Upload to HF button"))
}
Button {
if task.isActive { onCancel() } else { onRemove() }
} label: {
Image(systemName: "xmark")
.font(.system(size: 11))
}
.buttonStyle(.omlx(.plain, size: .small))
.help(task.isActive
? String(localized: "quant.queue.cancel.help",
defaultValue: "Cancel",
comment: "Tooltip on the X button for an active quant task")
: String(localized: "quant.queue.remove.help",
defaultValue: "Remove",
comment: "Tooltip on the X button for a terminal quant task"))
}
if task.isActive {
ProgressBar(progress: max(0, min(task.progress / 100, 1)), colors: [Color(rgb24: 0xFF2D55), Color(rgb24: 0xAF52DE)])
HStack(spacing: 8) {
if !task.phase.isEmpty {
Text(task.phase)
.font(.omlxText(11))
.foregroundStyle(theme.textSecondary)
}
Spacer(minLength: 0)
Text(progressText)
.font(.omlxMono(11))
.foregroundStyle(theme.textTertiary)
}
}
if !task.error.isEmpty {
Text(task.error)
.font(.omlxMono(10.5))
.foregroundStyle(theme.redDot)
.lineLimit(3)
}
}
}
private var progressText: String {
// While running, show "67%". When complete, server emits 100 anyway.
"\(Int(task.progress.rounded()))%"
}
private var elapsedText: String {
let now = Date().timeIntervalSince1970
let start = task.startedAt > 0 ? task.startedAt : task.createdAt
let end = task.completedAt > 0 ? task.completedAt : now
let secs = max(0, end - start)
if secs < 60 { return "\(Int(secs))s" }
let m = Int(secs / 60)
let s = Int(secs.truncatingRemainder(dividingBy: 60))
return "\(m)m \(s)s"
}
}
private struct StatusChip: View {
let status: OQTaskDTO.Status?
@Environment(\.omlxTheme) private var theme
var body: some View {
let cfg: (Color, String) = {
switch status {
case .pending: return (theme.textTertiary,
String(localized: "quant.status.pending",
defaultValue: "Pending",
comment: "Status chip label for a queued quantization task"))
case .loading: return (theme.blueDot,
String(localized: "quant.status.loading",
defaultValue: "Loading",
comment: "Status chip label while the source model is being loaded"))
case .quantizing: return (theme.blueDot,
String(localized: "quant.status.quantizing",
defaultValue: "Quantizing",
comment: "Status chip label while quantization is running"))
case .saving: return (theme.blueDot,
String(localized: "quant.status.saving",
defaultValue: "Saving",
comment: "Status chip label while the quantized output is being written"))
case .completed: return (theme.greenDot,
String(localized: "quant.status.completed",
defaultValue: "Completed",
comment: "Status chip label for a finished quantization"))
case .failed: return (theme.redDot,
String(localized: "quant.status.failed",
defaultValue: "Failed",
comment: "Status chip label for a failed quantization"))
case .cancelled: return (theme.textTertiary,
String(localized: "quant.status.cancelled",
defaultValue: "Cancelled",
comment: "Status chip label for a quantization cancelled by the user"))
case .none: return (theme.textTertiary, "—")
}
}()
Text(cfg.1)
.font(.omlxText(10, weight: .semibold))
.foregroundStyle(cfg.0)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(cfg.0.opacity(0.12))
.clipShape(Capsule())
}
}
// MARK: - Upload tasks
// Renders the HF upload queue. Mirrors `QueueSection` for visual parity but
// reads from `vm.uploadTasks` and exposes an "Open" link for completed jobs
// so the user can jump to the freshly published repo on huggingface.co.
private struct UploadTasksSection: View {
let tasks: [HFUploadTaskDTO]
let onCancel: (String) -> Void
let onRemove: (String) -> Void
@Environment(\.omlxTheme) private var theme
private var activeCount: Int { tasks.filter { $0.isActive }.count }
private var completedCount: Int { tasks.filter { $0.statusEnum == .completed }.count }
var body: some View {
if tasks.isEmpty {
EmptyView()
} else {
SectionHeader(
String(localized: "quant.uploads.title",
defaultValue: "Uploads",
comment: "Section heading for the HF upload task list"),
subtitle: String(localized: "quant.uploads.subtitle",
defaultValue: "Active: \(activeCount) / completed: \(completedCount)",
comment: "Subtitle for Uploads section. Placeholders: active count, completed count")
)
ListGroup {
ForEach(Array(tasks.enumerated()), id: \.element.id) { idx, task in
FreeRow(isLast: idx == tasks.count - 1) {
UploadRow(
task: task,
onCancel: { onCancel(task.taskId) },
onRemove: { onRemove(task.taskId) }
)
}
}
}
}
}
}
private struct UploadRow: View {
let task: HFUploadTaskDTO
let onCancel: () -> Void
let onRemove: () -> Void
@Environment(\.omlxTheme) private var theme
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: "arrow.up.circle")
.font(.system(size: 12))
.foregroundStyle(theme.blueDot)
Text(task.modelName)
.font(.omlxMono(12))
.foregroundStyle(theme.text)
.lineLimit(1)
.truncationMode(.middle)
UploadStatusChip(status: task.statusEnum)
Spacer(minLength: 4)
if task.statusEnum == .completed, !task.repoUrl.isEmpty,
let url = URL(string: task.repoUrl) {
Button {
NSWorkspace.shared.open(url)
} label: {
Label(String(localized: "quant.uploads.open",
defaultValue: "Open",
comment: "Button label that opens the published HF repo URL in a browser"),
systemImage: "arrow.up.right.square")
.labelStyle(.titleAndIcon)
}
.buttonStyle(.omlx(.plain, size: .small))
.help(task.repoUrl)
}
Button {
if task.isActive { onCancel() } else { onRemove() }
} label: {
Image(systemName: "xmark")
.font(.system(size: 11))
}
.buttonStyle(.omlx(.plain, size: .small))
.help(task.isActive
? String(localized: "quant.uploads.cancel.help",
defaultValue: "Cancel",
comment: "Tooltip on the X button for an active upload task")
: String(localized: "quant.uploads.remove.help",
defaultValue: "Remove",
comment: "Tooltip on the X button for a terminal upload task"))
}
if task.isActive {
ProgressBar(progress: max(0, min(task.progress / 100, 1)), colors: [Color(rgb24: 0xFF2D55), Color(rgb24: 0xAF52DE)])
HStack(spacing: 8) {
Text(task.repoId)
.font(.omlxMono(11))
.foregroundStyle(theme.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
Text("\(Int(task.progress.rounded()))%")
.font(.omlxMono(11))
.foregroundStyle(theme.textTertiary)
}
} else if !task.repoId.isEmpty {
Text(task.repoId)
.font(.omlxMono(11))
.foregroundStyle(theme.textTertiary)
.lineLimit(1)
.truncationMode(.middle)
}
if !task.error.isEmpty {
Text(task.error)
.font(.omlxMono(10.5))
.foregroundStyle(theme.redDot)
.lineLimit(3)
}
}
}
}
private struct UploadStatusChip: View {
let status: HFUploadTaskDTO.Status?
@Environment(\.omlxTheme) private var theme
var body: some View {
let cfg: (Color, String) = {
switch status {
case .pending: return (theme.textTertiary,
String(localized: "quant.upload_status.pending",
defaultValue: "Pending",
comment: "Status chip label for a queued upload"))
case .uploading: return (theme.blueDot,
String(localized: "quant.upload_status.uploading",
defaultValue: "Uploading",
comment: "Status chip label while an upload is in progress"))
case .completed: return (theme.greenDot,
String(localized: "quant.upload_status.completed",
defaultValue: "Completed",
comment: "Status chip label for a finished upload"))
case .failed: return (theme.redDot,
String(localized: "quant.upload_status.failed",
defaultValue: "Failed",
comment: "Status chip label for a failed upload"))
case .cancelled: return (theme.textTertiary,
String(localized: "quant.upload_status.cancelled",
defaultValue: "Cancelled",
comment: "Status chip label for an upload cancelled by the user"))
case .none: return (theme.textTertiary, "—")
}
}()
Text(cfg.1)
.font(.omlxText(10, weight: .semibold))
.foregroundStyle(cfg.0)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(cfg.0.opacity(0.12))
.clipShape(Capsule())
}
}
// MARK: - Upload modal
// Sheet presented when the user taps "Upload to HF" on a completed quant
// task. Three sections (Credentials / Repository / README) feed
// `POST /admin/api/upload/start` once the token has been validated and a
// repo name entered. Body submission closes the sheet via uploadTarget=nil.
private struct UploadModalView: View {
let task: OQTaskDTO
@Bindable var vm: QuantizationScreenVM
let client: OMLXClient
@Environment(\.omlxTheme) private var theme
/// Editable repo name. Pre-filled with the quant task's output name —
/// users are free to rename before publish.
@State private var repoName: String = ""
@State private var isPrivate: Bool = false
/// "" sentinel triggers the auto-generated README path. Any other value
/// is the absolute path of another local model whose README will be
/// copied verbatim.
@State private var readmeSourcePath: String = ""
@State private var addRedownloadNotice: Bool = true
@State private var isStarting: Bool = false
@State private var localError: String?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Sheet header — explicit since `SectionHeader` styles map to a
// scrollable screen, not a modal.
VStack(alignment: .leading, spacing: 4) {
Text(String(localized: "quant.upload_modal.title",
defaultValue: "Upload to Hugging Face",
comment: "Title of the upload-to-HF sheet"))
.font(.omlxText(15, weight: .semibold))
.foregroundStyle(theme.text)
Text(task.outputName)
.font(.omlxMono(11.5))
.foregroundStyle(theme.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
}
.padding(.horizontal, 14)
.padding(.top, 16)
.padding(.bottom, 8)
credentialsSection
repositorySection
readmeSection
if let err = localError, !err.isEmpty {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(theme.redDot)
.font(.system(size: 11))
.padding(.top, 1)
Text(err)
.font(.omlxText(11.5))
.foregroundStyle(theme.text)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(10)
.background(theme.redDot.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.padding(.horizontal, 14)
.padding(.top, 6)
}
footer
}
.frame(width: 560)
.frame(minHeight: 480)
.background(theme.windowBg)
.onAppear {
repoName = task.outputName
readmeSourcePath = ""
addRedownloadNotice = true
localError = nil
}
}
// MARK: Credentials
private var credentialsSection: some View {
VStack(alignment: .leading, spacing: 0) {
SectionHeader(
String(localized: "quant.upload_modal.credentials.title",
defaultValue: "Credentials",
comment: "Section heading inside the upload sheet for the HF token row"),
subtitle: vm.uploadValidatedUsername.map {
String(localized: "quant.upload_modal.credentials.subtitle.logged_in",
defaultValue: "Logged in as @\($0)",
comment: "Subtitle when a token has been validated. Placeholder is the HF username")
}
?? String(localized: "quant.upload_modal.credentials.subtitle.needs_validate",
defaultValue: "Validate a token to enable upload",
comment: "Subtitle when no token has been validated yet")
)
ListGroup {
Row(
label: String(localized: "quant.upload_modal.token.label",
defaultValue: "HF token",
comment: "Row label for the HF token input"),
sublabel: String(localized: "quant.upload_modal.token.sub",
defaultValue: "Stored in macOS Keychain. Needs write access to your account.",
comment: "Row sublabel explaining where the HF token is stored")
) {
HStack(spacing: 6) {
TextInput(
text: $vm.uploadToken,
placeholder: "hf_…",
isSecure: true,
mono: true,
width: 220
)
Button {
Task { await vm.validateUploadToken(client: client) }
} label: {
if vm.isValidatingToken {
ProgressView().controlSize(.small)
Text(String(localized: "quant.upload_modal.token.validating",
defaultValue: "Validating…",
comment: "Button label while the HF token validation request is in flight"))
} else {
Text(vm.uploadValidatedUsername == nil
? String(localized: "quant.upload_modal.token.validate",
defaultValue: "Validate",
comment: "Button label that triggers HF token validation")
: String(localized: "quant.upload_modal.token.revalidate",
defaultValue: "Re-validate",
comment: "Button label that re-runs HF token validation after a successful one"))
}
}
.buttonStyle(.omlx(.normal, size: .small))
.disabled(vm.isValidatingToken || vm.uploadToken.isEmpty)
}
}
if vm.uploadValidatedUsername != nil && !vm.uploadOrgs.isEmpty {
Row(
label: String(localized: "quant.upload_modal.namespace.label",
defaultValue: "Target namespace",
comment: "Row label for the HF namespace picker (user or org)"),
sublabel: String(localized: "quant.upload_modal.namespace.sub.with_orgs",
defaultValue: "Publish under your account or one of your orgs",
comment: "Row sublabel when orgs are available to publish under"),
isLast: true
) {
Popup(
selection: $vm.uploadNamespace,
width: 220,
options: namespaceOptions
)
}
} else {
// Make the last visible row in the group flush with the
// bottom rounded edge by toggling `isLast` on it.
Row(
label: String(localized: "quant.upload_modal.namespace.label",
defaultValue: "Target namespace",
comment: "Row label for the HF namespace picker (user or org)"),
sublabel: vm.uploadValidatedUsername == nil
? String(localized: "quant.upload_modal.namespace.sub.unvalidated",
defaultValue: "Available after validation",
comment: "Row sublabel before any token has been validated")
: String(localized: "quant.upload_modal.namespace.sub.user_only",
defaultValue: "Your account is the only available namespace",
comment: "Row sublabel when the validated user has no orgs"),
isLast: true
) {
Text(vm.uploadNamespace.isEmpty ? "—" : "@\(vm.uploadNamespace)")
.font(.omlxText(13, weight: .medium))
.foregroundStyle(theme.textSecondary)
}
}
}
}
}
// MARK: Repository
private var repositorySection: some View {
VStack(alignment: .leading, spacing: 0) {
SectionHeader(String(localized: "quant.upload_modal.repo.title",
defaultValue: "Repository",
comment: "Section heading for the repo-config rows inside the upload sheet"))
ListGroup {
Row(
label: String(localized: "quant.upload_modal.repo_name.label",
defaultValue: "Repo name",
comment: "Row label for the editable HF repo name"),
sublabel: String(localized: "quant.upload_modal.repo_name.sub",
defaultValue: "Full repo id will be \(vm.uploadNamespace.isEmpty ? "<namespace>" : vm.uploadNamespace)/<repo-name>",
comment: "Row sublabel previewing the full repo id. Placeholder is namespace or <namespace> sentinel")
) {
HStack(spacing: 6) {
Text(vm.uploadNamespace.isEmpty ? "<namespace>/" : "\(vm.uploadNamespace)/")
.font(.omlxMono(11.5))
.foregroundStyle(theme.textSecondary)
TextInput(
text: $repoName,
placeholder: "model-id",
mono: true,
width: 240
)
}
}
Row(
label: String(localized: "quant.upload_modal.private.label",
defaultValue: "Private repo",
comment: "Toggle row label: publish as private"),
sublabel: String(localized: "quant.upload_modal.private.sub",
defaultValue: "Only you and your org will see this model",
comment: "Toggle row sublabel explaining the private flag"),
isLast: true
) {
Toggle("", isOn: $isPrivate).labelsHidden().toggleStyle(.switch)
}
}
}
}
// MARK: README
private var readmeSection: some View {
VStack(alignment: .leading, spacing: 0) {
SectionHeader(String(localized: "quant.upload_modal.readme.title",
defaultValue: "README",
comment: "Section heading for the README configuration inside the upload sheet"))
ListGroup {
Row(
label: String(localized: "quant.upload_modal.readme_source.label",
defaultValue: "Source",
comment: "Row label for the README source picker"),
sublabel: String(localized: "quant.upload_modal.readme_source.sub",
defaultValue: "Generate a default card or copy from another local model",
comment: "Row sublabel explaining the README source options")
) {
Popup(
selection: $readmeSourcePath,
width: 260,
options: readmeOptions
)
}
if readmeSourcePath.isEmpty {
Row(
label: String(localized: "quant.upload_modal.notice.label.add",
defaultValue: "Add re-download notice",
comment: "Toggle row label: append a re-download banner to the auto-generated README"),
sublabel: String(localized: "quant.upload_modal.notice.sub.add",
defaultValue: "Append a banner reminding downstream users to re-pull",
comment: "Toggle row sublabel for the re-download notice"),
isLast: true
) {
Toggle("", isOn: $addRedownloadNotice).labelsHidden().toggleStyle(.switch)
}
} else {
// Trailing row stays flush even when the toggle is hidden.
Row(
label: String(localized: "quant.upload_modal.notice.label.copied",
defaultValue: "Re-download notice",
comment: "Row label when README is copied — notice toggle is disabled"),
sublabel: String(localized: "quant.upload_modal.notice.sub.copied",
defaultValue: "Disabled when copying an existing README",
comment: "Row sublabel explaining why the notice toggle is off"),
isLast: true
) {
Text(String(localized: "quant.upload_modal.notice.off",
defaultValue: "Off",
comment: "Value text displayed when the re-download notice is unavailable"))
.font(.omlxText(13, weight: .medium))
.foregroundStyle(theme.textTertiary)
}
}
}
}
}
// MARK: Footer
private var footer: some View {
HStack(spacing: 8) {
Spacer()
Button(String(localized: "common.cancel",
defaultValue: "Cancel",
comment: "Generic cancel button")) { vm.uploadTarget = nil }
.buttonStyle(.omlx(.normal, size: .regular))
.keyboardShortcut(.cancelAction)
Button {
submit()
} label: {
if isStarting {
ProgressView().controlSize(.small)
Text(String(localized: "quant.upload_modal.uploading",
defaultValue: "Uploading…",
comment: "Footer button label shown while the upload start request is in flight"))
} else {
Label(String(localized: "quant.upload_modal.upload",
defaultValue: "Upload",
comment: "Primary footer button label that starts the HF upload"),
systemImage: "arrow.up.circle.fill")
.labelStyle(.titleAndIcon)
}
}
.buttonStyle(.omlx(.primary))
.disabled(!canSubmit || isStarting)
.keyboardShortcut(.defaultAction)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
// MARK: Helpers
private var canSubmit: Bool {
vm.uploadValidatedUsername != nil
&& !vm.uploadNamespace.isEmpty
&& !repoName.trimmingCharacters(in: .whitespaces).isEmpty
}
private var namespaceOptions: [PopupOption<String>] {
var opts: [PopupOption<String>] = []
if let user = vm.uploadValidatedUsername {
opts.append(PopupOption(value: user, label: "@\(user)"))
}
for org in vm.uploadOrgs {
opts.append(PopupOption(value: org.name, label: org.name))
}
return opts
}
private var readmeOptions: [PopupOption<String>] {
var opts = [PopupOption(value: "",
label: String(localized: "quant.upload_modal.readme_source.auto",
defaultValue: "Auto-generate",
comment: "Default option in the README source picker meaning generate a default card"))]
opts += vm.uploadCandidateModels.map { m in
PopupOption(value: m.path,
label: String(localized: "quant.upload_modal.readme_source.copy_from",
defaultValue: "Copy from \(m.name)",
comment: "README source option that copies an existing model's README. Placeholder is the source model name"))
}
return opts
}
private func submit() {
let trimmed = repoName.trimmingCharacters(in: .whitespaces)
guard canSubmit else { return }
isStarting = true
localError = nil
let body = HFUploadStartRequest(
modelPath: task.outputPath,
repoId: "\(vm.uploadNamespace)/\(trimmed)",
hfToken: vm.uploadToken,
readmeSourcePath: readmeSourcePath,
autoReadme: readmeSourcePath.isEmpty,
redownloadNotice: readmeSourcePath.isEmpty && addRedownloadNotice,
private: isPrivate
)
Task { @MainActor in
await vm.startUpload(body: body, client: client)
isStarting = false
if let err = vm.lastUploadError, !err.isEmpty {
localError = err
} else {
vm.uploadTarget = nil
}
}
}
}
// MARK: - About
private struct AboutSection: View {
@Environment(\.omlxTheme) private var theme
var body: some View {
SectionHeader(String(localized: "quant.about.title",
defaultValue: "About oQ Quantization",
comment: "Section heading for the static About card on the Quantization screen"))
ListGroup {
FreeRow(isLast: true) {
VStack(alignment: .leading, spacing: 10) {
Text(String(localized: "quant.about.headline",
defaultValue: "oMLX Universal Dynamic Quantization",
comment: "Headline inside the About oQ card"))
.font(.omlxText(13, weight: .semibold))
.foregroundStyle(theme.text)
Text(String(localized: "quant.about.body1",
defaultValue: "Quantization should not be exclusive to any particular inference server. oQ produces standard mlx-lm models that work everywhere — oMLX, mlx-lm, LM Studio, and any app that supports MLX safetensors format. No custom loader required.",
comment: "First body paragraph of the About oQ card"))
.font(.omlxText(11.5))
.foregroundStyle(theme.textSecondary)
.fixedSize(horizontal: false, vertical: true)
Text(String(localized: "quant.about.body2",
defaultValue: "oQ measures each layer's quantization sensitivity through calibration (relative MSE vs float16) and builds a byte-budgeted mixed-precision plan that allocates bits where the data says they matter most. Every model gets a unique bit allocation tuned to its architecture.",
comment: "Second body paragraph of the About oQ card"))
.font(.omlxText(11.5))
.foregroundStyle(theme.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.padding(.bottom, 18)
}
}
// MARK: - Keychain helper
// Thin SecItem wrapper. Single account/service pair — the upload screen is
// the only consumer right now, so we keep the surface small. All accesses
// happen on the main actor (called from the VM); the SecItem APIs are
// thread-safe so we don't need additional locking.
enum Keychain {
private static let service = "app.omlx.hf-upload"
private static let account = "huggingface-token"
/// Returns the stored token or `nil` if no item exists / the read fails.
static func read() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess,
let data = item as? Data,
let str = String(data: data, encoding: .utf8) else {
return nil
}
return str
}
/// Writes (or updates) the stored token. No-op on empty input so we
/// don't accidentally clobber an existing entry with an empty string.
@discardableResult
static func write(_ value: String) -> Bool {
guard let data = value.data(using: .utf8), !value.isEmpty else { return false }
let baseQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let updateStatus = SecItemUpdate(
baseQuery as CFDictionary,
[kSecValueData as String: data] as CFDictionary
)
if updateStatus == errSecSuccess { return true }
if updateStatus == errSecItemNotFound {
var addQuery = baseQuery
addQuery[kSecValueData as String] = data
return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess
}
return false
}
@discardableResult
static func delete() -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
}