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>
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 | |