feat: improve on-device model download flow

This commit is contained in:
Kellyvv committed 2026-04-07 16:19:56 +08:00
1 parent 3d5c1ca67f
commit 446d216088
2 files changed
+250 -17

No files matched your search

+179 -6
View File
@@ -27,6 +27,13 @@ public enum ModelInstallState: Equatable, Sendable {
case failed(String)
}
public struct ModelDownloadMetrics: Equatable, Sendable {
public let bytesReceived: Int64
public let totalBytes: Int64?
public let bytesPerSecond: Double?
public let sourceLabel: String?
}
/// MLX GPU inference service for Gemma 4.
/// Forces MLX Metal GPU path — no CPU fallback.
@Observable
@@ -102,6 +109,7 @@ public class MLXLocalLLMService: LLMEngine {
public var selectedModelID: String { selectedModel.id }
public var loadedModelID: String? { loadedModel?.id }
public private(set) var modelInstallStates: [String: ModelInstallState] = [:]
public private(set) var modelDownloadMetrics: [String: ModelDownloadMetrics] = [:]
// MARK: - Compatibility Settings
@@ -121,6 +129,104 @@ public class MLXLocalLLMService: LLMEngine {
private var lifecycleObserverTokens: [NSObjectProtocol] = []
private var audioCapabilityEnabled = false
private struct DownloadSource: Sendable {
let label: String
let url: URL
}
private final class DownloadTaskClient: NSObject, URLSessionDownloadDelegate {
private let progressHandler: @Sendable (Int64, Int64?) -> Void
private var continuation: CheckedContinuation<(URL, URLResponse), Error>?
private var downloadedFileURL: URL?
private var response: URLResponse?
private lazy var session: URLSession = {
URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil)
}()
private var task: URLSessionDownloadTask?
init(progressHandler: @escaping @Sendable (Int64, Int64?) -> Void) {
self.progressHandler = progressHandler
}
func start(request: URLRequest) async throws -> (URL, URLResponse) {
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
let task = session.downloadTask(with: request)
self.task = task
task.resume()
}
} onCancel: {
self.cancel()
}
}
func cancel() {
task?.cancel()
session.invalidateAndCancel()
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
let expected = totalBytesExpectedToWrite > 0 ? totalBytesExpectedToWrite : nil
progressHandler(totalBytesWritten, expected)
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
do {
let temporaryFileURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(location.pathExtension.isEmpty ? "tmp" : location.pathExtension)
if FileManager.default.fileExists(atPath: temporaryFileURL.path) {
try FileManager.default.removeItem(at: temporaryFileURL)
}
try FileManager.default.moveItem(at: location, to: temporaryFileURL)
downloadedFileURL = temporaryFileURL
response = downloadTask.response
} catch {
downloadedFileURL = nil
response = nil
continuation?.resume(throwing: error)
continuation = nil
self.task = nil
session.finishTasksAndInvalidate()
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
defer {
continuation = nil
downloadedFileURL = nil
response = nil
self.task = nil
session.finishTasksAndInvalidate()
}
if let error {
continuation?.resume(throwing: error)
return
}
guard let downloadedFileURL, let response else {
continuation?.resume(throwing: DownloadError.invalidResponse)
return
}
continuation?.resume(returning: (downloadedFileURL, response))
}
}
/// Local path to the model directory
private var modelPath: URL {
Self.resolveModelPath(for: selectedModel)
@@ -251,6 +357,10 @@ public class MLXLocalLLMService: LLMEngine {
}
}
public func downloadMetrics(for modelID: String) -> ModelDownloadMetrics? {
modelDownloadMetrics[modelID]
}
private func cleanupStalePartialDirectories() {
let fm = FileManager.default
for model in Self.availableModels {
@@ -261,9 +371,25 @@ public class MLXLocalLLMService: LLMEngine {
}
}
private func huggingFaceURL(for model: BundledModelOption, file: String) -> URL? {
let rawPath = "\(model.repositoryID)/resolve/main/\(file)"
return URL(string: "https://huggingface.co/" + rawPath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)!.replacingOccurrences(of: "%2F", with: "/"))
private func preferredDownloadSources(for model: BundledModelOption, file: String) -> [DownloadSource] {
let encodedPath = "\(model.repositoryID)/resolve/main/\(file)"
.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)?
.replacingOccurrences(of: "%2F", with: "/")
guard let encodedPath else { return [] }
return [
.init(label: "hf-mirror.com", url: URL(string: "https://hf-mirror.com/\(encodedPath)")!),
.init(label: "huggingface.co", url: URL(string: "https://huggingface.co/\(encodedPath)")!)
]
}
private func downloadFile(
with request: URLRequest,
progress: @escaping @Sendable (Int64, Int64?) -> Void
) async throws -> (URL, URLResponse) {
let client = DownloadTaskClient(progressHandler: progress)
return try await client.start(request: request)
}
public func downloadModel(id: String) async {
@@ -298,20 +424,64 @@ public class MLXLocalLLMService: LLMEngine {
let totalFiles = model.requiredFiles.count
for (index, file) in model.requiredFiles.enumerated() {
guard let url = huggingFaceURL(for: model, file: file) else {
let downloadSources = preferredDownloadSources(for: model, file: file)
guard !downloadSources.isEmpty else {
throw DownloadError.invalidURL(file)
}
var downloadedResult: (URL, URLResponse)?
var lastError: Error?
for source in downloadSources {
try Task.checkCancellation()
await MainActor.run {
self.modelInstallStates[id] = .downloading(
completedFiles: index,
totalFiles: totalFiles,
currentFile: file
)
self.modelDownloadMetrics[id] = .init(
bytesReceived: 0,
totalBytes: nil,
bytesPerSecond: nil,
sourceLabel: source.label
)
}
let request = URLRequest(
url: source.url,
cachePolicy: .reloadIgnoringLocalCacheData,
timeoutInterval: 1800
)
let startTime = Date()
do {
let result = try await downloadFile(with: request) { received, expected in
let elapsed = max(Date().timeIntervalSince(startTime), 0.001)
let bytesPerSecond = Double(received) / elapsed
Task { @MainActor [weak self] in
self?.modelDownloadMetrics[id] = .init(
bytesReceived: received,
totalBytes: expected,
bytesPerSecond: bytesPerSecond,
sourceLabel: source.label
)
}
}
downloadedResult = result
break
} catch is CancellationError {
throw CancellationError()
} catch {
lastError = error
}
}
guard let (temporaryURL, response) = downloadedResult else {
throw lastError ?? DownloadError.invalidResponse
}
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 1800)
let (temporaryURL, response) = try await URLSession.shared.download(for: request)
guard let http = response as? HTTPURLResponse else {
throw DownloadError.invalidResponse
}
@@ -337,17 +507,20 @@ public class MLXLocalLLMService: LLMEngine {
await MainActor.run {
self.modelInstallStates[id] = .downloaded
self.modelDownloadMetrics[id] = nil
self.refreshModelInstallStates()
}
} catch is CancellationError {
try? fm.removeItem(at: partialDirectory)
await MainActor.run {
self.modelInstallStates[id] = .notInstalled
self.modelDownloadMetrics[id] = nil
self.refreshModelInstallStates()
}
} catch {
try? fm.removeItem(at: partialDirectory)
await MainActor.run {
self.modelDownloadMetrics[id] = nil
self.modelInstallStates[id] = .failed(error.localizedDescription)
}
}
+71 -11
View File
@@ -187,6 +187,10 @@ struct ConfigurationsView: View {
VStack(spacing: 10) {
ForEach(engine.availableModels) { model in
let state = engine.llm.installState(for: model)
let isDownloading: Bool = {
if case .downloading = state { return true }
return false
}()
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
@@ -200,6 +204,15 @@ struct ConfigurationsView: View {
Spacer()
if isDownloading {
if selectedModelID == model.id {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.accent)
} else {
Image(systemName: "circle")
.foregroundStyle(Theme.textTertiary)
}
} else {
VStack(alignment: .trailing, spacing: 8) {
modelStateControl(for: model, state: state)
@@ -212,6 +225,15 @@ struct ConfigurationsView: View {
}
}
}
}
if case let .downloading(completedFiles, totalFiles, _) = state {
downloadProgressBadge(
modelID: model.id,
completedFiles: completedFiles,
totalFiles: totalFiles
)
}
if let detail = modelStateDetail(state) {
Text(detail)
@@ -425,19 +447,28 @@ struct ConfigurationsView: View {
) -> some View {
let safeTotal = max(totalFiles, 1)
let value = Double(min(completedFiles, safeTotal))
return HStack(spacing: 8) {
VStack(alignment: .trailing, spacing: 4) {
let metrics = engine.llm.downloadMetrics(for: modelID)
return VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text(localized(
"下载中 \(completedFiles)/\(totalFiles)",
"Downloading \(completedFiles)/\(totalFiles)"
))
.font(.caption.weight(.semibold))
.foregroundStyle(Theme.textTertiary)
.foregroundStyle(Theme.textPrimary)
.lineLimit(1)
ProgressView(value: value, total: Double(safeTotal))
.progressViewStyle(.linear)
.frame(width: 110)
if let metrics {
Text(downloadMetricsText(metrics))
.font(.caption2.monospacedDigit())
.foregroundStyle(Theme.textTertiary)
.lineLimit(1)
}
}
Spacer(minLength: 8)
Button(localized("取消", "Cancel")) {
engine.llm.cancelModelDownload(id: modelID)
@@ -447,20 +478,49 @@ struct ConfigurationsView: View {
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Theme.bg, in: Capsule())
.fixedSize(horizontal: true, vertical: true)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
ProgressView(value: value, total: Double(safeTotal))
.progressViewStyle(.linear)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Theme.textTertiary.opacity(0.14), in: RoundedRectangle(cornerRadius: 12))
}
private func downloadMetricsText(_ metrics: ModelDownloadMetrics) -> String {
let speedText = formattedSpeed(metrics.bytesPerSecond)
if let totalBytes = metrics.totalBytes, totalBytes > 0 {
return "\(formattedBytes(metrics.bytesReceived)) / \(formattedBytes(totalBytes)) · \(speedText)"
}
return "\(formattedBytes(metrics.bytesReceived)) · \(speedText)"
}
private func formattedBytes(_ bytes: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useKB, .useMB, .useGB]
formatter.countStyle = .file
formatter.includesUnit = true
formatter.isAdaptive = true
return formatter.string(fromByteCount: bytes)
}
private func formattedSpeed(_ bytesPerSecond: Double?) -> String {
guard let bytesPerSecond, bytesPerSecond > 0 else {
return localized("计算中…", "Calculating…")
}
return localized("速度 ", "Speed ") + formattedBytes(Int64(bytesPerSecond)) + "/s"
}
private func modelStateDetail(_ state: ModelInstallState) -> String? {
switch state {
case .notInstalled:
return localized("未安装", "Not Installed")
case .checkingSource:
return localized("正在检查模型下载源。", "Checking the model download source.")
case .downloading(_, _, let currentFile):
return localized("正在下载:", "Downloading: ") + currentFile
return localized("正在准备下载。", "Preparing download.")
case .downloading:
return nil
case .downloaded:
return localized("已下载到手机本地,可直接加载。", "Downloaded on device and ready to load.")
case .bundled: