SPB Git

Repository Public MIT

Commit

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 · Jul 31, 2026 parent 1d83052
15 changed files +1,974 −34 Browse files
modified Sources/ZyquoMLX/App/CLIFoundry.swift +31 −0
@@ -19,6 +19,7 @@ extension CLI {
19 let args = CommandLine.arguments 19 let args = CommandLine.arguments
20 return args.contains("--train") || args.contains("--fuse") 20 return args.contains("--train") || args.contains("--fuse")
21 || args.contains("--quantize") || args.contains("--validate-dataset") 21 || args.contains("--quantize") || args.contains("--validate-dataset")
22+ || args.contains("--download")
22 } 23 }
23 24
24 static func runFoundry() async -> Int32 { 25 static func runFoundry() async -> Int32 {
@@ -40,6 +41,10 @@ extension CLI {
40 try await validateDataset(path: path) 41 try await validateDataset(path: path)
41 return 0 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 return 2 48 return 2
44 } catch { 49 } catch {
45 FileHandle.standardError.write(Data("error: \(error.localizedDescription)\n".utf8)) 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 // MARK: - Dataset validation 234 // MARK: - Dataset validation
204 235
205 private static func validateDataset(path: String) async throws { 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 .defaultSize( 40 .defaultSize(
41 width: ZyquoTheme.defaultWindowSize.width, 41 width: ZyquoTheme.defaultWindowSize.width,
42 height: ZyquoTheme.defaultWindowSize.height) 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 Settings { 55 Settings {
45 SettingsView() 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 import Foundation 9 import Foundation
10 import Observation 10 import Observation
11+import SwiftUI
11 12
12 /// Workbench sections (left navigator, charter §4.2). 13 /// Workbench sections (left navigator, charter §4.2).
13 enum WorkbenchSection: String, CaseIterable, Identifiable { 14 enum WorkbenchSection: String, CaseIterable, Identifiable {
@@ -41,6 +42,24 @@ enum WorkbenchSection: String, CaseIterable, Identifiable {
41 case .evaluate: "checkmark.seal" 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 /// Root observable state for the workbench: library contents, run history, 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 import SwiftUI 9 import SwiftUI
10 10
11−/// Convert section: quantize / fuse / convert job cards (interactive job 11+/// Convert section: quantize, fuse adapters, and import HF repos — every job
12−/// launcher wired in Phase 6). 12+/// tracked with progress and a before/after size story.
13 struct ConvertView: View { 13 struct ConvertView: View {
14 @Bindable var model: AppModel 14 @Bindable var model: AppModel
15+ @State private var controller = ConvertController()
15 16
16 var body: some View { 17 var body: some View {
17− EmptyStateView( 18+ ScrollView {
18− icon: "arrow.triangle.2.circlepath", 19+ VStack(alignment: .leading, spacing: ZyquoTheme.spacing16) {
19− title: "The Conversion Bench", 20+ QuantizeCard(model: model, controller: controller)
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.") 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 import SwiftUI 9 import SwiftUI
10 10
11−/// Evaluate section: held-out loss/perplexity + base-vs-tuned scorecard 11+/// Evaluate section: base-vs-tuned side-by-side qualitative comparison with
12−/// (wired in Phase 6). 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 struct EvaluateView: View { 14 struct EvaluateView: View {
14 @Bindable var model: AppModel 15 @Bindable var model: AppModel
16+ @State private var controller = EvaluateController()
15 17
16 var body: some View { 18 var body: some View {
17− EmptyStateView( 19+ if model.models.count < 2 {
18− icon: "checkmark.seal", 20+ EmptyStateView(
19− title: "Prove Your Fine-Tune", 21+ icon: "checkmark.seal",
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.") 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 private var discover: some View { 59 private var discover: some View {
60− // Live Hub search lands in Phase 6 (HubService); the state is designed now. 60+ DiscoverView(model: model)
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.")
65 } 61 }
66 } 62 }
67 63
modified Sources/ZyquoMLX/Views/PlaygroundView.swift +363 −4
@@ -6,12 +6,16 @@
6 // Mail: contact@spboucher.ai 6 // Mail: contact@spboucher.ai
7 // 7 //
8 8
9+import MLXLMCommon
9 import SwiftUI 10 import SwiftUI
11+import UniformTypeIdentifiers
10 12
11−/// Playground section: interactive inference per model type (streaming chat, 13+/// Playground: interactive inference per model type — streaming chat for
12−/// image+text, embeddings inspector — wired in Phase 6). 14+/// LLM/VLM (images attach for VLM), a text→vector inspector with similarity
15+/// for embeddings, per-run stats, and verifiable load/unload.
13 struct PlaygroundView: View { 16 struct PlaygroundView: View {
14 @Bindable var model: AppModel 17 @Bindable var model: AppModel
18+ @State private var session = PlaygroundSession()
15 19
16 var body: some View { 20 var body: some View {
17 if model.models.isEmpty { 21 if model.models.isEmpty {
@@ -20,10 +24,365 @@ struct PlaygroundView: View {
20 title: "The Playground Awaits", 24 title: "The Playground Awaits",
21 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.") 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 } else { 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 EmptyStateView( 88 EmptyStateView(
24− icon: "bubble.left.and.text.bubble.right", 89+ icon: "cpu",
25 title: "Pick a Model to Begin", 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
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
modified Sources/ZyquoMLX/Views/TrainView.swift +454 −12
Large diff (483 lines) is not loaded by default to keep the page fast.Load diff
modified Sources/ZyquoMLX/Views/WorkbenchView.swift +5 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
modified docs/PLAN.md +18 −1
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme