feat: improve on-device model download flow
This commit is contained in:
1 parent
3d5c1ca67f
commit
446d216088
2 files changed
+250
-17
No files matched your search
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user