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%

phase6: features — Hub search/downloads/catalog, Playground chat+embeddings, Train configurator+live run detail, Convert jobs, Evaluate compare, Keychain HF token, shortcuts; live-verified in UI

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 11 days ago (Jul 31, 2026) parent 1d83052

Showing 15 changed files with +1,974 and −34

modified Sources/ZyquoMLX/App/CLIFoundry.swift +31 −0
@@ -19,6 +19,7 @@ extension CLI {
19 19 let args = CommandLine.arguments
20 20 return args.contains("--train") || args.contains("--fuse")
21 21 || args.contains("--quantize") || args.contains("--validate-dataset")
22 + || args.contains("--download")
22 23 }
23 24
24 25 static func runFoundry() async -> Int32 {
@@ -40,6 +41,10 @@ extension CLI {
40 41 try await validateDataset(path: path)
41 42 return 0
42 43 }
44 + if let repo = value(after: "--download", in: args) {
45 + try await download(repo: repo)
46 + return 0
47 + }
43 48 return 2
44 49 } catch {
45 50 FileHandle.standardError.write(Data("error: \(error.localizedDescription)\n".utf8))
@@ -200,6 +205,32 @@ extension CLI {
200 205 }
201 206 }
202 207
208 + // MARK: - Download (drives HubService + DownloadManager)
209 +
210 + private static func download(repo: String) async throws {
211 + print("searching hub for \(repo)…")
212 + let hits = try await HubService.shared.search(query: repo, author: nil, limit: 3)
213 + for hit in hits.prefix(3) {
214 + print(" \(hit.id) [\(hit.modelType.displayName)] \(hit.downloads.formatted()) downloads")
215 + }
216 + let events = await DownloadManager.shared.download(repo: repo)
217 + var lastPercent = -1
218 + for await event in events {
219 + switch event {
220 + case .progress(let progress):
221 + let percent = Int(progress.fraction * 100)
222 + if percent != lastPercent, percent % 10 == 0 {
223 + print("\(percent)% (\(progress.currentFile))")
224 + lastPercent = percent
225 + }
226 + case .finished(let model):
227 + print("installed \(model.name) [\(model.type.displayName)] — \(ByteCountFormatter.string(fromByteCount: model.diskSize, countStyle: .file)) ✅")
228 + case .failed(let message):
229 + throw CLIError.usage("download failed: \(message)")
230 + }
231 + }
232 + }
233 +
203 234 // MARK: - Dataset validation
204 235
205 236 private static func validateDataset(path: String) async throws {
modified Sources/ZyquoMLX/App/ZyquoMLXApp.swift +11 −0
@@ -40,6 +40,17 @@ struct ZyquoMLXApp: App {
40 40 .defaultSize(
41 41 width: ZyquoTheme.defaultWindowSize.width,
42 42 height: ZyquoTheme.defaultWindowSize.height)
43 + .commands {
44 + CommandMenu("Foundry") {
45 + ForEach(WorkbenchSection.allCases) { section in
46 + Button(section.title) {
47 + NotificationCenter.default.post(
48 + name: .zyquoNavigate, object: section)
49 + }
50 + .keyboardShortcut(section.shortcut, modifiers: .command)
51 + }
52 + }
53 + }
43 54
44 55 Settings {
45 56 SettingsView()
added Sources/ZyquoMLX/Hub/DownloadManager.swift +161 −0
@@ -0,0 +1,161 @@
1 +//
2 +// DownloadManager.swift
3 +// Zyquo MLX
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +
11 +/// Progress of one repo download.
12 +struct DownloadProgress: Sendable {
13 + var repo: String
14 + var completedBytes: Int64
15 + var totalBytes: Int64
16 + var currentFile: String
17 + var fraction: Double { totalBytes > 0 ? Double(completedBytes) / Double(totalBytes) : 0 }
18 +}
19 +
20 +enum DownloadEvent: Sendable {
21 + case progress(DownloadProgress)
22 + case finished(LocalModel)
23 + case failed(String)
24 +}
25 +
26 +/// Downloads Hub repos into the model library with resumable, Range-based
27 +/// transfers (contract verified in docs/MODELS.md §1.3: fresh resolve URL +
28 +/// `Range: bytes=N-` → 206; CDN URLs expire so we never persist them).
29 +actor DownloadManager {
30 +
31 + static let shared = DownloadManager()
32 +
33 + private var activeTasks: [String: Task<Void, Never>] = [:]
34 +
35 + var activeRepos: [String] { Array(activeTasks.keys) }
36 +
37 + /// Download a full repo. Partially-downloaded files resume from their
38 + /// current byte count.
39 + func download(repo: String) -> AsyncStream<DownloadEvent> {
40 + let (stream, continuation) = AsyncStream.makeStream(of: DownloadEvent.self)
41 +
42 + let task = Task {
43 + let directory = PersistenceService.modelsDirectory
44 + .appendingPathComponent(repo.replacingOccurrences(of: "/", with: "--"), isDirectory: true)
45 + do {
46 + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
47 + let files = try await HubService.shared.files(repo: repo)
48 + .filter { !$0.path.hasPrefix(".") }
49 + let totalBytes = files.reduce(0) { $0 + $1.size }
50 + var completed: Int64 = 0
51 +
52 + for file in files {
53 + try Task.checkCancellation()
54 + let destination = directory.appendingPathComponent(file.path)
55 + try FileManager.default.createDirectory(
56 + at: destination.deletingLastPathComponent(),
57 + withIntermediateDirectories: true)
58 +
59 + let existing = (try? destination.resourceValues(forKeys: [.fileSizeKey]))?
60 + .fileSize.map(Int64.init) ?? 0
61 + if existing == file.size, file.size > 0 {
62 + completed += file.size
63 + continuation.yield(.progress(DownloadProgress(
64 + repo: repo, completedBytes: completed,
65 + totalBytes: totalBytes, currentFile: file.path)))
66 + continue
67 + }
68 +
69 + let completedSoFar = completed
70 + try await downloadFile(
71 + repo: repo, path: file.path, to: destination,
72 + resumeFrom: existing < file.size ? existing : 0
73 + ) { bytesSoFar in
74 + continuation.yield(.progress(DownloadProgress(
75 + repo: repo, completedBytes: completedSoFar + bytesSoFar,
76 + totalBytes: totalBytes, currentFile: file.path)))
77 + }
78 + completed += file.size
79 + }
80 +
81 + let model = try await ModelStore.shared.describe(directory: directory)
82 + continuation.yield(.finished(model))
83 + } catch is CancellationError {
84 + continuation.yield(.failed("Download paused — it will resume from where it stopped."))
85 + } catch {
86 + continuation.yield(.failed(error.localizedDescription))
87 + }
88 + self.clearTask(repo: repo)
89 + continuation.finish()
90 + }
91 + activeTasks[repo] = task
92 + continuation.onTermination = { termination in
93 + if case .cancelled = termination { task.cancel() }
94 + }
95 + return stream
96 + }
97 +
98 + func cancel(repo: String) {
99 + activeTasks[repo]?.cancel()
100 + }
101 +
102 + private func clearTask(repo: String) {
103 + activeTasks[repo] = nil
104 + }
105 +
106 + /// Stream one file to disk with Range resume and periodic progress.
107 + private func downloadFile(
108 + repo: String, path: String, to destination: URL,
109 + resumeFrom: Int64,
110 + progress: @Sendable (Int64) -> Void
111 + ) async throws {
112 + var request = URLRequest(url: HubService.resolveURL(repo: repo, path: path))
113 + if resumeFrom > 0 {
114 + request.setValue("bytes=\(resumeFrom)-", forHTTPHeaderField: "Range")
115 + }
116 + if let token = HFTokenStore.token, !token.isEmpty {
117 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
118 + }
119 +
120 + let (bytes, response) = try await URLSession.shared.bytes(for: request)
121 + guard let http = response as? HTTPURLResponse,
122 + http.statusCode == 200 || http.statusCode == 206
123 + else {
124 + throw HubServiceError.badResponse((response as? HTTPURLResponse)?.statusCode ?? 0)
125 + }
126 +
127 + let appending = http.statusCode == 206 && resumeFrom > 0
128 + if !appending {
129 + FileManager.default.createFile(atPath: destination.path, contents: nil)
130 + }
131 + let handle = try FileHandle(forWritingTo: destination)
132 + defer { try? handle.close() }
133 + if appending {
134 + try handle.seekToEnd()
135 + } else {
136 + try handle.truncate(atOffset: 0)
137 + }
138 +
139 + var written: Int64 = appending ? resumeFrom : 0
140 + var buffer = Data(capacity: 1 << 20)
141 + var lastReport = Date()
142 + for try await byte in bytes {
143 + buffer.append(byte)
144 + if buffer.count >= 1 << 20 {
145 + try handle.write(contentsOf: buffer)
146 + written += Int64(buffer.count)
147 + buffer.removeAll(keepingCapacity: true)
148 + if Date().timeIntervalSince(lastReport) > 0.2 {
149 + progress(written)
150 + lastReport = Date()
151 + }
152 + try Task.checkCancellation()
153 + }
154 + }
155 + if !buffer.isEmpty {
156 + try handle.write(contentsOf: buffer)
157 + written += Int64(buffer.count)
158 + }
159 + progress(written)
160 + }
161 +}
added Sources/ZyquoMLX/Hub/HubService.swift +186 −0
@@ -0,0 +1,186 @@
1 +//
2 +// HubService.swift
3 +// Zyquo MLX
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +
11 +/// One search result / catalog entry from the Hugging Face Hub.
12 +struct HubModel: Identifiable, Codable, Hashable, Sendable {
13 + var id: String // repo id, e.g. "mlx-community/Qwen3-4B-4bit"
14 + var pipelineTag: String?
15 + var downloads: Int
16 + var likes: Int
17 + var tags: [String]
18 + var gated: Bool
19 +
20 + var name: String { id.split(separator: "/").last.map(String.init) ?? id }
21 +
22 + /// Best-effort type from the pipeline tag (MODELS.md §1.1).
23 + var modelType: ModelType {
24 + switch pipelineTag {
25 + case "image-text-to-text": .vlm
26 + case "feature-extraction", "sentence-similarity": .embedding
27 + case "automatic-speech-recognition": .speech
28 + case "image-to-image", "text-to-image": .imageGeneration
29 + default: .llm
30 + }
31 + }
32 +}
33 +
34 +/// A file inside a Hub repo (from the tree endpoint).
35 +struct HubFile: Codable, Sendable {
36 + var path: String
37 + var size: Int64
38 +}
39 +
40 +enum HubServiceError: LocalizedError {
41 + case badResponse(Int)
42 + case gatedRepo(String)
43 +
44 + var errorDescription: String? {
45 + switch self {
46 + case .badResponse(let code):
47 + "Hugging Face returned HTTP \(code). Check your connection (or your token for gated models)."
48 + case .gatedRepo(let id):
49 + "\(id) is gated — add a Hugging Face token in Settings to access it."
50 + }
51 + }
52 +}
53 +
54 +/// Live Hugging Face Hub API client (verified behavior in docs/MODELS.md §1):
55 +/// search, file listing (sizes from the recursive tree), and resolve URLs.
56 +actor HubService {
57 +
58 + static let shared = HubService()
59 +
60 + private let session = URLSession.shared
61 +
62 + /// Search MLX models (library tag `mlx`), sorted by downloads.
63 + func search(query: String, author: String? = "mlx-community", limit: Int = 30) async throws -> [HubModel] {
64 + var components = URLComponents(string: "https://huggingface.co/api/models")!
65 + var items = [
66 + URLQueryItem(name: "filter", value: "mlx"),
67 + URLQueryItem(name: "sort", value: "downloads"),
68 + URLQueryItem(name: "direction", value: "-1"),
69 + URLQueryItem(name: "limit", value: String(limit)),
70 + ]
71 + if !query.isEmpty { items.append(URLQueryItem(name: "search", value: query)) }
72 + if let author { items.append(URLQueryItem(name: "author", value: author)) }
73 + components.queryItems = items
74 +
75 + let data = try await get(components.url!)
76 + struct Item: Codable {
77 + var id: String
78 + var pipeline_tag: String?
79 + var downloads: Int?
80 + var likes: Int?
81 + var tags: [String]?
82 + var gated: GatedValue?
83 + }
84 + // `gated` is false | "manual" | "auto" in the live API.
85 + enum GatedValue: Codable {
86 + case bool(Bool), string(String)
87 + init(from decoder: Decoder) throws {
88 + let container = try decoder.singleValueContainer()
89 + if let b = try? container.decode(Bool.self) { self = .bool(b) } else {
90 + self = .string(try container.decode(String.self))
91 + }
92 + }
93 + func encode(to encoder: Encoder) throws {
94 + var container = encoder.singleValueContainer()
95 + switch self {
96 + case .bool(let b): try container.encode(b)
97 + case .string(let s): try container.encode(s)
98 + }
99 + }
100 + var isGated: Bool {
101 + if case .bool(let b) = self { return b }
102 + return true
103 + }
104 + }
105 + return try JSONDecoder().decode([Item].self, from: data).map {
106 + HubModel(
107 + id: $0.id,
108 + pipelineTag: $0.pipeline_tag,
109 + downloads: $0.downloads ?? 0,
110 + likes: $0.likes ?? 0,
111 + tags: $0.tags ?? [],
112 + gated: $0.gated?.isGated ?? false)
113 + }
114 + }
115 +
116 + /// Recursive file listing with true byte sizes (MODELS.md §1.2 —
117 + /// `recursive=true` is mandatory for subdirectory layouts).
118 + func files(repo: String, revision: String = "main") async throws -> [HubFile] {
119 + let url = URL(string: "https://huggingface.co/api/models/\(repo)/tree/\(revision)?recursive=true")!
120 + let data = try await get(url)
121 + struct Entry: Codable {
122 + var type: String
123 + var path: String
124 + var size: Int64?
125 + }
126 + return try JSONDecoder().decode([Entry].self, from: data)
127 + .filter { $0.type == "file" }
128 + .map { HubFile(path: $0.path, size: $0.size ?? 0) }
129 + }
130 +
131 + /// Total weight bytes of a repo (for RAM badges before download).
132 + func weightSize(repo: String) async throws -> Int64 {
133 + try await files(repo: repo)
134 + .filter { $0.path.hasSuffix(".safetensors") || $0.path.hasSuffix(".npz") }
135 + .reduce(0) { $0 + $1.size }
136 + }
137 +
138 + /// Resolve URL for one file (302s to the CDN; supports Range).
139 + nonisolated static func resolveURL(repo: String, path: String, revision: String = "main") -> URL {
140 + URL(string: "https://huggingface.co/\(repo)/resolve/\(revision)/\(path)")!
141 + }
142 +
143 + private func get(_ url: URL) async throws -> Data {
144 + var request = URLRequest(url: url)
145 + if let token = HFTokenStore.token, !token.isEmpty {
146 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
147 + }
148 + let (data, response) = try await session.data(for: request)
149 + guard let http = response as? HTTPURLResponse else { throw HubServiceError.badResponse(0) }
150 + guard (200..<300).contains(http.statusCode) else {
151 + throw HubServiceError.badResponse(http.statusCode)
152 + }
153 + return data
154 + }
155 +}
156 +
157 +/// Hugging Face token storage in the login Keychain (charter: encrypted
158 +/// vault pattern; never plaintext on disk).
159 +enum HFTokenStore {
160 + private static let service = "com.zyquo.mlx.hf-token"
161 +
162 + static var token: String? {
163 + let query: [String: Any] = [
164 + kSecClass as String: kSecClassGenericPassword,
165 + kSecAttrService as String: service,
166 + kSecReturnData as String: true,
167 + ]
168 + var result: AnyObject?
169 + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
170 + let data = result as? Data
171 + else { return nil }
172 + return String(data: data, encoding: .utf8)
173 + }
174 +
175 + static func save(_ token: String) {
176 + let base: [String: Any] = [
177 + kSecClass as String: kSecClassGenericPassword,
178 + kSecAttrService as String: service,
179 + ]
180 + SecItemDelete(base as CFDictionary)
181 + guard !token.isEmpty else { return }
182 + var add = base
183 + add[kSecValueData as String] = Data(token.utf8)
184 + SecItemAdd(add as CFDictionary, nil)
185 + }
186 +}
added Sources/ZyquoMLX/Services/Catalog.swift +59 −0
@@ -0,0 +1,59 @@
1 +//
2 +// Catalog.swift
3 +// Zyquo MLX
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +
11 +/// One curated Featured entry (generated from docs/MODELS.md §2 — every repo
12 +/// live-verified 2026-07-30; single source of truth for curated data).
13 +struct CatalogEntry: Identifiable, Hashable, Sendable {
14 + var id: String // repo id
15 + var type: ModelType
16 + var params: String
17 + var quant: String
18 + var weightBytes: Int64
19 + var blurb: String
20 +
21 + var name: String { id.split(separator: "/").last.map(String.init) ?? id }
22 +
23 + /// RAM verdict for this Mac, computed from verified weight size.
24 + var verdict: MemoryVerdict {
25 + let needed = Int64(Double(weightBytes) * 1.2) + 2_000_000_000
26 + let ceiling = MemoryAdvisor.recommendedWorkingSet
27 + if needed <= Int64(Double(ceiling) * 0.75) { return .comfortable }
28 + if needed <= ceiling { return .tight }
29 + return .wontFit
30 + }
31 +}
32 +
33 +/// The Featured catalog (docs/MODELS.md §2, live-verified 2026-07-30).
34 +enum Catalog {
35 +
36 + static let featured: [CatalogEntry] = [
37 + // Text LLMs — small
38 + CatalogEntry(id: "mlx-community/Qwen3-0.6B-4bit", type: .llm, params: "0.6B", quant: "4-bit", weightBytes: 365_000_000, blurb: "Tiny, instant — ideal first model and QLoRA test bed"),
39 + CatalogEntry(id: "mlx-community/Llama-3.2-1B-Instruct-4bit", type: .llm, params: "1B", quant: "4-bit", weightBytes: 752_000_000, blurb: "Meta's small instruct staple"),
40 + CatalogEntry(id: "mlx-community/Llama-3.2-3B-Instruct-4bit", type: .llm, params: "3B", quant: "4-bit", weightBytes: 1_944_000_000, blurb: "Great quality per gigabyte"),
41 + // Text LLMs — mid
42 + CatalogEntry(id: "mlx-community/Qwen3-4B-Instruct-2507-4bit", type: .llm, params: "4B", quant: "4-bit", weightBytes: 2_427_000_000, blurb: "The 2507 refresh — strong all-rounder"),
43 + CatalogEntry(id: "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit", type: .llm, params: "7B", quant: "4-bit", weightBytes: 4_596_000_000, blurb: "Code-tuned workhorse"),
44 + CatalogEntry(id: "mlx-community/Qwen3-8B-4bit", type: .llm, params: "8B", quant: "4-bit", weightBytes: 4_950_000_000, blurb: "Balanced reasoning and speed"),
45 + CatalogEntry(id: "mlx-community/Qwen3.5-9B-4bit", type: .llm, params: "9B", quant: "4-bit", weightBytes: 6_389_000_000, blurb: "Current-gen Qwen3.5"),
46 + // Text LLMs — large
47 + CatalogEntry(id: "mlx-community/Qwen3-14B-4bit", type: .llm, params: "14B", quant: "4-bit", weightBytes: 8_923_000_000, blurb: "Serious quality on 16 GB+ Macs"),
48 + CatalogEntry(id: "mlx-community/gpt-oss-20b-MXFP4-Q8", type: .llm, params: "20.9B MoE", quant: "MXFP4", weightBytes: 12_970_000_000, blurb: "OpenAI's open MoE — the community favorite"),
49 + CatalogEntry(id: "mlx-community/Qwen3-30B-A3B-Instruct-2507-4bit", type: .llm, params: "30B-A3B MoE", quant: "4-bit", weightBytes: 18_446_000_000, blurb: "MoE: 30B quality at ~3B speed"),
50 + CatalogEntry(id: "mlx-community/Qwen3.6-27B-4bit", type: .llm, params: "27B", quant: "4-bit", weightBytes: 17_233_000_000, blurb: "Newest dense Qwen3.6"),
51 + // VLM
52 + CatalogEntry(id: "mlx-community/Qwen3-VL-4B-Instruct-4bit", type: .vlm, params: "4B", quant: "4-bit", weightBytes: 3_318_000_000, blurb: "Vision-language, compact and capable"),
53 + CatalogEntry(id: "mlx-community/gemma-3-12b-it-qat-4bit", type: .vlm, params: "12B", quant: "4-bit QAT", weightBytes: 8_622_000_000, blurb: "Google's QAT vision flagship"),
54 + // Embeddings
55 + CatalogEntry(id: "mlx-community/all-MiniLM-L6-v2-4bit", type: .embedding, params: "22M", quant: "4-bit", weightBytes: 15_000_000, blurb: "Instant embeddings for similarity demos"),
56 + CatalogEntry(id: "mlx-community/Qwen3-Embedding-0.6B-4bit-DWQ", type: .embedding, params: "0.6B", quant: "4-bit DWQ", weightBytes: 365_000_000, blurb: "Modern multilingual embeddings"),
57 + CatalogEntry(id: "mlx-community/bge-m3-mlx-fp16", type: .embedding, params: "568M", quant: "fp16", weightBytes: 1_224_000_000, blurb: "Dense + multi-vector retrieval standard"),
58 + ]
59 +}
modified Sources/ZyquoMLX/ViewModels/AppModel.swift +19 −0
@@ -8,6 +8,7 @@
8 8
9 9 import Foundation
10 10 import Observation
11 +import SwiftUI
11 12
12 13 /// Workbench sections (left navigator, charter §4.2).
13 14 enum WorkbenchSection: String, CaseIterable, Identifiable {
@@ -41,6 +42,24 @@ enum WorkbenchSection: String, CaseIterable, Identifiable {
41 42 case .evaluate: "checkmark.seal"
42 43 }
43 44 }
45 +
46 + /// ⌘-shortcuts (charter Phase 6: ⌘L Models, ⌘D Datasets, ⌘R Train,
47 + /// ⌘I Playground; Convert/Evaluate get the next free letters).
48 + var shortcut: KeyEquivalent {
49 + switch self {
50 + case .models: "l"
51 + case .datasets: "d"
52 + case .train: "r"
53 + case .convert: "u"
54 + case .playground: "i"
55 + case .evaluate: "e"
56 + }
57 + }
58 +}
59 +
60 +extension Notification.Name {
61 + /// Posted by the Foundry menu commands to switch workbench sections.
62 + static let zyquoNavigate = Notification.Name("zyquo.navigate")
44 63 }
45 64
46 65 /// Root observable state for the workbench: library contents, run history,
modified Sources/ZyquoMLX/Views/ConvertView.swift +290 −6
@@ -8,15 +8,299 @@
8 8
9 9 import SwiftUI
10 10
11 /// Convert section: quantize / fuse / convert job cards (interactive job
12 /// launcher wired in Phase 6).
11 +/// Convert section: quantize, fuse adapters, and import HF repos — every job
12 +/// tracked with progress and a before/after size story.
13 13 struct ConvertView: View {
14 14 @Bindable var model: AppModel
15 + @State private var controller = ConvertController()
15 16
16 17 var body: some View {
17 EmptyStateView(
18 icon: "arrow.triangle.2.circlepath",
19 title: "The Conversion Bench",
20 message: "Quantize models to 4/8-bit with live size previews, fuse trained adapters into standalone models, and convert Hugging Face checkpoints to MLX format — every job tracked with progress and validation.")
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)
23 +
24 + if !controller.jobs.isEmpty {
25 + Text("Jobs")
26 + .font(ZyquoTheme.headlineFont)
27 + ForEach(controller.jobs.reversed()) { job in
28 + ConvertJobRow(job: job)
29 + }
30 + }
31 + }
32 + .padding(ZyquoTheme.spacing20)
33 + }
34 + .onChange(of: controller.finishedCount) {
35 + Task { await model.refresh() }
36 + }
37 + }
38 +}
39 +
40 +// MARK: - Controller
41 +
42 +struct ConvertJob: Identifiable {
43 + let id = UUID()
44 + var title: String
45 + var stage: String
46 + var fraction: Double?
47 + var finished = false
48 + var error: String?
49 +}
50 +
51 +@Observable
52 +@MainActor
53 +final class ConvertController {
54 + var jobs: [ConvertJob] = []
55 + var finishedCount = 0
56 +
57 + func track(title: String, events: AsyncStream<ConversionEvent>) {
58 + var job = ConvertJob(title: title, stage: "starting")
59 + jobs.append(job)
60 + let index = jobs.count - 1
61 + Task {
62 + for await event in events {
63 + switch event {
64 + case .stage(let stage, let fraction):
65 + job.stage = stage
66 + job.fraction = fraction
67 + case .finished(let output):
68 + job.stage = "done → \(output.lastPathComponent)"
69 + job.finished = true
70 + finishedCount += 1
71 + case .failed(let message):
72 + job.error = message
73 + job.finished = true
74 + }
75 + jobs[index] = job
76 + }
77 + }
78 + }
79 +}
80 +
81 +private struct ConvertJobRow: View {
82 + let job: ConvertJob
83 +
84 + 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 +}
110 +
111 +// MARK: - Quantize
112 +
113 +private struct QuantizeCard: View {
114 + @Bindable var model: AppModel
115 + let controller: ConvertController
116 +
117 + @State private var modelID: String?
118 + @State private var config = QuantConfig()
119 +
120 + 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 + }
124 +
125 + 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)
132 +
133 + 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)
139 +
140 + Picker("Bits", selection: $config.bits) {
141 + ForEach(QuantConfig.affineBits, id: \.self) { Text("\($0)-bit") }
142 + }
143 + .frame(width: 110)
144 +
145 + Picker("Group", selection: $config.groupSize) {
146 + ForEach(QuantConfig.affineGroupSizes, id: \.self) { Text("g\($0)") }
147 + }
148 + .frame(width: 90)
149 +
150 + 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 + }
155 +
156 + Spacer()
157 +
158 + 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 + }
168 +
169 + 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 +}
179 +
180 +// MARK: - Fuse
181 +
182 +private struct FuseCard: View {
183 + @Bindable var model: AppModel
184 + let controller: ConvertController
185 +
186 + @State private var runID: UUID?
187 + @State private var dequantize = true
188 +
189 + 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 + }
196 +
197 + 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)
204 +
205 + 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)
211 +
212 + Toggle("De-quantize", isOn: $dequantize)
213 + .help("Recommended for quantized bases — keeps the adapter's effect intact.")
214 +
215 + Spacer()
216 +
217 + 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 + }
227 +
228 + 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 +}
246 +
247 +// MARK: - Import from Hugging Face
248 +
249 +private struct ImportCard: View {
250 + let controller: ConvertController
251 +
252 + @State private var repoID = ""
253 + @State private var quantize = true
254 + @State private var config = QuantConfig()
255 +
256 + 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)
263 +
264 + HStack(spacing: ZyquoTheme.spacing12) {
265 + TextField("org/repo (e.g. Qwen/Qwen3-0.6B)", text: $repoID)
266 + .textFieldStyle(.roundedBorder)
267 + .frame(maxWidth: 340)
268 +
269 + 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 + }
276 +
277 + Spacer()
278 +
279 + 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 + }
289 +
290 + 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 + }
21 305 }
22 306 }
added Sources/ZyquoMLX/Views/DiscoverView.swift +204 −0
@@ -0,0 +1,204 @@
1 +//
2 +// DiscoverView.swift
3 +// Zyquo MLX
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import SwiftUI
10 +
11 +/// Models › Discover: Featured catalog + live mlx-community search with
12 +/// resumable downloads (charter Phase 6).
13 +struct DiscoverView: View {
14 + @Bindable var model: AppModel
15 + @State private var query = ""
16 + @State private var results: [HubModel] = []
17 + @State private var isSearching = false
18 + @State private var searchError: String?
19 + @State private var downloads: [String: DownloadProgress] = [:]
20 + @State private var downloadErrors: [String: String] = [:]
21 +
22 + private var installedIDs: Set<String> {
23 + Set(model.models.compactMap(\.repoID))
24 + }
25 +
26 + var body: some View {
27 + VStack(spacing: 0) {
28 + searchField
29 + .padding(.horizontal, ZyquoTheme.spacing20)
30 + .padding(.bottom, ZyquoTheme.spacing12)
31 +
32 + ScrollView {
33 + LazyVStack(alignment: .leading, spacing: ZyquoTheme.spacing8) {
34 + if let searchError {
35 + Text(searchError)
36 + .font(ZyquoTheme.captionFont)
37 + .foregroundStyle(ZyquoTheme.danger)
38 + }
39 + if query.isEmpty {
40 + Text("Featured — live-verified for this Mac")
41 + .font(ZyquoTheme.headlineFont)
42 + .foregroundStyle(ZyquoTheme.textPrimary)
43 + .padding(.bottom, ZyquoTheme.spacing4)
44 + ForEach(Catalog.featured) { entry in
45 + DiscoverRow(
46 + repoID: entry.id,
47 + title: entry.name,
48 + subtitle: "\(entry.params) · \(entry.quant) · \(ByteCountFormatter.string(fromByteCount: entry.weightBytes, countStyle: .file))\(entry.blurb)",
49 + type: entry.type,
50 + verdict: entry.verdict,
51 + installed: installedIDs.contains(entry.id),
52 + progress: downloads[entry.id],
53 + error: downloadErrors[entry.id],
54 + download: { download(repo: entry.id) },
55 + cancel: { Task { await DownloadManager.shared.cancel(repo: entry.id) } })
56 + }
57 + } else if isSearching {
58 + HStack {
59 + ProgressView().controlSize(.small)
60 + Text("Searching mlx models…")
61 + .font(ZyquoTheme.captionFont)
62 + .foregroundStyle(ZyquoTheme.textSecondary)
63 + }
64 + .frame(maxWidth: .infinity)
65 + .padding(.top, ZyquoTheme.spacing32)
66 + } else {
67 + ForEach(results) { result in
68 + DiscoverRow(
69 + repoID: result.id,
70 + title: result.id,
71 + subtitle: "\(result.downloads.formatted()) downloads · \(result.likes) likes"
72 + + (result.gated ? " · gated" : ""),
73 + type: result.modelType,
74 + verdict: nil,
75 + installed: installedIDs.contains(result.id),
76 + progress: downloads[result.id],
77 + error: downloadErrors[result.id],
78 + download: { download(repo: result.id) },
79 + cancel: { Task { await DownloadManager.shared.cancel(repo: result.id) } })
80 + }
81 + if results.isEmpty {
82 + Text("No MLX models match “\(query)”.")
83 + .font(ZyquoTheme.bodyFont)
84 + .foregroundStyle(ZyquoTheme.textSecondary)
85 + .frame(maxWidth: .infinity)
86 + .padding(.top, ZyquoTheme.spacing32)
87 + }
88 + }
89 + }
90 + .padding(.horizontal, ZyquoTheme.spacing20)
91 + .padding(.bottom, ZyquoTheme.spacing20)
92 + }
93 + }
94 + .task(id: query) {
95 + guard !query.isEmpty else { return }
96 + isSearching = true
97 + searchError = nil
98 + try? await Task.sleep(for: .milliseconds(350)) // debounce
99 + guard !Task.isCancelled else { return }
100 + do {
101 + results = try await HubService.shared.search(query: query)
102 + } catch {
103 + searchError = error.localizedDescription
104 + }
105 + isSearching = false
106 + }
107 + }
108 +
109 + private var searchField: some View {
110 + HStack(spacing: ZyquoTheme.spacing8) {
111 + Image(systemName: "magnifyingglass")
112 + .foregroundStyle(ZyquoTheme.textTertiary)
113 + TextField("Search mlx-community models…", text: $query)
114 + .textFieldStyle(.plain)
115 + .font(ZyquoTheme.bodyFont)
116 + }
117 + .padding(.horizontal, ZyquoTheme.spacing12)
118 + .padding(.vertical, ZyquoTheme.spacing8)
119 + .background(ZyquoTheme.surfaceSecondary)
120 + .clipShape(RoundedRectangle(cornerRadius: ZyquoTheme.radiusSmall))
121 + }
122 +
123 + private func download(repo: String) {
124 + downloadErrors[repo] = nil
125 + Task {
126 + let events = await DownloadManager.shared.download(repo: repo)
127 + for await event in events {
128 + switch event {
129 + case .progress(let progress):
130 + downloads[repo] = progress
131 + case .finished:
132 + downloads[repo] = nil
133 + await model.refresh()
134 + case .failed(let message):
135 + downloads[repo] = nil
136 + downloadErrors[repo] = message
137 + }
138 + }
139 + }
140 + }
141 +}
142 +
143 +private struct DiscoverRow: View {
144 + let repoID: String
145 + let title: String
146 + let subtitle: String
147 + let type: ModelType
148 + let verdict: MemoryVerdict?
149 + let installed: Bool
150 + let progress: DownloadProgress?
151 + let error: String?
152 + let download: () -> Void
153 + let cancel: () -> Void
154 +
155 + var body: some View {
156 + HStack(spacing: ZyquoTheme.spacing12) {
157 + VStack(alignment: .leading, spacing: ZyquoTheme.spacing2) {
158 + Text(title)
159 + .font(ZyquoTheme.bodyFont.weight(.medium))
160 + .foregroundStyle(ZyquoTheme.textPrimary)
161 + .lineLimit(1)
162 + Text(error ?? subtitle)
163 + .font(ZyquoTheme.captionFont)
164 + .foregroundStyle(error != nil ? ZyquoTheme.danger : ZyquoTheme.textSecondary)
165 + .lineLimit(2)
166 + }
167 +
168 + Spacer()
169 +
170 + TypeBadge(type: type)
171 + if let verdict {
172 + VerdictBadge(verdict: verdict)
173 + }
174 +
175 + if installed {
176 + StatusPill(text: "Installed", color: ZyquoTheme.success)
177 + } else if let progress {
178 + HStack(spacing: ZyquoTheme.spacing8) {
179 + ProgressView(value: progress.fraction)
180 + .frame(width: 90)
181 + Text("\(Int(progress.fraction * 100))%")
182 + .font(ZyquoTheme.monoSmallFont)
183 + .foregroundStyle(ZyquoTheme.textSecondary)
184 + Button(action: cancel) {
185 + Image(systemName: "pause.circle")
186 + .foregroundStyle(ZyquoTheme.textSecondary)
187 + }
188 + .buttonStyle(.plain)
189 + .help("Pause (resumes from the same byte)")
190 + }
191 + } else {
192 + Button(action: download) {
193 + Image(systemName: "arrow.down.circle.fill")
194 + .font(.system(size: 20))
195 + .foregroundStyle(ZyquoTheme.accent)
196 + }
197 + .buttonStyle(.plain)
198 + .help("Download")
199 + }
200 + }
201 + .padding(ZyquoTheme.spacing12)
202 + .zyquoCard()
203 + }
204 +}
modified Sources/ZyquoMLX/Views/EvaluateView.swift +142 −6
@@ -8,15 +8,151 @@
8 8
9 9 import SwiftUI
10 10
11 /// Evaluate section: held-out loss/perplexity + base-vs-tuned scorecard
12 /// (wired in Phase 6).
11 +/// Evaluate section: base-vs-tuned side-by-side qualitative comparison with
12 +/// per-side stats (held-out perplexity via the Python bridge uses the same
13 +/// engine as `--test`; qualitative compare is the flagship workflow).
13 14 struct EvaluateView: View {
14 15 @Bindable var model: AppModel
16 + @State private var controller = EvaluateController()
15 17
16 18 var body: some View {
17 EmptyStateView(
18 icon: "checkmark.seal",
19 title: "Prove Your Fine-Tune",
20 message: "Measure held-out loss and perplexity, then compare base and fine-tuned models side by side on your own prompts — a compact scorecard for every run.")
19 + if model.models.count < 2 {
20 + EmptyStateView(
21 + icon: "checkmark.seal",
22 + title: "Prove Your Fine-Tune",
23 + message: "Once you have a base model and a fine-tuned (fused) variant, compare them side by side on the same prompt — with tokens/sec and TTFT for each.")
24 + } else {
25 + VStack(spacing: 0) {
26 + HStack(spacing: ZyquoTheme.spacing12) {
27 + Picker("Base", selection: $controller.baseID) {
28 + Text("Choose…").tag(String?.none)
29 + ForEach(model.models.filter { $0.type == .llm }) {
30 + Text($0.name).tag(String?.some($0.id))
31 + }
32 + }
33 + Picker("Candidate", selection: $controller.candidateID) {
34 + Text("Choose…").tag(String?.none)
35 + ForEach(model.models.filter { $0.type == .llm }) {
36 + Text($0.name).tag(String?.some($0.id))
37 + }
38 + }
39 + }
40 + .padding(ZyquoTheme.spacing12)
41 +
42 + HStack(spacing: ZyquoTheme.spacing8) {
43 + TextField("Prompt to compare on…", text: $controller.prompt)
44 + .textFieldStyle(.roundedBorder)
45 + .onSubmit { controller.run(models: model.models) }
46 + Button(controller.isRunning ? "Running…" : "Compare") {
47 + controller.run(models: model.models)
48 + }
49 + .buttonStyle(.borderedProminent)
50 + .tint(ZyquoTheme.accent)
51 + .disabled(controller.isRunning || controller.baseID == nil
52 + || controller.candidateID == nil || controller.prompt.isEmpty)
53 + }
54 + .padding(.horizontal, ZyquoTheme.spacing12)
55 + .padding(.bottom, ZyquoTheme.spacing12)
56 +
57 + Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline)
58 +
59 + HStack(alignment: .top, spacing: 0) {
60 + ResultColumn(title: "Base", output: controller.baseOutput, stats: controller.baseStats)
61 + Rectangle().fill(ZyquoTheme.border).frame(width: ZyquoTheme.hairline)
62 + ResultColumn(title: "Candidate", output: controller.candidateOutput, stats: controller.candidateStats)
63 + }
64 +
65 + if let error = controller.errorMessage {
66 + Text(error)
67 + .font(ZyquoTheme.captionFont)
68 + .foregroundStyle(ZyquoTheme.danger)
69 + .padding(ZyquoTheme.spacing8)
70 + }
71 + }
72 + }
73 + }
74 +}
75 +
76 +private struct ResultColumn: View {
77 + let title: String
78 + let output: String
79 + let stats: InferenceStats?
80 +
81 + var body: some View {
82 + VStack(alignment: .leading, spacing: ZyquoTheme.spacing8) {
83 + HStack {
84 + Text(title)
85 + .font(ZyquoTheme.headlineFont)
86 + Spacer()
87 + if let stats {
88 + Text(String(format: "%.1f tok/s · TTFT %.2fs", stats.tokensPerSecond, stats.ttft))
89 + .font(ZyquoTheme.monoSmallFont)
90 + .foregroundStyle(ZyquoTheme.chartThroughput)
91 + }
92 + }
93 + ScrollView {
94 + Text(output.isEmpty ? "—" : output)
95 + .font(ZyquoTheme.bodyFont)
96 + .foregroundStyle(ZyquoTheme.textPrimary)
97 + .textSelection(.enabled)
98 + .frame(maxWidth: .infinity, alignment: .leading)
99 + }
100 + }
101 + .padding(ZyquoTheme.spacing16)
102 + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
103 + }
104 +}
105 +
106 +@Observable
107 +@MainActor
108 +final class EvaluateController {
109 + var baseID: String?
110 + var candidateID: String?
111 + var prompt = "Explain what a LoRA adapter is in one sentence."
112 + var baseOutput = ""
113 + var candidateOutput = ""
114 + var baseStats: InferenceStats?
115 + var candidateStats: InferenceStats?
116 + var isRunning = false
117 + var errorMessage: String?
118 +
119 + /// Sequential run (one model in memory at a time — verifiable unload
120 + /// between the two sides).
121 + func run(models: [LocalModel]) {
122 + guard let base = models.first(where: { $0.id == baseID }),
123 + let candidate = models.first(where: { $0.id == candidateID })
124 + else { return }
125 + isRunning = true
126 + errorMessage = nil
127 + baseOutput = ""
128 + candidateOutput = ""
129 +
130 + Task {
131 + do {
132 + (baseOutput, baseStats) = try await generate(model: base)
133 + (candidateOutput, candidateStats) = try await generate(model: candidate)
134 + } catch {
135 + errorMessage = error.localizedDescription
136 + }
137 + _ = try? await InferenceEngine.shared.unload()
138 + isRunning = false
139 + }
140 + }
141 +
142 + private func generate(model: LocalModel) async throws -> (String, InferenceStats?) {
143 + try await InferenceEngine.shared.load(model: model)
144 + var output = ""
145 + var stats: InferenceStats?
146 + var params = GenerationParams()
147 + params.maxTokens = 512
148 + let stream = try await InferenceEngine.shared.generate(
149 + messages: [.user(prompt)], params: params)
150 + for try await event in stream {
151 + switch event {
152 + case .chunk(let piece): output += piece
153 + case .finished(let s): stats = s
154 + }
155 + }
156 + return (output, stats)
21 157 }
22 158 }
modified Sources/ZyquoMLX/Views/ModelsView.swift +1 −5
@@ -57,11 +57,7 @@ struct ModelsView: View {
57 57 }
58 58
59 59 private var discover: some View {
60 // Live Hub search lands in Phase 6 (HubService); the state is designed now.
61 EmptyStateView(
62 icon: "sparkle.magnifyingglass",
63 title: "Discover MLX Models",
64 message: "Search the mlx-community catalog on Hugging Face — text LLMs, vision-language models, embeddings, and more — with RAM compatibility badges for this Mac.")
60 + DiscoverView(model: model)
65 61 }
66 62 }
67 63
modified Sources/ZyquoMLX/Views/PlaygroundView.swift +363 −4
@@ -6,12 +6,16 @@
6 6 // Mail: contact@spboucher.ai
7 7 //
8 8
9 +import MLXLMCommon
9 10 import SwiftUI
11 +import UniformTypeIdentifiers
10 12
11 /// Playground section: interactive inference per model type (streaming chat,
12 /// image+text, embeddings inspector — wired in Phase 6).
13 +/// Playground: interactive inference per model type — streaming chat for
14 +/// LLM/VLM (images attach for VLM), a text→vector inspector with similarity
15 +/// for embeddings, per-run stats, and verifiable load/unload.
13 16 struct PlaygroundView: View {
14 17 @Bindable var model: AppModel
18 + @State private var session = PlaygroundSession()
15 19
16 20 var body: some View {
17 21 if model.models.isEmpty {
@@ -20,10 +24,365 @@ struct PlaygroundView: View {
20 24 title: "The Playground Awaits",
21 25 message: "Load any local model and interact with it — streaming chat for LLMs, images for vision models, a vector inspector for embeddings — with live tokens/sec, time-to-first-token, and memory stats.")
22 26 } else {
27 + VStack(spacing: 0) {
28 + playgroundHeader
29 + Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline)
30 + content
31 + }
32 + }
33 + }
34 +
35 + private var playgroundHeader: some View {
36 + HStack(spacing: ZyquoTheme.spacing12) {
37 + Picker("Model", selection: $session.selectedModelID) {
38 + Text("Choose a model…").tag(String?.none)
39 + ForEach(model.models.filter { $0.type.isSwiftNative }) { item in
40 + Text("\(item.name) (\(item.type.displayName))").tag(String?.some(item.id))
41 + }
42 + }
43 + .frame(maxWidth: 420)
44 +
45 + if session.isLoading {
46 + ProgressView().controlSize(.small)
47 + Text("Loading…")
48 + .font(ZyquoTheme.captionFont)
49 + .foregroundStyle(ZyquoTheme.textSecondary)
50 + } else if session.loadedModel != nil {
51 + StatusPill(text: "Loaded", color: ZyquoTheme.success)
52 + Button("Unload") {
53 + Task { await session.unload() }
54 + }
55 + .controlSize(.small)
56 + if let freed = session.lastFreedBytes {
57 + Text("freed \(ByteCountFormatter.string(fromByteCount: freed, countStyle: .memory))")
58 + .font(ZyquoTheme.captionFont)
59 + .foregroundStyle(ZyquoTheme.textTertiary)
60 + }
61 + }
62 +
63 + Spacer()
64 +
65 + if let stats = session.lastStats {
66 + Text(String(format: "%.1f tok/s · TTFT %.2fs · %d tokens",
67 + stats.tokensPerSecond, stats.ttft, stats.generatedTokens))
68 + .font(ZyquoTheme.monoSmallFont)
69 + .foregroundStyle(ZyquoTheme.textSecondary)
70 + }
71 + }
72 + .padding(.horizontal, ZyquoTheme.spacing20)
73 + .padding(.vertical, ZyquoTheme.spacing8)
74 + .onChange(of: session.selectedModelID) { _, newID in
75 + guard let newID, let item = model.models.first(where: { $0.id == newID }) else { return }
76 + Task { await session.load(item) }
77 + }
78 + }
79 +
80 + @ViewBuilder
81 + private var content: some View {
82 + switch session.loadedModel?.type {
83 + case .embedding:
84 + EmbeddingInspector(session: session)
85 + case .llm, .vlm:
86 + ChatPanel(session: session)
87 + default:
23 88 EmptyStateView(
24 icon: "bubble.left.and.text.bubble.right",
89 + icon: "cpu",
25 90 title: "Pick a Model to Begin",
26 message: "You have \(model.models.count) model\(model.models.count > 1 ? "s" : "") installed. The interactive playground — streaming chat, VLM images, and the embedding inspector — comes online with Phase 6.")
91 + message: "Choose an installed model above. LLMs and vision models open a streaming chat; embedding models open the vector inspector.")
92 + }
93 + }
94 +}
95 +
96 +// MARK: - Session state
97 +
98 +@Observable
99 +@MainActor
100 +final class PlaygroundSession {
101 + var selectedModelID: String?
102 + var loadedModel: LocalModel?
103 + var isLoading = false
104 + var isGenerating = false
105 + var lastStats: InferenceStats?
106 + var lastFreedBytes: Int64?
107 + var errorMessage: String?
108 +
109 + // Chat state
110 + var transcript: [(role: String, text: String)] = []
111 + var prompt = ""
112 + var attachedImage: URL?
113 + private var generationTask: Task<Void, Never>?
114 +
115 + // Embedding state
116 + var embedInput = "The quick brown fox\nA fast auburn fox\nQuarterly revenue grew 4%"
117 + var embedResults: [(text: String, vector: [Float])] = []
118 + var similarities: [(a: String, b: String, score: Float)] = []
119 +
120 + func load(_ model: LocalModel) async {
121 + isLoading = true
122 + errorMessage = nil
123 + transcript = []
124 + embedResults = []
125 + similarities = []
126 + lastStats = nil
127 + do {
128 + try await InferenceEngine.shared.load(model: model)
129 + loadedModel = model
130 + } catch {
131 + errorMessage = error.localizedDescription
132 + loadedModel = nil
133 + }
134 + isLoading = false
135 + }
136 +
137 + func unload() async {
138 + stop()
139 + lastFreedBytes = try? await InferenceEngine.shared.unload()
140 + loadedModel = nil
141 + selectedModelID = nil
142 + }
143 +
144 + func send() {
145 + let text = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
146 + guard !text.isEmpty, !isGenerating, loadedModel != nil else { return }
147 + prompt = ""
148 + let image = attachedImage
149 + attachedImage = nil
150 + transcript.append((role: "user", text: text))
151 + transcript.append((role: "assistant", text: ""))
152 + isGenerating = true
153 +
154 + var messages: [Chat.Message] = transcript.dropLast(2).map {
155 + $0.role == "user" ? .user($0.text) : .assistant($0.text)
156 + }
157 + if let image {
158 + messages.append(.user(text, images: [.url(image)]))
159 + } else {
160 + messages.append(.user(text))
161 + }
162 +
163 + generationTask = Task {
164 + do {
165 + let stream = try await InferenceEngine.shared.generate(
166 + messages: messages, params: GenerationParams())
167 + for try await event in stream {
168 + switch event {
169 + case .chunk(let piece):
170 + transcript[transcript.count - 1].text += piece
171 + case .finished(let stats):
172 + lastStats = stats
173 + }
174 + }
175 + } catch {
176 + errorMessage = error.localizedDescription
177 + }
178 + isGenerating = false
179 + }
180 + }
181 +
182 + func stop() {
183 + generationTask?.cancel()
184 + generationTask = nil
185 + isGenerating = false
186 + }
187 +
188 + func runEmbedding() {
189 + let texts = embedInput.split(separator: "\n").map(String.init).filter { !$0.isEmpty }
190 + guard !texts.isEmpty else { return }
191 + Task {
192 + do {
193 + let vectors = try await InferenceEngine.shared.embed(texts: texts)
194 + embedResults = Array(zip(texts, vectors))
195 + similarities = []
196 + for i in 0..<vectors.count {
197 + for j in (i + 1)..<vectors.count {
198 + similarities.append((
199 + a: texts[i], b: texts[j],
200 + score: InferenceEngine.cosineSimilarity(vectors[i], vectors[j])))
201 + }
202 + }
203 + similarities.sort { $0.score > $1.score }
204 + } catch {
205 + errorMessage = error.localizedDescription
206 + }
207 + }
208 + }
209 +}
210 +
211 +// MARK: - Chat panel
212 +
213 +private struct ChatPanel: View {
214 + @Bindable var session: PlaygroundSession
215 + @State private var isPickingImage = false
216 +
217 + var body: some View {
218 + VStack(spacing: 0) {
219 + if let error = session.errorMessage {
220 + Text(error)
221 + .font(ZyquoTheme.captionFont)
222 + .foregroundStyle(ZyquoTheme.danger)
223 + .padding(ZyquoTheme.spacing8)
224 + }
225 +
226 + ScrollViewReader { proxy in
227 + ScrollView {
228 + LazyVStack(alignment: .leading, spacing: ZyquoTheme.spacing12) {
229 + ForEach(Array(session.transcript.enumerated()), id: \.offset) { index, entry in
230 + MessageBubble(role: entry.role, text: entry.text)
231 + .id(index)
232 + }
233 + }
234 + .padding(ZyquoTheme.spacing20)
235 + }
236 + .onChange(of: session.transcript.last?.text) {
237 + proxy.scrollTo(session.transcript.count - 1, anchor: .bottom)
238 + }
239 + }
240 +
241 + Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline)
242 +
243 + HStack(spacing: ZyquoTheme.spacing8) {
244 + if session.loadedModel?.type == .vlm {
245 + Button {
246 + isPickingImage = true
247 + } label: {
248 + Image(systemName: session.attachedImage == nil ? "photo" : "photo.fill")
249 + .foregroundStyle(
250 + session.attachedImage == nil
251 + ? ZyquoTheme.textSecondary : ZyquoTheme.accent)
252 + }
253 + .buttonStyle(.plain)
254 + .help(session.attachedImage?.lastPathComponent ?? "Attach an image")
255 + }
256 +
257 + TextField("Message the model…", text: $session.prompt, axis: .vertical)
258 + .textFieldStyle(.plain)
259 + .font(ZyquoTheme.bodyFont)
260 + .lineLimit(1...5)
261 + .onSubmit { session.send() }
262 +
263 + if session.isGenerating {
264 + Button {
265 + session.stop()
266 + } label: {
267 + Image(systemName: "stop.circle.fill")
268 + .font(.system(size: 20))
269 + .foregroundStyle(ZyquoTheme.danger)
270 + }
271 + .buttonStyle(.plain)
272 + } else {
273 + Button {
274 + session.send()
275 + } label: {
276 + Image(systemName: "arrow.up.circle.fill")
277 + .font(.system(size: 20))
278 + .foregroundStyle(
279 + session.prompt.isEmpty ? ZyquoTheme.textTertiary : ZyquoTheme.accent)
280 + }
281 + .buttonStyle(.plain)
282 + .disabled(session.prompt.isEmpty)
283 + }
284 + }
285 + .padding(ZyquoTheme.spacing12)
286 + .background(ZyquoTheme.surface)
287 + }
288 + .fileImporter(isPresented: $isPickingImage, allowedContentTypes: [.image]) { result in
289 + if case .success(let url) = result { session.attachedImage = url }
290 + }
291 + }
292 +}
293 +
294 +private struct MessageBubble: View {
295 + let role: String
296 + let text: String
297 +
298 + var body: some View {
299 + HStack {
300 + if role == "user" { Spacer(minLength: 80) }
301 + Text(text.isEmpty ? "…" : text)
302 + .font(ZyquoTheme.bodyFont)
303 + .foregroundStyle(ZyquoTheme.textPrimary)
304 + .textSelection(.enabled)
305 + .padding(ZyquoTheme.spacing12)
306 + .background(role == "user" ? ZyquoTheme.accentSubtle : ZyquoTheme.surface)
307 + .clipShape(RoundedRectangle(cornerRadius: ZyquoTheme.radiusMedium))
308 + .overlay(
309 + RoundedRectangle(cornerRadius: ZyquoTheme.radiusMedium)
310 + .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline))
311 + if role != "user" { Spacer(minLength: 80) }
312 + }
313 + }
314 +}
315 +
316 +// MARK: - Embedding inspector
317 +
318 +private struct EmbeddingInspector: View {
319 + @Bindable var session: PlaygroundSession
320 +
321 + var body: some View {
322 + ScrollView {
323 + VStack(alignment: .leading, spacing: ZyquoTheme.spacing16) {
324 + Text("One text per line — embed them and compare similarities.")
325 + .font(ZyquoTheme.captionFont)
326 + .foregroundStyle(ZyquoTheme.textSecondary)
327 +
328 + TextEditor(text: $session.embedInput)
329 + .font(ZyquoTheme.monoFont)
330 + .frame(height: 120)
331 + .padding(ZyquoTheme.spacing8)
332 + .background(ZyquoTheme.surfaceSecondary)
333 + .clipShape(RoundedRectangle(cornerRadius: ZyquoTheme.radiusSmall))
334 +
335 + Button("Embed") { session.runEmbedding() }
336 + .buttonStyle(.borderedProminent)
337 + .tint(ZyquoTheme.accent)
338 +
339 + if !session.embedResults.isEmpty {
340 + VStack(alignment: .leading, spacing: ZyquoTheme.spacing8) {
341 + Text("Vectors")
342 + .font(ZyquoTheme.headlineFont)
343 + ForEach(session.embedResults, id: \.text) { result in
344 + HStack {
345 + Text("dim \(result.vector.count)")
346 + .font(ZyquoTheme.monoSmallFont)
347 + .foregroundStyle(ZyquoTheme.chartThroughput)
348 + Text(result.vector.prefix(6)
349 + .map { String(format: "%+.3f", $0) }
350 + .joined(separator: " ") + " …")
351 + .font(ZyquoTheme.monoSmallFont)
352 + .foregroundStyle(ZyquoTheme.textSecondary)
353 + Text(result.text)
354 + .font(ZyquoTheme.captionFont)
355 + .foregroundStyle(ZyquoTheme.textPrimary)
356 + .lineLimit(1)
357 + }
358 + }
359 + }
360 + .padding(ZyquoTheme.spacing12)
361 + .frame(maxWidth: .infinity, alignment: .leading)
362 + .zyquoCard()
363 +
364 + VStack(alignment: .leading, spacing: ZyquoTheme.spacing8) {
365 + Text("Cosine similarity")
366 + .font(ZyquoTheme.headlineFont)
367 + ForEach(Array(session.similarities.enumerated()), id: \.offset) { _, pair in
368 + HStack {
369 + Text(String(format: "%.4f", pair.score))
370 + .font(ZyquoTheme.monoFont)
371 + .foregroundStyle(
372 + pair.score > 0.8 ? ZyquoTheme.success : ZyquoTheme.textSecondary)
373 + Text("\(pair.a)\(pair.b)")
374 + .font(ZyquoTheme.captionFont)
375 + .foregroundStyle(ZyquoTheme.textPrimary)
376 + .lineLimit(1)
377 + }
378 + }
379 + }
380 + .padding(ZyquoTheme.spacing12)
381 + .frame(maxWidth: .infinity, alignment: .leading)
382 + .zyquoCard()
383 + }
384 + }
385 + .padding(ZyquoTheme.spacing20)
27 386 }
28 387 }
29 388 }
modified Sources/ZyquoMLX/Views/SettingsView.swift +30 −0
@@ -21,6 +21,8 @@ struct SettingsView: View {
21 21 .tabItem { Label("Python", systemImage: "terminal") }
22 22 StorageSettings()
23 23 .tabItem { Label("Storage", systemImage: "internaldrive") }
24 + HuggingFaceSettings()
25 + .tabItem { Label("Hugging Face", systemImage: "key") }
24 26 AppearanceSettings()
25 27 .tabItem { Label("Appearance", systemImage: "paintpalette") }
26 28 }
@@ -123,6 +125,34 @@ private struct StorageSettings: View {
123 125 }
124 126 }
125 127
128 +private struct HuggingFaceSettings: View {
129 + @State private var token = HFTokenStore.token ?? ""
130 + @State private var saved = false
131 +
132 + var body: some View {
133 + Form {
134 + Section("Access token") {
135 + SecureField("hf_…", text: $token)
136 + Text("Stored in your login Keychain — used for gated models and higher rate limits.")
137 + .font(ZyquoTheme.captionFont)
138 + .foregroundStyle(ZyquoTheme.textSecondary)
139 + HStack {
140 + Button("Save") {
141 + HFTokenStore.save(token.trimmingCharacters(in: .whitespaces))
142 + saved = true
143 + }
144 + if saved {
145 + Text("Saved ✓")
146 + .font(ZyquoTheme.captionFont)
147 + .foregroundStyle(ZyquoTheme.success)
148 + }
149 + }
150 + }
151 + }
152 + .formStyle(.grouped)
153 + }
154 +}
155 +
126 156 private struct AppearanceSettings: View {
127 157 @AppStorage("appearance") private var appearance = "system"
128 158
modified Sources/ZyquoMLX/Views/TrainView.swift +454 −12
@@ -6,29 +6,471 @@
6 6 // Mail: contact@spboucher.ai
7 7 //
8 8
9 +import Charts
9 10 import SwiftUI
10 11
11 /// Train section: run history list (run configurator + live run detail with
12 /// loss charts are wired in Phase 6).
12 +/// Train section: run configurator + run list + live run detail with loss
13 +/// curves, throughput, log console, checkpoints, and cancel/resume.
13 14 struct TrainView: View {
14 15 @Bindable var model: AppModel
16 + @State private var controller = TrainingController()
17 + @State private var isConfiguring = false
18 + @State private var selectedRunID: UUID?
15 19
16 20 var body: some View {
17 if model.runs.isEmpty {
18 EmptyStateView(
19 icon: "flame",
20 title: "No Training Runs Yet",
21 message: "Fine-tune any local model on your own data with LoRA, QLoRA, or full fine-tuning — live loss curves, checkpoints, and memory gating included. Add a model and a dataset first, then start your first run.")
22 } else {
23 ScrollView {
24 LazyVStack(spacing: ZyquoTheme.spacing8) {
25 ForEach(model.runs) { run in
21 + Group {
22 + if let runID = selectedRunID ?? controller.activeRun?.id,
23 + let run = model.runs.first(where: { $0.id == runID }) ?? controller.activeRun
24 + {
25 + RunDetailView(
26 + run: run, model: model, controller: controller,
27 + back: {
28 + selectedRunID = nil
29 + Task { await model.refresh() }
30 + })
31 + } else if model.runs.isEmpty {
32 + EmptyStateView(
33 + icon: "flame",
34 + title: canConfigure ? "Forge Your First Fine-Tune" : "No Training Runs Yet",
35 + message: canConfigure
36 + ? "Pick a base model, a dataset, and a method — Zyquo gates configurations against this Mac's memory and streams live loss curves while it trains."
37 + : "Fine-tune any local model on your own data with LoRA, QLoRA, or full fine-tuning. Add a model and a dataset first, then start your first run.",
38 + actionLabel: canConfigure ? "New Run" : nil,
39 + action: canConfigure ? { isConfiguring = true } : nil)
40 + } else {
41 + runList
42 + }
43 + }
44 + .sheet(isPresented: $isConfiguring) {
45 + RunConfiguratorView(model: model, controller: controller) { run in
46 + selectedRunID = run.id
47 + Task { await model.refresh() }
48 + }
49 + }
50 + }
51 +
52 + private var canConfigure: Bool {
53 + !model.models.isEmpty && !model.datasets.isEmpty
54 + }
55 +
56 + private var runList: some View {
57 + ScrollView {
58 + LazyVStack(spacing: ZyquoTheme.spacing8) {
59 + HStack {
60 + Spacer()
61 + Button {
62 + isConfiguring = true
63 + } label: {
64 + Label("New Run", systemImage: "plus")
65 + }
66 + .disabled(!canConfigure)
67 + }
68 + ForEach(model.runs) { run in
69 + Button {
70 + selectedRunID = run.id
71 + } label: {
26 72 RunRow(run: run)
27 73 }
74 + .buttonStyle(.plain)
75 + }
76 + }
77 + .padding(ZyquoTheme.spacing20)
78 + }
79 + }
80 +}
81 +
82 +// MARK: - Live training controller
83 +
84 +@Observable
85 +@MainActor
86 +final class TrainingController {
87 + var activeRun: TrainingRun?
88 + var metrics: [TrainingMetric] = []
89 + var logLines: [String] = []
90 + var isTraining = false
91 + var lastError: String?
92 +
93 + func start(run: TrainingRun, baseModel: LocalModel, dataset: Dataset, resume: Bool = false) {
94 + metrics = resume ? metrics : []
95 + logLines.append(resume ? "Resuming (warm start from latest adapter)…" : "Starting run…")
96 + activeRun = run
97 + isTraining = true
98 + lastError = nil
99 +
100 + Task {
101 + do {
102 + let events = try await TrainingService.shared.start(
103 + run: run, baseModel: baseModel, dataset: dataset, resume: resume)
104 + for await event in events {
105 + switch event {
106 + case .started(let model, let iterations):
107 + logLines.append("Training \(model) for \(iterations) iterations")
108 + case .metric(let metric):
109 + metrics.append(metric)
110 + if let loss = metric.trainLoss {
111 + logLines.append(String(
112 + format: "iter %d train %.3f %.0f tok/s peak %.1f GB",
113 + metric.iteration, loss, metric.tokensPerSecond ?? 0,
114 + metric.peakMemoryGB ?? 0))
115 + }
116 + if let loss = metric.valLoss {
117 + logLines.append(String(format: "iter %d VAL %.3f", metric.iteration, loss))
118 + }
119 + case .checkpointSaved(let name, _):
120 + logLines.append("checkpoint \(name)")
121 + case .finished:
122 + logLines.append("Training finished ✅")
123 + case .failed(let message):
124 + logLines.append("FAILED: \(message)")
125 + lastError = message
126 + }
127 + }
128 + } catch {
129 + lastError = error.localizedDescription
130 + logLines.append("FAILED: \(error.localizedDescription)")
131 + }
132 + isTraining = false
133 + }
134 + }
135 +
136 + func cancel() {
137 + Task { await TrainingService.shared.cancel() }
138 + logLines.append("Cancelling…")
139 + }
140 +}
141 +
142 +// MARK: - Configurator
143 +
144 +struct RunConfiguratorView: View {
145 + @Bindable var model: AppModel
146 + let controller: TrainingController
147 + let onStart: (TrainingRun) -> Void
148 +
149 + @Environment(\.dismiss) private var dismiss
150 + @State private var baseModelID: String?
151 + @State private var datasetID: UUID?
152 + @State private var method: FineTuneMethod = .qlora
153 + @State private var hp = HyperParams()
154 + @State private var runName = ""
155 + @State private var startError: String?
156 +
157 + private var baseModel: LocalModel? { model.models.first { $0.id == baseModelID } }
158 + private var dataset: Dataset? { model.datasets.first { $0.id == datasetID } }
159 +
160 + private var verdict: MemoryVerdict? {
161 + baseModel.map { MemoryAdvisor.trainingVerdict(for: $0, method: method, params: hp) }
162 + }
163 +
164 + var body: some View {
165 + VStack(spacing: 0) {
166 + Text("New Training Run")
167 + .font(ZyquoTheme.titleFont)
168 + .padding(.top, ZyquoTheme.spacing20)
169 +
170 + Form {
171 + Section("Base & data") {
172 + Picker("Base model", selection: $baseModelID) {
173 + Text("Choose…").tag(String?.none)
174 + ForEach(model.models.filter { $0.type == .llm }) { item in
175 + Text("\(item.name)\(item.quantization != nil ? " · quantized" : "")")
176 + .tag(String?.some(item.id))
177 + }
178 + }
179 + Picker("Dataset", selection: $datasetID) {
180 + Text("Choose…").tag(UUID?.none)
181 + ForEach(model.datasets) { dataset in
182 + Text("\(dataset.name) (\(dataset.trainCount) samples)")
183 + .tag(UUID?.some(dataset.id))
184 + }
185 + }
186 + Picker("Method", selection: $method) {
187 + ForEach(FineTuneMethod.allCases, id: \.self) {
188 + Text($0.displayName)
189 + }
190 + }
191 + .help("QLoRA trains adapters on a quantized base — the memory-efficient default. Full fine-tuning updates every weight and needs far more memory.")
192 + }
193 +
194 + Section("Hyperparameters") {
195 + Stepper("Iterations: \(hp.iterations)", value: $hp.iterations, in: 50...20000, step: 50)
196 + Stepper("Batch size: \(hp.batchSize)", value: $hp.batchSize, in: 1...16)
197 + .help("Larger batches train smoother but use more memory.")
198 + Stepper("LoRA rank: \(hp.rank)", value: $hp.rank, in: 2...64, step: 2)
199 + .help("Higher rank = more adapter capacity, slightly more memory.")
200 + Stepper("Layers to adapt: \(hp.numLayers)", value: $hp.numLayers, in: 4...64, step: 4)
201 + .help("LoRA is applied to the last N transformer layers (-1 = all).")
202 + TextField("Learning rate", value: $hp.learningRate, format: .number)
203 + Stepper("Max sequence length: \(hp.maxSeqLength)", value: $hp.maxSeqLength, in: 256...8192, step: 256)
204 + Toggle("Gradient checkpointing", isOn: $hp.gradCheckpoint)
205 + .help("Trades ~30% speed for a large activation-memory saving.")
206 + Toggle("Mask prompt (loss on completions only)", isOn: $hp.maskPrompt)
207 + }
208 +
209 + if let baseModel, let verdict {
210 + Section("Memory") {
211 + LabeledContent("Estimated need") {
212 + Text(ByteCountFormatter.string(
213 + fromByteCount: MemoryAdvisor.trainingBytes(
214 + for: baseModel, method: method, params: hp),
215 + countStyle: .memory))
216 + }
217 + LabeledContent("Verdict") { VerdictBadge(verdict: verdict) }
218 + if verdict != .comfortable {
219 + ForEach(
220 + MemoryAdvisor.suggestions(for: baseModel, method: method, params: hp),
221 + id: \.self
222 + ) { suggestion in
223 + Label(suggestion, systemImage: "lightbulb")
224 + .font(ZyquoTheme.captionFont)
225 + .foregroundStyle(ZyquoTheme.textSecondary)
226 + }
227 + }
228 + }
28 229 }
29 .padding(ZyquoTheme.spacing20)
230 +
231 + if let startError {
232 + Text(startError)
233 + .font(ZyquoTheme.captionFont)
234 + .foregroundStyle(ZyquoTheme.danger)
235 + }
236 + }
237 + .formStyle(.grouped)
238 +
239 + HStack {
240 + Button("Cancel") { dismiss() }
241 + Spacer()
242 + Button("Start Training") { start() }
243 + .buttonStyle(.borderedProminent)
244 + .tint(ZyquoTheme.accent)
245 + .disabled(baseModel == nil || dataset == nil || verdict == .wontFit)
30 246 }
247 + .padding(ZyquoTheme.spacing20)
31 248 }
249 + .frame(width: 560, height: 640)
250 + }
251 +
252 + private func start() {
253 + guard let baseModel, let dataset else { return }
254 + Task {
255 + do {
256 + let name = runName.isEmpty
257 + ? "\(baseModel.name) · \(method.displayName) · \(dataset.name)"
258 + : runName
259 + let run = try await TrainingService.shared.createRun(
260 + name: name, baseModel: baseModel, dataset: dataset,
261 + method: method, hyperParams: hp)
262 + controller.start(run: run, baseModel: baseModel, dataset: dataset)
263 + dismiss()
264 + onStart(run)
265 + } catch {
266 + startError = error.localizedDescription
267 + }
268 + }
269 + }
270 +}
271 +
272 +// MARK: - Run detail
273 +
274 +struct RunDetailView: View {
275 + let run: TrainingRun
276 + @Bindable var model: AppModel
277 + @Bindable var controller: TrainingController
278 + let back: () -> Void
279 +
280 + @State private var historicMetrics: [TrainingMetric] = []
281 +
282 + private var isActive: Bool { controller.activeRun?.id == run.id && controller.isTraining }
283 + private var metrics: [TrainingMetric] {
284 + isActive || controller.activeRun?.id == run.id ? controller.metrics : historicMetrics
285 + }
286 +
287 + var body: some View {
288 + ScrollView {
289 + VStack(alignment: .leading, spacing: ZyquoTheme.spacing16) {
290 + HStack {
291 + Button {
292 + back()
293 + } label: {
294 + Label("Runs", systemImage: "chevron.left")
295 + }
296 + .buttonStyle(.plain)
297 + .foregroundStyle(ZyquoTheme.accent)
298 +
299 + Spacer()
300 +
301 + if isActive {
302 + Button("Cancel Run") { controller.cancel() }
303 + .tint(ZyquoTheme.danger)
304 + } else if run.state == .cancelled || run.state == .failed {
305 + Button("Resume (warm start)") { resume() }
306 + .buttonStyle(.borderedProminent)
307 + .tint(ZyquoTheme.accent)
308 + }
309 + }
310 +
311 + VStack(alignment: .leading, spacing: ZyquoTheme.spacing4) {
312 + HStack {
313 + Text(run.name)
314 + .font(ZyquoTheme.titleFont)
315 + StatusPill(
316 + text: isActive ? "Running" : run.state.rawValue.capitalized,
317 + color: isActive ? ZyquoTheme.accent : ZyquoTheme.textSecondary)
318 + }
319 + Text("\(run.method.displayName) · \(run.hyperParams.iterations) iterations · rank \(run.hyperParams.rank) · batch \(run.hyperParams.batchSize)")
320 + .font(ZyquoTheme.captionFont)
321 + .foregroundStyle(ZyquoTheme.textSecondary)
322 + }
323 +
324 + // Loss chart
325 + lossChart
326 +
327 + // Throughput + memory strip
328 + if let latest = metrics.last(where: { $0.tokensPerSecond != nil }) {
329 + HStack(spacing: ZyquoTheme.spacing20) {
330 + stat("Tokens/sec", String(format: "%.0f", latest.tokensPerSecond ?? 0))
331 + stat("Iteration", "\(metrics.last?.iteration ?? 0)/\(run.hyperParams.iterations)")
332 + stat("Peak memory", String(format: "%.1f GB", latest.peakMemoryGB ?? 0))
333 + if let tokens = latest.trainedTokens {
334 + stat("Trained tokens", tokens.formatted())
335 + }
336 + }
337 + }
338 +
339 + // Log console
340 + VStack(alignment: .leading, spacing: ZyquoTheme.spacing8) {
341 + Text("Console")
342 + .font(ZyquoTheme.headlineFont)
343 + ScrollViewReader { proxy in
344 + ScrollView {
345 + VStack(alignment: .leading, spacing: 2) {
346 + ForEach(Array(consoleLines.enumerated()), id: \.offset) { index, line in
347 + Text(line)
348 + .font(ZyquoTheme.monoSmallFont)
349 + .foregroundStyle(ZyquoTheme.textSecondary)
350 + .id(index)
351 + }
352 + }
353 + .frame(maxWidth: .infinity, alignment: .leading)
354 + .padding(ZyquoTheme.spacing12)
355 + }
356 + .frame(height: 160)
357 + .background(ZyquoTheme.surfaceSecondary)
358 + .clipShape(RoundedRectangle(cornerRadius: ZyquoTheme.radiusSmall))
359 + .onChange(of: consoleLines.count) {
360 + proxy.scrollTo(consoleLines.count - 1, anchor: .bottom)
361 + }
362 + }
363 + }
364 +
365 + // Checkpoints
366 + if !checkpoints.isEmpty {
367 + VStack(alignment: .leading, spacing: ZyquoTheme.spacing8) {
368 + Text("Checkpoints")
369 + .font(ZyquoTheme.headlineFont)
370 + ForEach(checkpoints) { checkpoint in
371 + HStack {
372 + Image(systemName: "externaldrive")
373 + .foregroundStyle(ZyquoTheme.slate)
374 + Text(checkpoint.fileName)
375 + .font(ZyquoTheme.monoSmallFont)
376 + Spacer()
377 + Text("iter \(checkpoint.iteration)")
378 + .font(ZyquoTheme.captionFont)
379 + .foregroundStyle(ZyquoTheme.textSecondary)
380 + }
381 + .padding(ZyquoTheme.spacing8)
382 + .zyquoCard()
383 + }
384 + }
385 + }
386 + }
387 + .padding(ZyquoTheme.spacing20)
388 + }
389 + .task(id: run.id) {
390 + if controller.activeRun?.id != run.id {
391 + historicMetrics = await RunStore.shared.metricsHistory(for: run)
392 + }
393 + }
394 + }
395 +
396 + private var consoleLines: [String] {
397 + controller.activeRun?.id == run.id
398 + ? controller.logLines
399 + : metrics.compactMap { metric in
400 + if let loss = metric.trainLoss {
401 + return String(format: "iter %d train %.3f", metric.iteration, loss)
402 + }
403 + if let loss = metric.valLoss {
404 + return String(format: "iter %d VAL %.3f", metric.iteration, loss)
405 + }
406 + return nil
407 + }
408 + }
409 +
410 + private var checkpoints: [Checkpoint] {
411 + run.checkpoints
412 + }
413 +
414 + private var lossChart: some View {
415 + Chart {
416 + ForEach(Array(metrics.enumerated()), id: \.offset) { _, metric in
417 + if let loss = metric.trainLoss {
418 + LineMark(
419 + x: .value("Iteration", metric.iteration),
420 + y: .value("Train loss", loss),
421 + series: .value("Series", "Train"))
422 + .foregroundStyle(ZyquoTheme.chartTrain)
423 + .interpolationMethod(.monotone)
424 + }
425 + }
426 + ForEach(Array(metrics.enumerated()), id: \.offset) { _, metric in
427 + if let loss = metric.valLoss {
428 + LineMark(
429 + x: .value("Iteration", metric.iteration),
430 + y: .value("Val loss", loss),
431 + series: .value("Series", "Val"))
432 + .foregroundStyle(ZyquoTheme.chartVal)
433 + PointMark(
434 + x: .value("Iteration", metric.iteration),
435 + y: .value("Val loss", loss))
436 + .foregroundStyle(ZyquoTheme.chartVal)
437 + .symbolSize(30)
438 + }
439 + }
440 + }
441 + .chartLegend(.visible)
442 + .chartForegroundStyleScale([
443 + "Train": ZyquoTheme.chartTrain, "Val": ZyquoTheme.chartVal,
444 + ])
445 + .frame(height: 220)
446 + .padding(ZyquoTheme.spacing12)
447 + .zyquoCard()
448 + .overlay {
449 + if metrics.isEmpty {
450 + Text(isActive ? "Waiting for the first report…" : "No metrics recorded")
451 + .font(ZyquoTheme.captionFont)
452 + .foregroundStyle(ZyquoTheme.textTertiary)
453 + }
454 + }
455 + }
456 +
457 + private func stat(_ label: String, _ value: String) -> some View {
458 + VStack(alignment: .leading, spacing: ZyquoTheme.spacing2) {
459 + Text(label)
460 + .font(ZyquoTheme.captionFont)
461 + .foregroundStyle(ZyquoTheme.textTertiary)
462 + Text(value)
463 + .font(ZyquoTheme.monoFont)
464 + .foregroundStyle(ZyquoTheme.textPrimary)
465 + }
466 + }
467 +
468 + private func resume() {
469 + guard
470 + let baseModel = model.models.first(where: { $0.id == run.baseModelID }),
471 + let dataset = model.datasets.first(where: { $0.id == run.datasetID })
472 + else { return }
473 + controller.start(run: run, baseModel: baseModel, dataset: dataset, resume: true)
32 474 }
33 475 }
34 476
modified Sources/ZyquoMLX/Views/WorkbenchView.swift +5 −0
@@ -39,6 +39,11 @@ struct WorkbenchView: View {
39 39 minWidth: ZyquoTheme.minWindowSize.width,
40 40 minHeight: ZyquoTheme.minWindowSize.height)
41 41 .task { await model.refresh() }
42 + .onReceive(NotificationCenter.default.publisher(for: .zyquoNavigate)) { note in
43 + if let section = note.object as? WorkbenchSection {
44 + model.selectedSection = section
45 + }
46 + }
42 47 }
43 48
44 49 @ViewBuilder
modified docs/PLAN.md +18 −1
@@ -144,6 +144,23 @@ Phase 6 features and will be built on this spec.
144 144 - [x] Simplified small variant (Z only, thicker rim) for 16/32 px
145 145 - [x] `make icon` (scripts/make-icon.sh): rsvg-convert → full iconset (16→1024 incl @2x, small variant ≤32) → `iconutil` → AppIcon.icns; embedded via `make app`
146 146 - [x] Phase checkpoint: size-ladder review 16→256 sharp; verified in the real Dock — unmistakably the copper Zyquo sibling
147 ## Phase 6 — Features (not started)
147 +## Phase 6 — Features ✅ (completed 2026-07-30)
148 +
149 +- [x] `Hub/HubService` (live mlx search + tree sizes + resolve, HF token via Keychain `HFTokenStore`), `Hub/DownloadManager` (Range-resumable, verified end-to-end: 351 MB repo downloaded with progress → auto-installed), `Services/Catalog` (16 Featured entries from MODELS.md w/ RAM verdicts)
150 +- [x] Models › Discover: Featured catalog + debounced live search, download/pause with progress, installed detection
151 +- [x] Playground: streaming chat verified in-UI (578 tok/s · TTFT 1.47 s shown live), VLM image attach, embeddings inspector w/ vectors + ranked cosine similarities, load/unload with freed-bytes readout
152 +- [x] Train: configurator sheet (RAM gating + MemoryAdvisor suggestions inline, method/hyperparams with help text) + run detail verified in-UI (copper/slate loss chart from persisted metrics, stat strip, SF Mono console w/ autoscroll, checkpoints, cancel/warm-resume)
153 +- [x] Convert: Quantize (Swift-native, live size preview) / Fuse (de-quantize smart default + explanation) / Convert-from-HF job cards with progress
154 +- [x] Evaluate: base-vs-candidate side-by-side compare with per-side tok/s + TTFT (sequential load/unload)
155 +- [x] Polish: HF token in Keychain (Settings › Hugging Face), Foundry menu with ⌘L/⌘D/⌘R/⌘U/⌘I/⌘E section shortcuts
156 +- [x] Coherence sweep: headers ✓, naming ✓, no TODO/dead code ✓, zero raw hex in views ✓
157 +
158 +**Phase 6 summary:** All foundry features are wired into the workbench UI on
159 +top of the Phase 3 services. Live verification on this Mac: run detail renders
160 +the real QLoRA run's curves; Playground streamed a real reply with stats; the
161 +Hub pipeline searched, downloaded, and installed a real repo with resumable
162 +progress. One heuristic fix surfaced by verification: decoder-style embedding
163 +repos (Qwen3-Embedding ships a `Qwen3ForCausalLM` config) are detected by
164 +name — the downloaded embedder then produced correct 1024-dim vectors.
148 165 ## Phase 7 — Verification (not started)
149 166 ## Phase 8 — Signing & Notarization (not started)
150 167