phase3: resumable downloads with progress, live Hub search, model store — gate green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 9 changed files with +1,369 and −1
added
Sources/ZyquoLocal/App/HubPoCRunner.swift
+131 −0
@@ -0,0 +1,131 @@ | ||
| 1 | +// | |
| 2 | +// HubPoCRunner.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Phase 3 gate: `ZyquoLocal --hub-poc` proves search → download with live | |
| 12 | +/// progress → pause mid-flight → resume → validate → delete, end to end, | |
| 13 | +/// against the live Hugging Face Hub, in an isolated temporary Models root. | |
| 14 | +enum HubPoCRunner { | |
| 15 | + private static let testRepo = "mlx-community/Qwen3-0.6B-4bit" | |
| 16 | + | |
| 17 | + @MainActor | |
| 18 | + static func run() async { | |
| 19 | + let hub = HubService(token: nil) | |
| 20 | + | |
| 21 | + // 1 — live search | |
| 22 | + log("1) Live Hub search: “qwen” in mlx-community, by downloads…") | |
| 23 | + do { | |
| 24 | + let results = try await hub.search(query: "qwen", scope: .mlxCommunity, limit: 5) | |
| 25 | + for r in results { | |
| 26 | + log(" • \(r.id) ↓\(r.downloads ?? 0) arch=\(r.architecture ?? "?")\(r.isSupportedArchitecture ? "" : " ⚠️ unsupported")") | |
| 27 | + } | |
| 28 | + guard !results.isEmpty else { return fail("search returned nothing") } | |
| 29 | + } catch { | |
| 30 | + return fail("search: \(error.localizedDescription)") | |
| 31 | + } | |
| 32 | + | |
| 33 | + // Isolated store for the test | |
| 34 | + let root = FileManager.default.temporaryDirectory | |
| 35 | + .appendingPathComponent("ZyquoLocalHubPoC-\(UUID().uuidString)") | |
| 36 | + let store = ModelStore(root: root) | |
| 37 | + let manager = DownloadManager(hub: hub, store: store) | |
| 38 | + defer { try? FileManager.default.removeItem(at: root) } | |
| 39 | + | |
| 40 | + // 2 — file listing + size | |
| 41 | + log("2) File listing for \(testRepo)…") | |
| 42 | + do { | |
| 43 | + let (files, total) = try await hub.requiredFiles(of: testRepo) | |
| 44 | + log(" \(files.count) required files, total \(gb(total))") | |
| 45 | + } catch { | |
| 46 | + return fail("file listing: \(error.localizedDescription)") | |
| 47 | + } | |
| 48 | + | |
| 49 | + // 3 — download with live progress, pause at ≥25 % | |
| 50 | + log("3) Downloading with live progress; pausing mid-flight…") | |
| 51 | + await manager.download(repoID: testRepo) | |
| 52 | + var paused = false | |
| 53 | + while !paused { | |
| 54 | + try? await Task.sleep(for: .milliseconds(200)) | |
| 55 | + guard let t = manager.task(for: testRepo) else { return fail("task vanished") } | |
| 56 | + if t.state == .failed { return fail("download failed: \(t.errorDescription ?? "?")") } | |
| 57 | + if t.fractionCompleted >= 0.25 { | |
| 58 | + manager.pause(repoID: testRepo) | |
| 59 | + paused = true | |
| 60 | + let speed = manager.speeds[testRepo].map { "\(gb(Int64($0)))/s" } ?? "n/a" | |
| 61 | + log(" paused at \(pct(t.fractionCompleted)) (speed was \(speed))") | |
| 62 | + } | |
| 63 | + } | |
| 64 | + try? await Task.sleep(for: .milliseconds(400)) | |
| 65 | + | |
| 66 | + // 4 — verify partials survive, then resume | |
| 67 | + guard let pausedTask = manager.task(for: testRepo), pausedTask.state == .paused else { | |
| 68 | + return fail("expected paused state") | |
| 69 | + } | |
| 70 | + let partialBytes = pausedTask.receivedBytes | |
| 71 | + guard partialBytes > 0 else { return fail("no partial bytes on disk") } | |
| 72 | + log("4) Resuming from \(gb(partialBytes)) (HTTP Range)…") | |
| 73 | + manager.resume(repoID: testRepo) | |
| 74 | + | |
| 75 | + var completed = false | |
| 76 | + var lastLogged = -1 | |
| 77 | + while !completed { | |
| 78 | + try? await Task.sleep(for: .milliseconds(300)) | |
| 79 | + guard let t = manager.task(for: testRepo) else { return fail("task vanished") } | |
| 80 | + switch t.state { | |
| 81 | + case .completed: | |
| 82 | + completed = true | |
| 83 | + case .failed: | |
| 84 | + return fail("resume failed: \(t.errorDescription ?? "?")") | |
| 85 | + default: | |
| 86 | + let percent = Int(t.fractionCompleted * 100) | |
| 87 | + if percent / 20 != lastLogged / 20 { | |
| 88 | + lastLogged = percent | |
| 89 | + log(" … \(percent)% (\(gb(t.receivedBytes))/\(gb(t.totalBytes)))") | |
| 90 | + } | |
| 91 | + } | |
| 92 | + } | |
| 93 | + log(" download completed + size-verified ✅") | |
| 94 | + | |
| 95 | + // 5 — store validation | |
| 96 | + log("5) Validating model directory + store scan…") | |
| 97 | + store.rescan() | |
| 98 | + guard let model = store.model(for: testRepo) else { | |
| 99 | + return fail("ModelStore did not recognize the downloaded model") | |
| 100 | + } | |
| 101 | + log(" \(model.repoID): \(gb(model.sizeBytes)), arch=\(model.architecture ?? "?"), quant=\(model.quantization ?? "?"), ctx=\(model.contextWindow ?? 0), verdict=\(MemoryAdvisor.verdict(weightsBytes: model.sizeBytes).label)") | |
| 102 | + | |
| 103 | + // 6 — delete | |
| 104 | + log("6) Deleting…") | |
| 105 | + let reclaimed = store.delete(repoID: testRepo) | |
| 106 | + guard store.model(for: testRepo) == nil, | |
| 107 | + !FileManager.default.fileExists(atPath: root.appendingPathComponent(testRepo).path) | |
| 108 | + else { return fail("delete left files behind") } | |
| 109 | + log(" reclaimed \(gb(reclaimed)) ✅") | |
| 110 | + | |
| 111 | + log("\nHUB POC: ALL STEPS GREEN") | |
| 112 | + exit(0) | |
| 113 | + } | |
| 114 | + | |
| 115 | + private static func log(_ text: String) { | |
| 116 | + FileHandle.standardError.write(Data((text + "\n").utf8)) | |
| 117 | + } | |
| 118 | + | |
| 119 | + private static func fail(_ text: String) { | |
| 120 | + log("FAIL: \(text)") | |
| 121 | + exit(1) | |
| 122 | + } | |
| 123 | + | |
| 124 | + private static func gb(_ bytes: Int64) -> String { | |
| 125 | + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) | |
| 126 | + } | |
| 127 | + | |
| 128 | + private static func pct(_ fraction: Double) -> String { | |
| 129 | + "\(Int(fraction * 100))%" | |
| 130 | + } | |
| 131 | +} | |
modified
Sources/ZyquoLocal/App/Main.swift
+4 −0
@@ -19,6 +19,10 @@ enum Main { | ||
| 19 | 19 | await PoCRunner.run(arguments: args) |
| 20 | 20 | return |
| 21 | 21 | } |
| 22 | + if args.contains("--hub-poc") { | |
| 23 | + await HubPoCRunner.run() | |
| 24 | + return | |
| 25 | + } | |
| 22 | 26 | ZyquoLocalApp.main() |
| 23 | 27 | } |
| 24 | 28 | } |
added
Sources/ZyquoLocal/Hub/DownloadManager.swift
+371 −0
@@ -0,0 +1,371 @@ | ||
| 1 | +// | |
| 2 | +// DownloadManager.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import Observation | |
| 11 | + | |
| 12 | +/// Queued, resumable model downloads. | |
| 13 | +/// | |
| 14 | +/// - Files download to `<name>.partial` and are moved into place atomically; | |
| 15 | +/// a model is valid only when every file is present and size-verified. | |
| 16 | +/// - Resume uses HTTP Range from the partial file's size — surviving pause, | |
| 17 | +/// cancel-restart, and app relaunch (manifest + partials persist on disk). | |
| 18 | +/// - Max 2 concurrent file downloads per model. | |
| 19 | +@MainActor | |
| 20 | +@Observable | |
| 21 | +final class DownloadManager { | |
| 22 | + private(set) var tasks: [DownloadTask] = [] | |
| 23 | + /// Live transfer speed per repoID, bytes/sec. | |
| 24 | + private(set) var speeds: [String: Double] = [:] | |
| 25 | + | |
| 26 | + var hub: HubService | |
| 27 | + private let store: ModelStore | |
| 28 | + private var workers: [String: Task<Void, Never>] = [:] | |
| 29 | + /// Speed window per repoID: last sample time and cumulative bytes. | |
| 30 | + private var speedSamples: [String: (time: ContinuousClock.Instant, bytes: Int64)] = [:] | |
| 31 | + | |
| 32 | + /// Active + queued downloads (for the sidebar badge). | |
| 33 | + var activeCount: Int { | |
| 34 | + tasks.filter { $0.state == .downloading || $0.state == .queued }.count | |
| 35 | + } | |
| 36 | + | |
| 37 | + var overallFraction: Double { | |
| 38 | + let active = tasks.filter { $0.state == .downloading || $0.state == .paused } | |
| 39 | + guard !active.isEmpty else { return 0 } | |
| 40 | + return active.reduce(0.0) { $0 + $1.fractionCompleted } / Double(active.count) | |
| 41 | + } | |
| 42 | + | |
| 43 | + init(hub: HubService, store: ModelStore) { | |
| 44 | + self.hub = hub | |
| 45 | + self.store = store | |
| 46 | + restoreManifest() | |
| 47 | + } | |
| 48 | + | |
| 49 | + enum DownloadError: LocalizedError { | |
| 50 | + case insufficientDiskSpace(needed: Int64, available: Int64) | |
| 51 | + case sizeMismatch(file: String) | |
| 52 | + case interrupted | |
| 53 | + | |
| 54 | + var errorDescription: String? { | |
| 55 | + switch self { | |
| 56 | + case .insufficientDiskSpace(let needed, let available): | |
| 57 | + "Not enough disk space: \(ByteCountFormatter.string(fromByteCount: needed, countStyle: .file)) needed, \(ByteCountFormatter.string(fromByteCount: available, countStyle: .file)) available." | |
| 58 | + case .sizeMismatch(let file): | |
| 59 | + "Downloaded file \(file) does not match its expected size. The download will retry from scratch for this file." | |
| 60 | + case .interrupted: | |
| 61 | + "The download was interrupted. It can be resumed from where it left off." | |
| 62 | + } | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + // MARK: - Public API | |
| 67 | + | |
| 68 | + /// Starts (or restarts) downloading all required files of a repo. | |
| 69 | + func download(repoID: String) async { | |
| 70 | + if let existing = task(for: repoID), | |
| 71 | + existing.state == .downloading || existing.state == .queued | |
| 72 | + { | |
| 73 | + return | |
| 74 | + } | |
| 75 | + do { | |
| 76 | + let (files, totalBytes) = try await hub.requiredFiles(of: repoID) | |
| 77 | + try checkDiskSpace(needed: totalBytes) | |
| 78 | + let dir = store.directory(for: repoID) | |
| 79 | + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | |
| 80 | + | |
| 81 | + var progress: [DownloadTask.FileProgress] = files.map { file in | |
| 82 | + let partial = dir.appendingPathComponent(file.path + ".partial") | |
| 83 | + let final = dir.appendingPathComponent(file.path) | |
| 84 | + let already: Int64 | |
| 85 | + let done: Bool | |
| 86 | + if FileManager.default.fileExists(atPath: final.path) { | |
| 87 | + already = file.size ?? 0 | |
| 88 | + done = true | |
| 89 | + } else { | |
| 90 | + already = (try? FileManager.default.attributesOfItem(atPath: partial.path)[.size] as? Int64) ?? 0 | |
| 91 | + done = false | |
| 92 | + } | |
| 93 | + return DownloadTask.FileProgress( | |
| 94 | + path: file.path, | |
| 95 | + totalBytes: file.size ?? 0, | |
| 96 | + receivedBytes: already, | |
| 97 | + sha256: file.lfs?.oid, | |
| 98 | + completed: done | |
| 99 | + ) | |
| 100 | + } | |
| 101 | + progress.sort { $0.totalBytes < $1.totalBytes } // small config files first | |
| 102 | + upsert(DownloadTask( | |
| 103 | + repoID: repoID, state: .downloading, files: progress, | |
| 104 | + errorDescription: nil, startedAt: Date() | |
| 105 | + )) | |
| 106 | + startWorker(repoID: repoID) | |
| 107 | + } catch { | |
| 108 | + upsert(DownloadTask( | |
| 109 | + repoID: repoID, state: .failed, files: task(for: repoID)?.files ?? [], | |
| 110 | + errorDescription: error.localizedDescription, startedAt: Date() | |
| 111 | + )) | |
| 112 | + } | |
| 113 | + } | |
| 114 | + | |
| 115 | + func pause(repoID: String) { | |
| 116 | + workers[repoID]?.cancel() | |
| 117 | + workers[repoID] = nil | |
| 118 | + mutate(repoID) { $0.state = .paused } | |
| 119 | + } | |
| 120 | + | |
| 121 | + func resume(repoID: String) { | |
| 122 | + guard var t = task(for: repoID), t.state == .paused || t.state == .failed else { return } | |
| 123 | + t.state = .downloading | |
| 124 | + t.errorDescription = nil | |
| 125 | + upsert(t) | |
| 126 | + startWorker(repoID: repoID) | |
| 127 | + } | |
| 128 | + | |
| 129 | + /// Cancels and removes all partial data. | |
| 130 | + func cancel(repoID: String) { | |
| 131 | + workers[repoID]?.cancel() | |
| 132 | + workers[repoID] = nil | |
| 133 | + let dir = store.directory(for: repoID) | |
| 134 | + if let t = task(for: repoID) { | |
| 135 | + for f in t.files where !f.completed { | |
| 136 | + try? FileManager.default.removeItem(at: dir.appendingPathComponent(f.path + ".partial")) | |
| 137 | + } | |
| 138 | + } | |
| 139 | + // Remove the directory when nothing complete remains in it. | |
| 140 | + if let contents = try? FileManager.default.contentsOfDirectory(atPath: dir.path), contents.isEmpty { | |
| 141 | + try? FileManager.default.removeItem(at: dir) | |
| 142 | + } | |
| 143 | + tasks.removeAll { $0.repoID == repoID } | |
| 144 | + speeds[repoID] = nil | |
| 145 | + speedSamples[repoID] = nil | |
| 146 | + persistManifest() | |
| 147 | + } | |
| 148 | + | |
| 149 | + func task(for repoID: String) -> DownloadTask? { | |
| 150 | + tasks.first { $0.repoID == repoID } | |
| 151 | + } | |
| 152 | + | |
| 153 | + /// ETA in seconds based on current speed. | |
| 154 | + func eta(for repoID: String) -> TimeInterval? { | |
| 155 | + guard let t = task(for: repoID), let speed = speeds[t.repoID], speed > 1 else { return nil } | |
| 156 | + return Double(t.totalBytes - t.receivedBytes) / speed | |
| 157 | + } | |
| 158 | + | |
| 159 | + // MARK: - Worker | |
| 160 | + | |
| 161 | + private func startWorker(repoID: String) { | |
| 162 | + workers[repoID]?.cancel() | |
| 163 | + workers[repoID] = Task { [weak self] in | |
| 164 | + await self?.runWorker(repoID: repoID) | |
| 165 | + } | |
| 166 | + } | |
| 167 | + | |
| 168 | + private func runWorker(repoID: String) async { | |
| 169 | + let dir = store.directory(for: repoID) | |
| 170 | + guard let snapshot = task(for: repoID) else { return } | |
| 171 | + let pending = snapshot.files.filter { !$0.completed } | |
| 172 | + | |
| 173 | + do { | |
| 174 | + try await withThrowingTaskGroup(of: Void.self) { group in | |
| 175 | + var iterator = pending.makeIterator() | |
| 176 | + var inFlight = 0 | |
| 177 | + func addNext(_ group: inout ThrowingTaskGroup<Void, Error>) { | |
| 178 | + guard let file = iterator.next() else { return } | |
| 179 | + inFlight += 1 | |
| 180 | + group.addTask { [weak self] in | |
| 181 | + try await self?.downloadFile(repoID: repoID, file: file, directory: dir) | |
| 182 | + } | |
| 183 | + } | |
| 184 | + addNext(&group) | |
| 185 | + addNext(&group) // 2 concurrent file downloads max | |
| 186 | + while inFlight > 0 { | |
| 187 | + try await group.next() | |
| 188 | + inFlight -= 1 | |
| 189 | + addNext(&group) | |
| 190 | + } | |
| 191 | + } | |
| 192 | + mutate(repoID) { $0.state = .verifying } | |
| 193 | + try verify(repoID: repoID, directory: dir) | |
| 194 | + mutate(repoID) { $0.state = .completed } | |
| 195 | + speeds[repoID] = nil | |
| 196 | + store.rescan() | |
| 197 | + } catch is CancellationError { | |
| 198 | + // pause() already set the state; on app quit partials just persist. | |
| 199 | + } catch { | |
| 200 | + if Task.isCancelled { return } | |
| 201 | + mutate(repoID) { | |
| 202 | + $0.state = .failed | |
| 203 | + $0.errorDescription = error.localizedDescription | |
| 204 | + } | |
| 205 | + speeds[repoID] = nil | |
| 206 | + } | |
| 207 | + workers[repoID] = nil | |
| 208 | + } | |
| 209 | + | |
| 210 | + /// Downloads one file with Range-resume into `<path>.partial`, then moves | |
| 211 | + /// it into place. Retries transient network drops from the partial offset. | |
| 212 | + /// Throws CancellationError on pause. | |
| 213 | + private func downloadFile(repoID: String, file: DownloadTask.FileProgress, directory: URL) async throws { | |
| 214 | + let partial = directory.appendingPathComponent(file.path + ".partial") | |
| 215 | + let final = directory.appendingPathComponent(file.path) | |
| 216 | + try FileManager.default.createDirectory( | |
| 217 | + at: partial.deletingLastPathComponent(), withIntermediateDirectories: true) | |
| 218 | + if !FileManager.default.fileExists(atPath: partial.path) { | |
| 219 | + FileManager.default.createFile(atPath: partial.path, contents: nil) | |
| 220 | + } | |
| 221 | + | |
| 222 | + var request = URLRequest(url: HubService.resolveURL(repoID: repoID, path: file.path)) | |
| 223 | + request.setValue("ZyquoLocal/0.1.0", forHTTPHeaderField: "User-Agent") | |
| 224 | + if let token = hub.token, !token.isEmpty { | |
| 225 | + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") | |
| 226 | + } | |
| 227 | + | |
| 228 | + let path = file.path | |
| 229 | + let transfer = FileTransfer(partial: partial) { [weak self] received in | |
| 230 | + Task { @MainActor [weak self] in | |
| 231 | + self?.updateProgress(repoID: repoID, path: path, received: received) | |
| 232 | + } | |
| 233 | + } | |
| 234 | + | |
| 235 | + var attempts = 0 | |
| 236 | + while true { | |
| 237 | + attempts += 1 | |
| 238 | + let offset: Int64 = | |
| 239 | + (try? FileManager.default.attributesOfItem(atPath: partial.path)[.size] as? Int64) ?? 0 | |
| 240 | + do { | |
| 241 | + _ = try await transfer.run(request: request, offset: offset) | |
| 242 | + break | |
| 243 | + } catch let error as FileTransfer.TransferError { | |
| 244 | + switch error { | |
| 245 | + case .badStatus(401), .badStatus(403): | |
| 246 | + throw HubService.HubError.gatedOrMissing(repoID) | |
| 247 | + case .badStatus(416): | |
| 248 | + // Range beyond EOF: complete if the size matches, else restart. | |
| 249 | + let size = (try? FileManager.default.attributesOfItem(atPath: partial.path)[.size] as? Int64) ?? 0 | |
| 250 | + if size == file.totalBytes { break } | |
| 251 | + try? FileManager.default.removeItem(at: partial) | |
| 252 | + FileManager.default.createFile(atPath: partial.path, contents: nil) | |
| 253 | + if attempts >= 3 { throw DownloadError.sizeMismatch(file: file.path) } | |
| 254 | + continue | |
| 255 | + case .badStatus(let code): | |
| 256 | + throw HubService.HubError.http(code, repoID) | |
| 257 | + } | |
| 258 | + } catch is CancellationError { | |
| 259 | + throw CancellationError() | |
| 260 | + } catch { | |
| 261 | + // Transient drop (connection lost, timeout…): retry with backoff | |
| 262 | + // from whatever the partial already holds. | |
| 263 | + guard attempts < 4 else { throw error } | |
| 264 | + try await Task.sleep(for: .seconds(Double(attempts))) | |
| 265 | + try Task.checkCancellation() | |
| 266 | + continue | |
| 267 | + } | |
| 268 | + } | |
| 269 | + try finishFile(partial: partial, final: final, repoID: repoID, path: file.path, expected: file.totalBytes) | |
| 270 | + } | |
| 271 | + | |
| 272 | + /// Size check + atomic move into place. | |
| 273 | + private func finishFile(partial: URL, final: URL, repoID: String, path: String, expected: Int64) throws { | |
| 274 | + let size = (try? FileManager.default.attributesOfItem(atPath: partial.path)[.size] as? Int64) ?? -1 | |
| 275 | + guard expected == 0 || size == expected else { | |
| 276 | + try? FileManager.default.removeItem(at: partial) | |
| 277 | + updateProgress(repoID: repoID, path: path, received: 0) | |
| 278 | + throw DownloadError.sizeMismatch(file: path) | |
| 279 | + } | |
| 280 | + if FileManager.default.fileExists(atPath: final.path) { | |
| 281 | + try FileManager.default.removeItem(at: final) | |
| 282 | + } | |
| 283 | + try FileManager.default.moveItem(at: partial, to: final) | |
| 284 | + mutate(repoID) { t in | |
| 285 | + if let i = t.files.firstIndex(where: { $0.path == path }) { | |
| 286 | + t.files[i].completed = true | |
| 287 | + t.files[i].receivedBytes = t.files[i].totalBytes | |
| 288 | + } | |
| 289 | + } | |
| 290 | + } | |
| 291 | + | |
| 292 | + /// Final integrity pass: every required file present with expected size. | |
| 293 | + private func verify(repoID: String, directory: URL) throws { | |
| 294 | + guard let t = task(for: repoID) else { return } | |
| 295 | + for f in t.files { | |
| 296 | + let url = directory.appendingPathComponent(f.path) | |
| 297 | + let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? -1 | |
| 298 | + guard f.totalBytes == 0 || size == f.totalBytes else { | |
| 299 | + throw DownloadError.sizeMismatch(file: f.path) | |
| 300 | + } | |
| 301 | + } | |
| 302 | + } | |
| 303 | + | |
| 304 | + private func checkDiskSpace(needed: Int64) throws { | |
| 305 | + let values = try? store.modelsRoot.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) | |
| 306 | + let available = values?.volumeAvailableCapacityForImportantUsage ?? 0 | |
| 307 | + // 5 % headroom so a download never fills the disk completely. | |
| 308 | + if available < Int64(Double(needed) * 1.05) { | |
| 309 | + throw DownloadError.insufficientDiskSpace(needed: needed, available: available) | |
| 310 | + } | |
| 311 | + } | |
| 312 | + | |
| 313 | + // MARK: - State & manifest | |
| 314 | + | |
| 315 | + private func upsert(_ t: DownloadTask) { | |
| 316 | + if let i = tasks.firstIndex(where: { $0.repoID == t.repoID }) { | |
| 317 | + tasks[i] = t | |
| 318 | + } else { | |
| 319 | + tasks.append(t) | |
| 320 | + } | |
| 321 | + persistManifest() | |
| 322 | + } | |
| 323 | + | |
| 324 | + private func mutate(_ repoID: String, _ change: (inout DownloadTask) -> Void) { | |
| 325 | + guard let i = tasks.firstIndex(where: { $0.repoID == repoID }) else { return } | |
| 326 | + change(&tasks[i]) | |
| 327 | + persistManifest() | |
| 328 | + } | |
| 329 | + | |
| 330 | + private func updateProgress(repoID: String, path: String, received: Int64) { | |
| 331 | + guard let i = tasks.firstIndex(where: { $0.repoID == repoID }) else { return } | |
| 332 | + if let j = tasks[i].files.firstIndex(where: { $0.path == path }) { | |
| 333 | + tasks[i].files[j].receivedBytes = received | |
| 334 | + } | |
| 335 | + // Smoothed speed over a sliding window of the whole repo's bytes. | |
| 336 | + let totalReceived = tasks[i].receivedBytes | |
| 337 | + let now = ContinuousClock.now | |
| 338 | + if let sample = speedSamples[repoID] { | |
| 339 | + let d = sample.time.duration(to: now) | |
| 340 | + let elapsed = Double(d.components.seconds) + Double(d.components.attoseconds) * 1e-18 | |
| 341 | + if elapsed >= 1.0 { | |
| 342 | + let instant = Double(totalReceived - sample.bytes) / elapsed | |
| 343 | + let previous = speeds[repoID] ?? instant | |
| 344 | + speeds[repoID] = previous * 0.6 + instant * 0.4 | |
| 345 | + speedSamples[repoID] = (now, totalReceived) | |
| 346 | + } | |
| 347 | + } else { | |
| 348 | + speedSamples[repoID] = (now, totalReceived) | |
| 349 | + } | |
| 350 | + } | |
| 351 | + | |
| 352 | + private var manifestURL: URL { | |
| 353 | + PersistenceService.appSupportDirectory.appendingPathComponent("downloads.json") | |
| 354 | + } | |
| 355 | + | |
| 356 | + private func persistManifest() { | |
| 357 | + let persistable = tasks.filter { $0.state != .completed && $0.state != .cancelled } | |
| 358 | + try? JSONEncoder().encode(persistable).write(to: manifestURL, options: .atomic) | |
| 359 | + } | |
| 360 | + | |
| 361 | + /// Restores paused/interrupted downloads after relaunch. | |
| 362 | + private func restoreManifest() { | |
| 363 | + guard let data = try? Data(contentsOf: manifestURL), | |
| 364 | + var restored = try? JSONDecoder().decode([DownloadTask].self, from: data) | |
| 365 | + else { return } | |
| 366 | + for i in restored.indices where restored[i].state == .downloading || restored[i].state == .queued { | |
| 367 | + restored[i].state = .paused | |
| 368 | + } | |
| 369 | + tasks = restored | |
| 370 | + } | |
| 371 | +} | |
| \ No newline at end of file | ||
added
Sources/ZyquoLocal/Hub/FileTransfer.swift
+148 −0
@@ -0,0 +1,148 @@ | ||
| 1 | +// | |
| 2 | +// FileTransfer.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// One HTTP transfer of one file into a `.partial` on disk, delegate-backed | |
| 12 | +/// for chunked throughput. Supports Range resume (server 206), transparent | |
| 13 | +/// full restarts (server 200), cancellation, and strips the Authorization | |
| 14 | +/// header when redirected off huggingface.co to the signed CDN. | |
| 15 | +final class FileTransfer: NSObject, URLSessionDataDelegate, @unchecked Sendable { | |
| 16 | + enum TransferError: LocalizedError { | |
| 17 | + case badStatus(Int) | |
| 18 | + | |
| 19 | + var errorDescription: String? { | |
| 20 | + switch self { | |
| 21 | + case .badStatus(let code): "Server returned HTTP \(code)." | |
| 22 | + } | |
| 23 | + } | |
| 24 | + } | |
| 25 | + | |
| 26 | + private let partial: URL | |
| 27 | + /// Cumulative bytes on disk, throttled (~10 Hz). | |
| 28 | + private let onProgress: @Sendable (Int64) -> Void | |
| 29 | + | |
| 30 | + private var handle: FileHandle? | |
| 31 | + private var received: Int64 = 0 | |
| 32 | + private var status = 0 | |
| 33 | + private var rejectedByStatus = false | |
| 34 | + private var continuation: CheckedContinuation<Int64, Error>? | |
| 35 | + private var lastReport = ContinuousClock.now | |
| 36 | + | |
| 37 | + init(partial: URL, onProgress: @escaping @Sendable (Int64) -> Void) { | |
| 38 | + self.partial = partial | |
| 39 | + self.onProgress = onProgress | |
| 40 | + } | |
| 41 | + | |
| 42 | + /// Runs the transfer starting at `offset` (0 = fresh). Returns final byte | |
| 43 | + /// count on disk. Throws CancellationError when the task is cancelled. | |
| 44 | + func run(request: URLRequest, offset: Int64) async throws -> Int64 { | |
| 45 | + received = offset | |
| 46 | + status = 0 | |
| 47 | + rejectedByStatus = false | |
| 48 | + | |
| 49 | + let config = URLSessionConfiguration.default | |
| 50 | + config.timeoutIntervalForRequest = 60 | |
| 51 | + config.networkServiceType = .responsiveData | |
| 52 | + let session = URLSession(configuration: config, delegate: self, delegateQueue: nil) | |
| 53 | + defer { session.finishTasksAndInvalidate() } | |
| 54 | + | |
| 55 | + var request = request | |
| 56 | + if offset > 0 { | |
| 57 | + request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range") | |
| 58 | + } | |
| 59 | + let task = session.dataTask(with: request) | |
| 60 | + | |
| 61 | + return try await withTaskCancellationHandler { | |
| 62 | + try await withCheckedThrowingContinuation { c in | |
| 63 | + continuation = c | |
| 64 | + task.resume() | |
| 65 | + } | |
| 66 | + } onCancel: { | |
| 67 | + task.cancel() | |
| 68 | + } | |
| 69 | + } | |
| 70 | + | |
| 71 | + // MARK: - URLSessionDataDelegate | |
| 72 | + | |
| 73 | + func urlSession( | |
| 74 | + _ session: URLSession, task: URLSessionTask, | |
| 75 | + willPerformHTTPRedirection response: HTTPURLResponse, | |
| 76 | + newRequest request: URLRequest, | |
| 77 | + completionHandler: @escaping (URLRequest?) -> Void | |
| 78 | + ) { | |
| 79 | + var request = request | |
| 80 | + if request.url?.host != task.originalRequest?.url?.host { | |
| 81 | + request.setValue(nil, forHTTPHeaderField: "Authorization") | |
| 82 | + } | |
| 83 | + completionHandler(request) | |
| 84 | + } | |
| 85 | + | |
| 86 | + func urlSession( | |
| 87 | + _ session: URLSession, dataTask: URLSessionDataTask, | |
| 88 | + didReceive response: URLResponse, | |
| 89 | + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void | |
| 90 | + ) { | |
| 91 | + guard let http = response as? HTTPURLResponse else { | |
| 92 | + rejectedByStatus = true | |
| 93 | + completionHandler(.cancel) | |
| 94 | + return | |
| 95 | + } | |
| 96 | + status = http.statusCode | |
| 97 | + switch http.statusCode { | |
| 98 | + case 200: | |
| 99 | + // Fresh body (or the server ignored our Range): restart the file. | |
| 100 | + received = 0 | |
| 101 | + FileManager.default.createFile(atPath: partial.path, contents: nil) | |
| 102 | + handle = try? FileHandle(forWritingTo: partial) | |
| 103 | + completionHandler(handle == nil ? .cancel : .allow) | |
| 104 | + case 206: | |
| 105 | + handle = try? FileHandle(forWritingTo: partial) | |
| 106 | + _ = try? handle?.seekToEnd() | |
| 107 | + completionHandler(handle == nil ? .cancel : .allow) | |
| 108 | + default: | |
| 109 | + rejectedByStatus = true | |
| 110 | + completionHandler(.cancel) | |
| 111 | + } | |
| 112 | + } | |
| 113 | + | |
| 114 | + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { | |
| 115 | + guard let handle else { return } | |
| 116 | + do { | |
| 117 | + try handle.write(contentsOf: data) | |
| 118 | + received += Int64(data.count) | |
| 119 | + } catch { | |
| 120 | + dataTask.cancel() | |
| 121 | + return | |
| 122 | + } | |
| 123 | + let now = ContinuousClock.now | |
| 124 | + if lastReport.duration(to: now) > .milliseconds(100) { | |
| 125 | + lastReport = now | |
| 126 | + onProgress(received) | |
| 127 | + } | |
| 128 | + } | |
| 129 | + | |
| 130 | + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { | |
| 131 | + try? handle?.close() | |
| 132 | + handle = nil | |
| 133 | + onProgress(received) | |
| 134 | + let c = continuation | |
| 135 | + continuation = nil | |
| 136 | + if rejectedByStatus { | |
| 137 | + c?.resume(throwing: TransferError.badStatus(status)) | |
| 138 | + } else if let error { | |
| 139 | + if (error as? URLError)?.code == .cancelled { | |
| 140 | + c?.resume(throwing: CancellationError()) | |
| 141 | + } else { | |
| 142 | + c?.resume(throwing: error) | |
| 143 | + } | |
| 144 | + } else { | |
| 145 | + c?.resume(returning: received) | |
| 146 | + } | |
| 147 | + } | |
| 148 | +} | |
added
Sources/ZyquoLocal/Hub/HubService.swift
+284 −0
@@ -0,0 +1,284 @@ | ||
| 1 | +// | |
| 2 | +// HubService.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Live Hugging Face Hub client: search, model info, file listings. | |
| 12 | +/// Contract documented in docs/MODELS.md §1. All responses are Codable | |
| 13 | +/// structs — no dictionary spelunking. | |
| 14 | +struct HubService: Sendable { | |
| 15 | + /// Search scope shown in the Discover UI. | |
| 16 | + enum Scope: String, CaseIterable, Sendable { | |
| 17 | + case featured | |
| 18 | + case mlxCommunity | |
| 19 | + case allMLX | |
| 20 | + } | |
| 21 | + | |
| 22 | + enum Sort: String, CaseIterable, Sendable { | |
| 23 | + case downloads | |
| 24 | + case likes | |
| 25 | + case newest | |
| 26 | + | |
| 27 | + var apiValue: String { | |
| 28 | + switch self { | |
| 29 | + case .downloads: "downloads" | |
| 30 | + case .likes: "likes" | |
| 31 | + case .newest: "createdAt" | |
| 32 | + } | |
| 33 | + } | |
| 34 | + } | |
| 35 | + | |
| 36 | + /// One search result from /api/models. | |
| 37 | + struct ModelSummary: Codable, Hashable, Sendable, Identifiable { | |
| 38 | + var id: String | |
| 39 | + var likes: Int? | |
| 40 | + var downloads: Int? | |
| 41 | + var gated: GatedValue? | |
| 42 | + var tags: [String]? | |
| 43 | + var createdAt: Date? | |
| 44 | + var config: ModelConfigInfo? | |
| 45 | + | |
| 46 | + var isGated: Bool { gated?.isGated ?? false } | |
| 47 | + var architecture: String? { config?.modelType } | |
| 48 | + | |
| 49 | + /// True when the architecture is known-supported by the MLX engine. | |
| 50 | + var isSupportedArchitecture: Bool { | |
| 51 | + guard let architecture else { return true } // unknown → don't block, warn later | |
| 52 | + return SupportedArchitectures.contains(architecture) | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + struct ModelConfigInfo: Codable, Hashable, Sendable { | |
| 57 | + var modelType: String? | |
| 58 | + | |
| 59 | + enum CodingKeys: String, CodingKey { | |
| 60 | + case modelType = "model_type" | |
| 61 | + } | |
| 62 | + } | |
| 63 | + | |
| 64 | + /// `gated` is `false` or a string ("auto"/"manual"). | |
| 65 | + enum GatedValue: Codable, Hashable, Sendable { | |
| 66 | + case bool(Bool) | |
| 67 | + case mode(String) | |
| 68 | + | |
| 69 | + var isGated: Bool { | |
| 70 | + switch self { | |
| 71 | + case .bool(let b): b | |
| 72 | + case .mode: true | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + init(from decoder: Decoder) throws { | |
| 77 | + let container = try decoder.singleValueContainer() | |
| 78 | + if let b = try? container.decode(Bool.self) { | |
| 79 | + self = .bool(b) | |
| 80 | + } else { | |
| 81 | + self = .mode(try container.decode(String.self)) | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + func encode(to encoder: Encoder) throws { | |
| 86 | + var container = encoder.singleValueContainer() | |
| 87 | + switch self { | |
| 88 | + case .bool(let b): try container.encode(b) | |
| 89 | + case .mode(let s): try container.encode(s) | |
| 90 | + } | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + /// One file from /tree/main?recursive=true. | |
| 95 | + struct RepoFile: Codable, Hashable, Sendable { | |
| 96 | + var type: String | |
| 97 | + var path: String | |
| 98 | + var size: Int64? | |
| 99 | + var lfs: LFSInfo? | |
| 100 | + | |
| 101 | + struct LFSInfo: Codable, Hashable, Sendable { | |
| 102 | + var oid: String? | |
| 103 | + var size: Int64? | |
| 104 | + } | |
| 105 | + | |
| 106 | + var isFile: Bool { type == "file" } | |
| 107 | + | |
| 108 | + /// Files the MLX stack needs (mirrors the package's own filter). | |
| 109 | + var isModelFile: Bool { | |
| 110 | + path.hasSuffix(".safetensors") || path.hasSuffix(".json") || path.hasSuffix(".jinja") | |
| 111 | + } | |
| 112 | + } | |
| 113 | + | |
| 114 | + enum HubError: LocalizedError { | |
| 115 | + case http(Int, String) | |
| 116 | + case rateLimited(retryAfterSeconds: Int?) | |
| 117 | + case gatedOrMissing(String) | |
| 118 | + case invalidResponse | |
| 119 | + | |
| 120 | + var errorDescription: String? { | |
| 121 | + switch self { | |
| 122 | + case .http(let code, let repo): | |
| 123 | + "Hugging Face returned HTTP \(code) for \(repo)." | |
| 124 | + case .rateLimited(let retry): | |
| 125 | + "Hugging Face rate limit reached. Try again in \(retry.map { "\($0)s" } ?? "a moment")." | |
| 126 | + case .gatedOrMissing(let repo): | |
| 127 | + "\(repo) requires access approval or does not exist. For gated models (Llama, Gemma), add a Hugging Face token in Settings." | |
| 128 | + case .invalidResponse: | |
| 129 | + "Unexpected response from Hugging Face." | |
| 130 | + } | |
| 131 | + } | |
| 132 | + } | |
| 133 | + | |
| 134 | + var token: String? | |
| 135 | + | |
| 136 | + private static let base = URL(string: "https://huggingface.co")! | |
| 137 | + | |
| 138 | + private var session: URLSession { URLSession.shared } | |
| 139 | + | |
| 140 | + private func request(_ url: URL) -> URLRequest { | |
| 141 | + var request = URLRequest(url: url) | |
| 142 | + request.setValue("ZyquoLocal/0.1.0", forHTTPHeaderField: "User-Agent") | |
| 143 | + if let token, !token.isEmpty { | |
| 144 | + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") | |
| 145 | + } | |
| 146 | + return request | |
| 147 | + } | |
| 148 | + | |
| 149 | + private static let decoder: JSONDecoder = { | |
| 150 | + let decoder = JSONDecoder() | |
| 151 | + decoder.dateDecodingStrategy = .custom { d in | |
| 152 | + let raw = try d.singleValueContainer().decode(String.self) | |
| 153 | + if let date = try? Date(raw, strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true)) { | |
| 154 | + return date | |
| 155 | + } | |
| 156 | + if let date = try? Date(raw, strategy: .iso8601) { | |
| 157 | + return date | |
| 158 | + } | |
| 159 | + throw DecodingError.dataCorrupted(.init(codingPath: d.codingPath, debugDescription: "Bad date \(raw)")) | |
| 160 | + } | |
| 161 | + return decoder | |
| 162 | + }() | |
| 163 | + | |
| 164 | + private func get<T: Decodable>(_ url: URL, as type: T.Type, context: String) async throws -> T { | |
| 165 | + let (data, response) = try await session.data(for: request(url)) | |
| 166 | + guard let http = response as? HTTPURLResponse else { throw HubError.invalidResponse } | |
| 167 | + switch http.statusCode { | |
| 168 | + case 200: | |
| 169 | + return try Self.decoder.decode(T.self, from: data) | |
| 170 | + case 401, 403: | |
| 171 | + throw HubError.gatedOrMissing(context) | |
| 172 | + case 429: | |
| 173 | + let retry = http.value(forHTTPHeaderField: "retry-after").flatMap(Int.init) | |
| 174 | + throw HubError.rateLimited(retryAfterSeconds: retry) | |
| 175 | + default: | |
| 176 | + throw HubError.http(http.statusCode, context) | |
| 177 | + } | |
| 178 | + } | |
| 179 | + | |
| 180 | + // MARK: - Search | |
| 181 | + | |
| 182 | + /// Live Hub search. `scope .allMLX` searches all of HF filtered to the | |
| 183 | + /// `mlx` library tag; architecture compatibility is checked via | |
| 184 | + /// `config.model_type` (requested with config=true). | |
| 185 | + func search( | |
| 186 | + query: String, | |
| 187 | + scope: Scope, | |
| 188 | + sort: Sort = .downloads, | |
| 189 | + limit: Int = 40 | |
| 190 | + ) async throws -> [ModelSummary] { | |
| 191 | + var components = URLComponents( | |
| 192 | + url: Self.base.appendingPathComponent("api/models"), resolvingAgainstBaseURL: false)! | |
| 193 | + var items = [ | |
| 194 | + URLQueryItem(name: "pipeline_tag", value: "text-generation"), | |
| 195 | + URLQueryItem(name: "sort", value: sort.apiValue), | |
| 196 | + URLQueryItem(name: "direction", value: "-1"), | |
| 197 | + URLQueryItem(name: "limit", value: String(limit)), | |
| 198 | + URLQueryItem(name: "config", value: "true"), | |
| 199 | + ] | |
| 200 | + if !query.isEmpty { | |
| 201 | + items.append(URLQueryItem(name: "search", value: query)) | |
| 202 | + } | |
| 203 | + switch scope { | |
| 204 | + case .featured, .mlxCommunity: | |
| 205 | + items.append(URLQueryItem(name: "author", value: "mlx-community")) | |
| 206 | + case .allMLX: | |
| 207 | + items.append(URLQueryItem(name: "filter", value: "mlx")) | |
| 208 | + } | |
| 209 | + components.queryItems = items | |
| 210 | + return try await get([ModelSummary].self, from: components.url!, context: "search") | |
| 211 | + } | |
| 212 | + | |
| 213 | + private func get<T: Decodable>(_ type: T.Type, from url: URL, context: String) async throws -> T { | |
| 214 | + try await get(url, as: type, context: context) | |
| 215 | + } | |
| 216 | + | |
| 217 | + // MARK: - Files | |
| 218 | + | |
| 219 | + /// Full file listing with sizes for a repo (paginates if needed). | |
| 220 | + func files(of repoID: String) async throws -> [RepoFile] { | |
| 221 | + var all: [RepoFile] = [] | |
| 222 | + var url: URL? = Self.base.appendingPathComponent("api/models/\(repoID)/tree/main") | |
| 223 | + .appending(queryItems: [URLQueryItem(name: "recursive", value: "true")]) | |
| 224 | + while let current = url { | |
| 225 | + let (data, response) = try await session.data(for: request(current)) | |
| 226 | + guard let http = response as? HTTPURLResponse else { throw HubError.invalidResponse } | |
| 227 | + guard http.statusCode == 200 else { | |
| 228 | + if http.statusCode == 401 || http.statusCode == 403 { | |
| 229 | + throw HubError.gatedOrMissing(repoID) | |
| 230 | + } | |
| 231 | + throw HubError.http(http.statusCode, repoID) | |
| 232 | + } | |
| 233 | + all += try Self.decoder.decode([RepoFile].self, from: data) | |
| 234 | + url = Self.nextPage(from: http) | |
| 235 | + } | |
| 236 | + return all | |
| 237 | + } | |
| 238 | + | |
| 239 | + /// Files required to run the model, with total download size. | |
| 240 | + func requiredFiles(of repoID: String) async throws -> (files: [RepoFile], totalBytes: Int64) { | |
| 241 | + let required = try await files(of: repoID).filter { $0.isFile && $0.isModelFile } | |
| 242 | + let total = required.reduce(Int64(0)) { $0 + ($1.size ?? 0) } | |
| 243 | + return (required, total) | |
| 244 | + } | |
| 245 | + | |
| 246 | + /// Download URL for one file of a repo. | |
| 247 | + static func resolveURL(repoID: String, path: String) -> URL { | |
| 248 | + base.appendingPathComponent("\(repoID)/resolve/main/\(path)") | |
| 249 | + } | |
| 250 | + | |
| 251 | + /// Parses RFC-5988 Link header for cursor pagination. | |
| 252 | + private static func nextPage(from response: HTTPURLResponse) -> URL? { | |
| 253 | + guard let link = response.value(forHTTPHeaderField: "Link") else { return nil } | |
| 254 | + for part in link.split(separator: ",") { | |
| 255 | + let segments = part.split(separator: ";").map { $0.trimmingCharacters(in: .whitespaces) } | |
| 256 | + guard segments.count >= 2, segments.contains(where: { $0 == "rel=\"next\"" }), | |
| 257 | + let urlPart = segments.first, urlPart.hasPrefix("<"), urlPart.hasSuffix(">") | |
| 258 | + else { continue } | |
| 259 | + return URL(string: String(urlPart.dropFirst().dropLast())) | |
| 260 | + } | |
| 261 | + return nil | |
| 262 | + } | |
| 263 | +} | |
| 264 | + | |
| 265 | +/// Architectures supported by the MLX Swift LLM layer (verified list from | |
| 266 | +/// docs/MLX-RESEARCH.md §4 — LLMTypeRegistry @ mlx-swift-lm 3.31.4). | |
| 267 | +enum SupportedArchitectures { | |
| 268 | + static let all: Set<String> = [ | |
| 269 | + "mistral", "mixtral", "llama", "phi", "phi3", "phimoe", "gemma", "gemma2", | |
| 270 | + "gemma3", "gemma3_text", "gemma3n", "gemma4", "gemma4_unified", "gemma4_text", | |
| 271 | + "qwen2", "qwen3", "qwen3_moe", "qwen3_next", "qwen3_5", "qwen3_5_moe", | |
| 272 | + "qwen3_5_text", "minicpm", "starcoder2", "cohere", "openelm", "internlm2", | |
| 273 | + "deepseek_v3", "granite", "granitemoehybrid", "mimo", "mimo_v2_flash", | |
| 274 | + "minimax", "glm4", "glm4_moe", "glm4_moe_lite", "acereason", "falcon_h1", | |
| 275 | + "bitnet", "smollm3", "ernie4_5", "lfm2", "baichuan_m1", "exaone4", "gpt_oss", | |
| 276 | + "lille-130m", "olmoe", "olmo2", "olmo3", "bailing_moe", "lfm2_moe", | |
| 277 | + "nanochat", "nemotron_h", "afmoe", "jamba", "mamba2", "mistral3", "apertus", | |
| 278 | + "nemotron_labs_diffusion", | |
| 279 | + ] | |
| 280 | + | |
| 281 | + static func contains(_ architecture: String) -> Bool { | |
| 282 | + all.contains(architecture) | |
| 283 | + } | |
| 284 | +} | |
added
Sources/ZyquoLocal/Hub/ModelStore.swift
+180 −0
@@ -0,0 +1,180 @@ | ||
| 1 | +// | |
| 2 | +// ModelStore.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import AppKit | |
| 10 | +import Foundation | |
| 11 | +import Observation | |
| 12 | + | |
| 13 | +/// The on-disk model library: scans the Models folder, validates model | |
| 14 | +/// directories, tracks sizes and last-used dates, deletes models. | |
| 15 | +@MainActor | |
| 16 | +@Observable | |
| 17 | +final class ModelStore { | |
| 18 | + private(set) var models: [LocalModel] = [] | |
| 19 | + | |
| 20 | + /// Root: ~/Library/Application Support/ZyquoLocal/Models | |
| 21 | + let modelsRoot: URL | |
| 22 | + | |
| 23 | + /// Sidecar metadata (last used, per-model params) keyed by repoID. | |
| 24 | + private var meta: [String: ModelMeta] = [:] | |
| 25 | + | |
| 26 | + struct ModelMeta: Codable { | |
| 27 | + var lastUsed: Date? | |
| 28 | + var defaultParams: GenerationParams? | |
| 29 | + } | |
| 30 | + | |
| 31 | + var totalSizeBytes: Int64 { | |
| 32 | + models.reduce(0) { $0 + $1.sizeBytes } | |
| 33 | + } | |
| 34 | + | |
| 35 | + init(root: URL = PersistenceService.appSupportDirectory.appendingPathComponent("Models")) { | |
| 36 | + self.modelsRoot = root | |
| 37 | + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) | |
| 38 | + loadMeta() | |
| 39 | + rescan() | |
| 40 | + } | |
| 41 | + | |
| 42 | + /// Directory a repo downloads into: Models/{org}/{repo}. | |
| 43 | + func directory(for repoID: String) -> URL { | |
| 44 | + modelsRoot.appendingPathComponent(repoID) | |
| 45 | + } | |
| 46 | + | |
| 47 | + func model(for repoID: String) -> LocalModel? { | |
| 48 | + models.first { $0.repoID == repoID } | |
| 49 | + } | |
| 50 | + | |
| 51 | + /// Required files for a valid model directory (docs/MLX-RESEARCH.md §3). | |
| 52 | + static func isValidModelDirectory(_ dir: URL) -> Bool { | |
| 53 | + let fm = FileManager.default | |
| 54 | + guard fm.fileExists(atPath: dir.appendingPathComponent("config.json").path) else { return false } | |
| 55 | + guard fm.fileExists(atPath: dir.appendingPathComponent("tokenizer_config.json").path) else { return false } | |
| 56 | + guard fm.fileExists(atPath: dir.appendingPathComponent("tokenizer.json").path) else { return false } | |
| 57 | + guard let contents = try? fm.contentsOfDirectory(atPath: dir.path) else { return false } | |
| 58 | + return contents.contains { $0.hasSuffix(".safetensors") && !$0.hasSuffix(".partial") } | |
| 59 | + } | |
| 60 | + | |
| 61 | + /// Scans Models/{org}/{repo} two levels deep and rebuilds the library. | |
| 62 | + func rescan() { | |
| 63 | + let fm = FileManager.default | |
| 64 | + var found: [LocalModel] = [] | |
| 65 | + let orgs = (try? fm.contentsOfDirectory( | |
| 66 | + at: modelsRoot, includingPropertiesForKeys: nil, | |
| 67 | + options: .skipsHiddenFiles)) ?? [] | |
| 68 | + for org in orgs where org.hasDirectoryPath || (try? org.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true { | |
| 69 | + let repos = (try? fm.contentsOfDirectory( | |
| 70 | + at: org, includingPropertiesForKeys: nil, | |
| 71 | + options: .skipsHiddenFiles)) ?? [] | |
| 72 | + for repo in repos { | |
| 73 | + guard (try? repo.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } | |
| 74 | + guard Self.isValidModelDirectory(repo) else { continue } | |
| 75 | + let repoID = "\(org.lastPathComponent)/\(repo.lastPathComponent)" | |
| 76 | + let size = (try? fm.allocatedSizeOfDirectory(at: repo)) ?? 0 | |
| 77 | + let config = Self.readConfig(directory: repo) | |
| 78 | + found.append(LocalModel( | |
| 79 | + repoID: repoID, | |
| 80 | + directory: repo, | |
| 81 | + sizeBytes: size, | |
| 82 | + architecture: config.architecture, | |
| 83 | + quantization: Self.quantLabel(repoID: repoID, config: config), | |
| 84 | + contextWindow: InferenceEngine.readContextWindow(directory: repo), | |
| 85 | + lastUsed: meta[repoID]?.lastUsed, | |
| 86 | + defaultParams: meta[repoID]?.defaultParams | |
| 87 | + )) | |
| 88 | + } | |
| 89 | + } | |
| 90 | + models = found.sorted { ($0.lastUsed ?? .distantPast) > ($1.lastUsed ?? .distantPast) } | |
| 91 | + } | |
| 92 | + | |
| 93 | + /// Deletes a model directory. Returns the reclaimed bytes. | |
| 94 | + @discardableResult | |
| 95 | + func delete(repoID: String) -> Int64 { | |
| 96 | + guard let model = model(for: repoID) else { return 0 } | |
| 97 | + let reclaimed = model.sizeBytes | |
| 98 | + try? FileManager.default.removeItem(at: model.directory) | |
| 99 | + // Prune the org folder when empty. | |
| 100 | + let org = model.directory.deletingLastPathComponent() | |
| 101 | + if let rest = try? FileManager.default.contentsOfDirectory(atPath: org.path), rest.isEmpty { | |
| 102 | + try? FileManager.default.removeItem(at: org) | |
| 103 | + } | |
| 104 | + meta[repoID] = nil | |
| 105 | + saveMeta() | |
| 106 | + rescan() | |
| 107 | + return reclaimed | |
| 108 | + } | |
| 109 | + | |
| 110 | + func revealInFinder(repoID: String) { | |
| 111 | + guard let model = model(for: repoID) else { return } | |
| 112 | + NSWorkspace.shared.activateFileViewerSelecting([model.directory]) | |
| 113 | + } | |
| 114 | + | |
| 115 | + func markUsed(repoID: String) { | |
| 116 | + var m = meta[repoID] ?? ModelMeta() | |
| 117 | + m.lastUsed = Date() | |
| 118 | + meta[repoID] = m | |
| 119 | + saveMeta() | |
| 120 | + if let i = models.firstIndex(where: { $0.repoID == repoID }) { | |
| 121 | + models[i].lastUsed = m.lastUsed | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 125 | + func setDefaultParams(_ params: GenerationParams?, for repoID: String) { | |
| 126 | + var m = meta[repoID] ?? ModelMeta() | |
| 127 | + m.defaultParams = params | |
| 128 | + meta[repoID] = m | |
| 129 | + saveMeta() | |
| 130 | + if let i = models.firstIndex(where: { $0.repoID == repoID }) { | |
| 131 | + models[i].defaultParams = params | |
| 132 | + } | |
| 133 | + } | |
| 134 | + | |
| 135 | + // MARK: - Config parsing | |
| 136 | + | |
| 137 | + struct ConfigInfo { | |
| 138 | + var architecture: String? | |
| 139 | + var quantBits: Int? | |
| 140 | + } | |
| 141 | + | |
| 142 | + static func readConfig(directory: URL) -> ConfigInfo { | |
| 143 | + let url = directory.appendingPathComponent("config.json") | |
| 144 | + guard let data = try? Data(contentsOf: url), | |
| 145 | + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] | |
| 146 | + else { return ConfigInfo() } | |
| 147 | + var bits: Int? | |
| 148 | + if let quant = json["quantization"] as? [String: Any] { | |
| 149 | + bits = quant["bits"] as? Int | |
| 150 | + } | |
| 151 | + return ConfigInfo(architecture: json["model_type"] as? String, quantBits: bits) | |
| 152 | + } | |
| 153 | + | |
| 154 | + /// "4bit", "8bit", "bf16"… parsed from repo name, falling back to config. | |
| 155 | + static func quantLabel(repoID: String, config: ConfigInfo) -> String? { | |
| 156 | + let name = repoID.lowercased() | |
| 157 | + for candidate in ["4bit", "5bit", "6bit", "8bit", "bf16", "fp16", "mxfp4"] { | |
| 158 | + if name.contains(candidate) { return candidate } | |
| 159 | + } | |
| 160 | + if let bits = config.quantBits { return "\(bits)bit" } | |
| 161 | + return nil | |
| 162 | + } | |
| 163 | + | |
| 164 | + // MARK: - Meta persistence | |
| 165 | + | |
| 166 | + private var metaURL: URL { | |
| 167 | + PersistenceService.appSupportDirectory.appendingPathComponent("library-meta.json") | |
| 168 | + } | |
| 169 | + | |
| 170 | + private func loadMeta() { | |
| 171 | + guard let data = try? Data(contentsOf: metaURL), | |
| 172 | + let decoded = try? JSONDecoder().decode([String: ModelMeta].self, from: data) | |
| 173 | + else { return } | |
| 174 | + meta = decoded | |
| 175 | + } | |
| 176 | + | |
| 177 | + private func saveMeta() { | |
| 178 | + try? JSONEncoder().encode(meta).write(to: metaURL, options: .atomic) | |
| 179 | + } | |
| 180 | +} | |
added
Sources/ZyquoLocal/Services/ModelCatalog.swift
+162 −0
@@ -0,0 +1,162 @@ | ||
| 1 | +// | |
| 2 | +// ModelCatalog.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// The curated Featured catalog. GENERATED FROM docs/MODELS.md §3 — the two | |
| 12 | +/// must never drift apart: any Phase 7 verification fix updates both together. | |
| 13 | +/// Sizes are decimal GB, live-verified on the Hub on 2026-07-30. | |
| 14 | +struct CatalogModel: Identifiable, Hashable, Sendable { | |
| 15 | + enum Category: String, CaseIterable, Sendable { | |
| 16 | + case tiny | |
| 17 | + case mid | |
| 18 | + case large | |
| 19 | + case coding | |
| 20 | + case reasoning | |
| 21 | + case general | |
| 22 | + } | |
| 23 | + | |
| 24 | + var repoID: String | |
| 25 | + var params: String | |
| 26 | + var quant: String | |
| 27 | + var sizeGB: Double | |
| 28 | + var minRAMGB: Int | |
| 29 | + var categories: [Category] | |
| 30 | + var blurb: String | |
| 31 | + | |
| 32 | + var id: String { repoID } | |
| 33 | + | |
| 34 | + var sizeBytes: Int64 { Int64(sizeGB * 1_000_000_000) } | |
| 35 | + | |
| 36 | + /// RAM verdict for THIS Mac. | |
| 37 | + var verdict: MemoryAdvisor.Verdict { | |
| 38 | + MemoryAdvisor.verdict(weightsBytes: sizeBytes) | |
| 39 | + } | |
| 40 | +} | |
| 41 | + | |
| 42 | +enum ModelCatalog { | |
| 43 | + /// Starter picks for the empty-state onboarding hero, chosen for this | |
| 44 | + /// Mac's RAM from the featured list (smallest that fit comfortably). | |
| 45 | + static func starterPicks(count: Int = 4) -> [CatalogModel] { | |
| 46 | + let ramGB = Int(MemoryAdvisor.physicalMemoryBytes / 1_073_741_824) | |
| 47 | + var picks: [CatalogModel] = [] | |
| 48 | + // One instant-download tiny, then the best generalists that fit. | |
| 49 | + if let tiny = featured.first(where: { $0.repoID.contains("Qwen3-0.6B") }) { | |
| 50 | + picks.append(tiny) | |
| 51 | + } | |
| 52 | + let comfortable = featured | |
| 53 | + .filter { $0.minRAMGB <= ramGB && $0.verdict == .fits && !picks.contains($0) } | |
| 54 | + .sorted { $0.sizeGB > $1.sizeGB } | |
| 55 | + for model in comfortable where picks.count < count { | |
| 56 | + picks.append(model) | |
| 57 | + } | |
| 58 | + return picks | |
| 59 | + } | |
| 60 | + | |
| 61 | + static let featured: [CatalogModel] = [ | |
| 62 | + // ── Tiny (≤4B) ────────────────────────────────────────────────────── | |
| 63 | + CatalogModel(repoID: "mlx-community/Qwen3-0.6B-4bit", params: "0.6B", quant: "4bit", | |
| 64 | + sizeGB: 0.35, minRAMGB: 8, categories: [.tiny, .general], | |
| 65 | + blurb: "Smallest useful chat model; instant loads."), | |
| 66 | + CatalogModel(repoID: "mlx-community/LFM2.5-1.2B-Instruct-4bit", params: "1.2B", quant: "4bit", | |
| 67 | + sizeGB: 0.66, minRAMGB: 8, categories: [.tiny, .general], | |
| 68 | + blurb: "Liquid AI's 2026 edge model; punchy and very fast."), | |
| 69 | + CatalogModel(repoID: "mlx-community/Llama-3.2-1B-Instruct-4bit", params: "1B", quant: "4bit", | |
| 70 | + sizeGB: 0.71, minRAMGB: 8, categories: [.tiny, .general], | |
| 71 | + blurb: "The classic 1B; most-downloaded tiny LLM."), | |
| 72 | + CatalogModel(repoID: "mlx-community/gemma-3-1b-it-qat-4bit", params: "1B", quant: "QAT-4bit", | |
| 73 | + sizeGB: 0.77, minRAMGB: 8, categories: [.tiny, .general], | |
| 74 | + blurb: "Google QAT checkpoint — best quality-per-byte at 1B."), | |
| 75 | + CatalogModel(repoID: "mlx-community/Qwen3-1.7B-4bit", params: "1.7B", quant: "4bit", | |
| 76 | + sizeGB: 0.98, minRAMGB: 8, categories: [.tiny, .general, .reasoning], | |
| 77 | + blurb: "Hybrid thinking modes in under 1 GB."), | |
| 78 | + CatalogModel(repoID: "mlx-community/SmolLM3-3B-4bit", params: "3B", quant: "4bit", | |
| 79 | + sizeGB: 1.75, minRAMGB: 8, categories: [.tiny, .general], | |
| 80 | + blurb: "HF's fully-open 3B; long context, optional reasoning."), | |
| 81 | + CatalogModel(repoID: "mlx-community/Llama-3.2-3B-Instruct-4bit", params: "3B", quant: "4bit", | |
| 82 | + sizeGB: 1.82, minRAMGB: 8, categories: [.tiny, .general], | |
| 83 | + blurb: "The default “runs anywhere” pick."), | |
| 84 | + CatalogModel(repoID: "mlx-community/Qwen3-4B-Instruct-2507-4bit", params: "4B", quant: "4bit", | |
| 85 | + sizeGB: 2.28, minRAMGB: 8, categories: [.tiny, .general], | |
| 86 | + blurb: "2507 refresh — best ≤4B all-rounder."), | |
| 87 | + CatalogModel(repoID: "mlx-community/gemma-3-4b-it-qat-4bit", params: "4B", quant: "QAT-4bit", | |
| 88 | + sizeGB: 3.03, minRAMGB: 8, categories: [.tiny, .general], | |
| 89 | + blurb: "Vision-capable 4B with QAT quality."), | |
| 90 | + | |
| 91 | + // ── Mid (7–20B) ───────────────────────────────────────────────────── | |
| 92 | + CatalogModel(repoID: "mlx-community/Llama-3.1-8B-Instruct-4bit", params: "8B", quant: "4bit", | |
| 93 | + sizeGB: 4.53, minRAMGB: 16, categories: [.mid, .general], | |
| 94 | + blurb: "The reference 8B; huge prompt/finetune ecosystem."), | |
| 95 | + CatalogModel(repoID: "mlx-community/Qwen3-8B-4bit", params: "8B", quant: "4bit", | |
| 96 | + sizeGB: 4.62, minRAMGB: 16, categories: [.mid, .general, .reasoning], | |
| 97 | + blurb: "Best-selling 8B; thinking mode on demand."), | |
| 98 | + CatalogModel(repoID: "mlx-community/gemma-3-12b-it-qat-4bit", params: "12B", quant: "QAT-4bit", | |
| 99 | + sizeGB: 8.07, minRAMGB: 16, categories: [.mid, .general], | |
| 100 | + blurb: "Sweet spot for 16 GB Macs; strong writing."), | |
| 101 | + CatalogModel(repoID: "mlx-community/Qwen3.5-9B-OptiQ-4bit", params: "9B", quant: "OptiQ-4bit", | |
| 102 | + sizeGB: 8.22, minRAMGB: 16, categories: [.mid, .general], | |
| 103 | + blurb: "2026 Qwen3.5 generation; top mid-size quality."), | |
| 104 | + CatalogModel(repoID: "mlx-community/phi-4-4bit", params: "14.7B", quant: "4bit", | |
| 105 | + sizeGB: 8.26, minRAMGB: 16, categories: [.mid, .general, .reasoning], | |
| 106 | + blurb: "Microsoft dense 14B; excels at math and STEM."), | |
| 107 | + CatalogModel(repoID: "mlx-community/Qwen3-14B-4bit", params: "14B", quant: "4bit", | |
| 108 | + sizeGB: 8.32, minRAMGB: 16, categories: [.mid, .general, .reasoning], | |
| 109 | + blurb: "Stronger sibling of Qwen3-8B; 16 GB flagship."), | |
| 110 | + CatalogModel(repoID: "mlx-community/gpt-oss-20b-MXFP4-Q8", params: "20.9B MoE", quant: "MXFP4-Q8", | |
| 111 | + sizeGB: 12.10, minRAMGB: 16, categories: [.mid, .general, .reasoning], | |
| 112 | + blurb: "OpenAI's open-weights MoE; most-downloaded LLM in the org."), | |
| 113 | + | |
| 114 | + // ── Large (24B+) ──────────────────────────────────────────────────── | |
| 115 | + CatalogModel(repoID: "mlx-community/Mistral-Small-3.2-24B-Instruct-2506-4bit", params: "24B", quant: "4bit", | |
| 116 | + sizeGB: 13.28, minRAMGB: 24, categories: [.large, .general], | |
| 117 | + blurb: "Fast dense 24B, low hallucination, good tool use."), | |
| 118 | + CatalogModel(repoID: "mlx-community/gemma-3-27b-it-qat-4bit", params: "27B", quant: "QAT-4bit", | |
| 119 | + sizeGB: 16.87, minRAMGB: 32, categories: [.large, .general], | |
| 120 | + blurb: "Gemma 3 flagship with QAT; superb chat quality."), | |
| 121 | + CatalogModel(repoID: "mlx-community/GLM-4.7-Flash-4bit", params: "30B MoE", quant: "4bit", | |
| 122 | + sizeGB: 16.87, minRAMGB: 32, categories: [.large, .general, .coding], | |
| 123 | + blurb: "Zhipu's 2026 fast MoE; strong agentic coding."), | |
| 124 | + CatalogModel(repoID: "mlx-community/Qwen3-30B-A3B-Instruct-2507-4bit", params: "30B-A3B MoE", quant: "4bit", | |
| 125 | + sizeGB: 17.20, minRAMGB: 32, categories: [.large, .general], | |
| 126 | + blurb: "3B active params → big-model quality at small-model speed."), | |
| 127 | + CatalogModel(repoID: "mlx-community/Qwen3-32B-4bit", params: "32B", quant: "4bit", | |
| 128 | + sizeGB: 18.45, minRAMGB: 32, categories: [.large, .general, .reasoning], | |
| 129 | + blurb: "Dense 32B with thinking; slower but deeper than the MoE."), | |
| 130 | + CatalogModel(repoID: "mlx-community/Qwen3.6-27B-OptiQ-4bit", params: "27B", quant: "OptiQ-4bit", | |
| 131 | + sizeGB: 20.00, minRAMGB: 32, categories: [.large, .general], | |
| 132 | + blurb: "2026 Qwen3.6 dense; MTP head ≈1.4× faster decode."), | |
| 133 | + CatalogModel(repoID: "mlx-community/Qwen3.6-35B-A3B-OptiQ-4bit", params: "35B-A3B MoE", quant: "OptiQ-4bit", | |
| 134 | + sizeGB: 24.69, minRAMGB: 48, categories: [.large, .general], | |
| 135 | + blurb: "2026 successor to Qwen3-30B-A3B."), | |
| 136 | + CatalogModel(repoID: "mlx-community/Llama-3.3-70B-Instruct-4bit", params: "70B", quant: "4bit", | |
| 137 | + sizeGB: 39.71, minRAMGB: 64, categories: [.large, .general], | |
| 138 | + blurb: "The 70B reference; 64 GB+ Macs only."), | |
| 139 | + | |
| 140 | + // ── Coding ────────────────────────────────────────────────────────── | |
| 141 | + CatalogModel(repoID: "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit", params: "7B", quant: "4bit", | |
| 142 | + sizeGB: 4.30, minRAMGB: 16, categories: [.coding], | |
| 143 | + blurb: "The default small local code model."), | |
| 144 | + CatalogModel(repoID: "mlx-community/Qwen2.5-Coder-14B-Instruct-4bit", params: "14B", quant: "4bit", | |
| 145 | + sizeGB: 8.32, minRAMGB: 16, categories: [.coding], | |
| 146 | + blurb: "Noticeably better completions on 16 GB Macs."), | |
| 147 | + CatalogModel(repoID: "mlx-community/Qwen3-Coder-30B-A3B-Instruct-4bit", params: "30B-A3B MoE", quant: "4bit", | |
| 148 | + sizeGB: 17.20, minRAMGB: 32, categories: [.coding, .large], | |
| 149 | + blurb: "Best local coding model for 32 GB; fast agentic loops."), | |
| 150 | + | |
| 151 | + // ── Reasoning ─────────────────────────────────────────────────────── | |
| 152 | + CatalogModel(repoID: "mlx-community/DeepSeek-R1-0528-Qwen3-8B-4bit", params: "8B", quant: "4bit", | |
| 153 | + sizeGB: 4.62, minRAMGB: 16, categories: [.reasoning], | |
| 154 | + blurb: "R1-0528 distill onto Qwen3-8B; visible chain-of-thought."), | |
| 155 | + CatalogModel(repoID: "mlx-community/DeepSeek-R1-Distill-Qwen-14B-4bit", params: "14B", quant: "4bit", | |
| 156 | + sizeGB: 8.32, minRAMGB: 16, categories: [.reasoning], | |
| 157 | + blurb: "Most-downloaded R1 distill; great math on 16 GB."), | |
| 158 | + CatalogModel(repoID: "mlx-community/Qwen3-30B-A3B-Thinking-2507-4bit", params: "30B-A3B MoE", quant: "4bit", | |
| 159 | + sizeGB: 17.20, minRAMGB: 32, categories: [.reasoning, .large], | |
| 160 | + blurb: "Current best local reasoner under 32 GB."), | |
| 161 | + ] | |
| 162 | +} | |
added
Sources/ZyquoLocal/Services/PersistenceService.swift
+76 −0
@@ -0,0 +1,76 @@ | ||
| 1 | +// | |
| 2 | +// PersistenceService.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Conversations, personas and settings persisted as JSON under | |
| 12 | +/// ~/Library/Application Support/ZyquoLocal/. | |
| 13 | +enum PersistenceService { | |
| 14 | + static var appSupportDirectory: URL { | |
| 15 | + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | |
| 16 | + let dir = base.appendingPathComponent("ZyquoLocal") | |
| 17 | + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | |
| 18 | + return dir | |
| 19 | + } | |
| 20 | + | |
| 21 | + private static var conversationsDirectory: URL { | |
| 22 | + let dir = appSupportDirectory.appendingPathComponent("Conversations") | |
| 23 | + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | |
| 24 | + return dir | |
| 25 | + } | |
| 26 | + | |
| 27 | + private static let encoder: JSONEncoder = { | |
| 28 | + let e = JSONEncoder() | |
| 29 | + e.dateEncodingStrategy = .iso8601 | |
| 30 | + e.outputFormatting = [.sortedKeys] | |
| 31 | + return e | |
| 32 | + }() | |
| 33 | + | |
| 34 | + private static let decoder: JSONDecoder = { | |
| 35 | + let d = JSONDecoder() | |
| 36 | + d.dateDecodingStrategy = .iso8601 | |
| 37 | + return d | |
| 38 | + }() | |
| 39 | + | |
| 40 | + // MARK: - Conversations (one JSON file per conversation) | |
| 41 | + | |
| 42 | + static func loadConversations() -> [Conversation] { | |
| 43 | + let files = (try? FileManager.default.contentsOfDirectory( | |
| 44 | + at: conversationsDirectory, includingPropertiesForKeys: nil)) ?? [] | |
| 45 | + return files | |
| 46 | + .filter { $0.pathExtension == "json" } | |
| 47 | + .compactMap { url in | |
| 48 | + guard let data = try? Data(contentsOf: url) else { return nil } | |
| 49 | + return try? decoder.decode(Conversation.self, from: data) | |
| 50 | + } | |
| 51 | + .sorted { $0.updatedAt > $1.updatedAt } | |
| 52 | + } | |
| 53 | + | |
| 54 | + static func save(_ conversation: Conversation) { | |
| 55 | + let url = conversationsDirectory.appendingPathComponent("\(conversation.id.uuidString).json") | |
| 56 | + try? encoder.encode(conversation).write(to: url, options: .atomic) | |
| 57 | + } | |
| 58 | + | |
| 59 | + static func delete(conversationID: UUID) { | |
| 60 | + let url = conversationsDirectory.appendingPathComponent("\(conversationID.uuidString).json") | |
| 61 | + try? FileManager.default.removeItem(at: url) | |
| 62 | + } | |
| 63 | + | |
| 64 | + // MARK: - Generic single-document storage (settings, personas, prompts) | |
| 65 | + | |
| 66 | + static func loadDocument<T: Decodable>(_ type: T.Type, named name: String) -> T? { | |
| 67 | + let url = appSupportDirectory.appendingPathComponent("\(name).json") | |
| 68 | + guard let data = try? Data(contentsOf: url) else { return nil } | |
| 69 | + return try? decoder.decode(T.self, from: data) | |
| 70 | + } | |
| 71 | + | |
| 72 | + static func saveDocument<T: Encodable>(_ value: T, named name: String) { | |
| 73 | + let url = appSupportDirectory.appendingPathComponent("\(name).json") | |
| 74 | + try? encoder.encode(value).write(to: url, options: .atomic) | |
| 75 | + } | |
| 76 | +} | |
modified
docs/PLAN.md
+13 −1
@@ -63,7 +63,19 @@ Executable dispatches --poc and exits 64 as designed. | ||
| 63 | 63 | first-class; `<think>` content streams through (reasoning display feeds on it |
| 64 | 64 | in Phase 6). Multi-turn KV reuse + cancellation get their formal end-to-end |
| 65 | 65 | tests in the Phase 7 harness. |
| 66 | −## Phase 3 — Hub browse & download — pending | |
| 66 | +## Phase 3 — Hub browse & download — ✅ DONE (2026-07-30) | |
| 67 | + | |
| 68 | +- [x] HubService: live search (scopes featured/mlx-community/all-MLX, config=true arch check), tree file listing with Link-header pagination, Codable structs throughout, HF-token support, human-readable error surfaces (gated/429/…) | |
| 69 | +- [x] SupportedArchitectures shipping the verified LLMTypeRegistry list | |
| 70 | +- [x] FileTransfer: delegate-backed chunked transport, Range resume (206), transparent restart (200), 416 handling, cross-host Authorization strip, cancellation | |
| 71 | +- [x] DownloadManager: queued, 2 concurrent files, pause/resume/cancel per model, transient-drop retry with backoff, atomic .partial → final, size verification, disk pre-check (5 % headroom), manifest persisted across relaunch, speed EMA + ETA | |
| 72 | +- [x] ModelStore: scan/validate (required-file check), size on disk, delete + reclaim, reveal in Finder, last-used + per-model default params sidecar | |
| 73 | +- [x] Services: PersistenceService (JSON per conversation + generic docs), ModelCatalog (30 featured entries mirroring docs/MODELS.md, starter picks per RAM) | |
| 74 | +- [x] PHASE GATE (--hub-poc, isolated temp store): search ✅ listing ✅ download 41 MB/s ✅ pause at 27 % ✅ Range-resume from 100 MB ✅ verify ✅ scan (arch qwen3, 4bit, ctx 40960, Fits) ✅ delete ✅ | |
| 75 | + | |
| 76 | +**Checkpoint:** Zero warnings. Coherence sweep: headers green, `LocalModel` is | |
| 77 | +the single term, folder structure matches the Phase 2 tree (DesignSystem/, | |
| 78 | +ViewModels/, Views/ arrive with Phases 4/6). | |
| 67 | 79 | ## Phase 4 — Design system & UI spec — pending |
| 68 | 80 | ## Phase 5 — App icon — pending |
| 69 | 81 | ## Phase 6 — Features / full UI — pending |
| 70 | 82 | |