feat(models): 模型自动下载(我的·模型管理) + 断点续传 + 旁路导入
实现 spec(2026-05-29-model-download-design)的模型分发功能: - ModelManifest: 硬编码功能文件清单 + base URL https://file.myv0.com/ - FileDownloader: URLSessionDataDelegate 分块写盘,HTTP Range 断点续传 + 大小校验 (根因修复:URL.resourceValues 会缓存文件大小,续传时先读 offset 再读 finalSize 会拿到下载前的陈旧值导致校验误判;改用 FileManager.attributesOfItem) - ModelDownloadService: @MainActor @Observable 编排逐文件下载,聚合进度/速度, 支持下载全部/暂停/重试,以及旁路文件导入 - ModelStore: 新增 fileURL/localBytes/isComplete(可注入清单)/importModel(补 VL) - ModelManagementView: 分模型卡片(状态/进度/速度) + 下载全部/暂停 + NWPathMonitor 蜂窝提示 + 从文件导入(离线兜底) - MeView: 模型管理卡改 NavigationLink + 动态状态(已就绪/下载中/N就绪) 测试(Swift Testing): Manifest 清单/字节数、Store 路径/校验/导入、 DownloadState、FileDownloader(URLProtocol mock:下载/Range续传/大小校验) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
158
康康/AI/FileDownloader.swift
Normal file
158
康康/AI/FileDownloader.swift
Normal file
@@ -0,0 +1,158 @@
|
||||
import Foundation
|
||||
|
||||
enum DownloadError: Error, LocalizedError {
|
||||
case badStatus(Int)
|
||||
case sizeMismatch(expected: Int, got: Int)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .badStatus(let code):
|
||||
return "下载失败(HTTP \(code))"
|
||||
case .sizeMismatch(let expected, let got):
|
||||
return "文件大小校验失败(预期 \(expected),实际 \(got))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载单个文件,支持 HTTP Range 断点续传 + 完成后大小校验。
|
||||
/// 用 `URLSessionDataDelegate` 把响应体分块写入 `.part`,完成后原子改名为成品。
|
||||
///
|
||||
/// 注意:文件大小一律用 `FileManager.attributesOfItem` 读取,**不用**
|
||||
/// `URL.resourceValues(.fileSizeKey)` —— 后者会把结果缓存在 URL 实例上,
|
||||
/// 续传时先读 offset 再读 finalSize 会拿到下载前的陈旧大小,导致误判校验失败。
|
||||
///
|
||||
/// 一个实例一次处理一个文件(串行)。共享状态用锁保证可见性。
|
||||
final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendable {
|
||||
private let configuration: URLSessionConfiguration
|
||||
|
||||
private let lock = NSLock()
|
||||
private var handle: FileHandle?
|
||||
private var written: Int = 0
|
||||
private var onProgress: ((Int) -> Void)?
|
||||
private var responseError: Error?
|
||||
private var continuation: CheckedContinuation<Void, Error>?
|
||||
|
||||
init(configuration: URLSessionConfiguration = .default) {
|
||||
self.configuration = configuration
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// 不走 URL 资源值缓存的文件大小读取。
|
||||
static func fileSize(at url: URL) -> Int {
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let size = attrs[.size] as? Int else { return 0 }
|
||||
return size
|
||||
}
|
||||
|
||||
/// 从 `url` 下载到 `destination`。若存在 `destination.part` 则发 Range 请求续传;
|
||||
/// 完成后校验总大小 == `expectedBytes`,通过则原子改名为 `destination`。
|
||||
nonisolated func download(
|
||||
from url: URL,
|
||||
to destination: URL,
|
||||
expectedBytes: Int,
|
||||
onProgress: (@Sendable (Int) -> Void)? = nil
|
||||
) async throws {
|
||||
let fm = FileManager.default
|
||||
let part = destination.appendingPathExtension("part")
|
||||
|
||||
// 成品已存在且大小正确 → 跳过
|
||||
if Self.fileSize(at: destination) == expectedBytes,
|
||||
fm.fileExists(atPath: destination.path) {
|
||||
return
|
||||
}
|
||||
|
||||
try fm.createDirectory(
|
||||
at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
|
||||
var offset = 0
|
||||
if fm.fileExists(atPath: part.path) {
|
||||
offset = Self.fileSize(at: part)
|
||||
} else {
|
||||
fm.createFile(atPath: part.path, contents: nil)
|
||||
}
|
||||
|
||||
let fileHandle = try FileHandle(forWritingTo: part)
|
||||
try fileHandle.seekToEnd()
|
||||
|
||||
lock.lock()
|
||||
self.handle = fileHandle
|
||||
self.written = offset
|
||||
self.onProgress = onProgress
|
||||
self.responseError = nil
|
||||
lock.unlock()
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
if offset > 0 {
|
||||
request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
|
||||
}
|
||||
|
||||
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
// 句柄在 didCompleteWithError 内关闭(同一 delegate 队列,串行于 didReceive)。
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
lock.lock()
|
||||
self.continuation = cont
|
||||
lock.unlock()
|
||||
session.dataTask(with: request).resume()
|
||||
}
|
||||
|
||||
let finalSize = Self.fileSize(at: part)
|
||||
guard finalSize == expectedBytes else {
|
||||
try? fm.removeItem(at: part)
|
||||
throw DownloadError.sizeMismatch(expected: expectedBytes, got: finalSize)
|
||||
}
|
||||
|
||||
if fm.fileExists(atPath: destination.path) {
|
||||
try fm.removeItem(at: destination)
|
||||
}
|
||||
try fm.moveItem(at: part, to: destination)
|
||||
}
|
||||
|
||||
// MARK: - URLSessionDataDelegate (全部在串行 delegate 队列执行)
|
||||
|
||||
nonisolated func urlSession(
|
||||
_ session: URLSession, dataTask: URLSessionDataTask,
|
||||
didReceive response: URLResponse,
|
||||
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
|
||||
) {
|
||||
if let http = response as? HTTPURLResponse, http.statusCode >= 400 {
|
||||
lock.lock(); responseError = DownloadError.badStatus(http.statusCode); lock.unlock()
|
||||
completionHandler(.cancel)
|
||||
} else {
|
||||
completionHandler(.allow)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func urlSession(
|
||||
_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data
|
||||
) {
|
||||
lock.lock()
|
||||
try? handle?.write(contentsOf: data)
|
||||
written += data.count
|
||||
let progress = written
|
||||
let callback = onProgress
|
||||
lock.unlock()
|
||||
callback?(progress)
|
||||
}
|
||||
|
||||
nonisolated func urlSession(
|
||||
_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?
|
||||
) {
|
||||
lock.lock()
|
||||
try? handle?.close()
|
||||
handle = nil
|
||||
let cont = continuation
|
||||
continuation = nil
|
||||
let respErr = responseError
|
||||
lock.unlock()
|
||||
|
||||
if let respErr {
|
||||
cont?.resume(throwing: respErr)
|
||||
} else if let error {
|
||||
cont?.resume(throwing: error)
|
||||
} else {
|
||||
cont?.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
59
康康/AI/ModelManifest.swift
Normal file
59
康康/AI/ModelManifest.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
/// 模型文件清单中的一项:相对模型目录的路径 + 预期字节数(用于总进度计算与下载后大小校验)。
|
||||
struct ModelFile: Equatable, Sendable {
|
||||
let path: String
|
||||
let bytes: Int
|
||||
}
|
||||
|
||||
/// 硬编码的模型文件清单与下载源。
|
||||
/// 只列加载必需的功能文件,排除 README.md / .gitattributes(省下载)。
|
||||
/// 字节数与服务器素材逐一核对一致,见
|
||||
/// docs/superpowers/specs/2026-05-29-model-download-design.md 附录 A。
|
||||
enum ModelManifest {
|
||||
/// 自建 Caddy 静态服务(用户自建 HTTPS 反代)。
|
||||
/// 备选纯 IP(需 App 端 ATS 例外): http://101.132.124.52:5244/
|
||||
static let baseURL = URL(string: "https://file.myv0.com/")!
|
||||
|
||||
static func files(for kind: ModelKind) -> [ModelFile] {
|
||||
switch kind {
|
||||
case .llm:
|
||||
return [
|
||||
ModelFile(path: "config.json", bytes: 937),
|
||||
ModelFile(path: "model.safetensors", bytes: 968_080_210),
|
||||
ModelFile(path: "model.safetensors.index.json", bytes: 49_731),
|
||||
ModelFile(path: "tokenizer.json", bytes: 11_422_654),
|
||||
ModelFile(path: "tokenizer_config.json", bytes: 9_706),
|
||||
ModelFile(path: "vocab.json", bytes: 2_776_833),
|
||||
ModelFile(path: "merges.txt", bytes: 1_671_853),
|
||||
ModelFile(path: "special_tokens_map.json", bytes: 613),
|
||||
ModelFile(path: "added_tokens.json", bytes: 707),
|
||||
]
|
||||
case .vl:
|
||||
return [
|
||||
ModelFile(path: "config.json", bytes: 1_659),
|
||||
ModelFile(path: "model.safetensors", bytes: 3_073_720_461),
|
||||
ModelFile(path: "model.safetensors.index.json", bytes: 108_307),
|
||||
ModelFile(path: "tokenizer.json", bytes: 11_421_896),
|
||||
ModelFile(path: "tokenizer_config.json", bytes: 7_256),
|
||||
ModelFile(path: "vocab.json", bytes: 2_776_833),
|
||||
ModelFile(path: "merges.txt", bytes: 1_671_853),
|
||||
ModelFile(path: "special_tokens_map.json", bytes: 613),
|
||||
ModelFile(path: "added_tokens.json", bytes: 605),
|
||||
ModelFile(path: "chat_template.json", bytes: 1_050),
|
||||
ModelFile(path: "preprocessor_config.json", bytes: 350),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
static func totalBytes(for kind: ModelKind) -> Int {
|
||||
files(for: kind).reduce(0) { $0 + $1.bytes }
|
||||
}
|
||||
|
||||
/// 单个文件的下载 URL = baseURL / <仓库名> / <相对路径>。
|
||||
static func fileURL(for kind: ModelKind, file: ModelFile) -> URL {
|
||||
baseURL
|
||||
.appendingPathComponent(kind.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(file.path)
|
||||
}
|
||||
}
|
||||
@@ -84,4 +84,55 @@ final class ModelStore: @unchecked Sendable {
|
||||
}
|
||||
try FileManager.default.copyItem(at: bundleURL, to: target)
|
||||
}
|
||||
|
||||
// MARK: - 下载 / 导入支撑
|
||||
|
||||
/// 模型目录下某个相对路径文件的本地 URL。
|
||||
nonisolated func fileURL(for kind: ModelKind, relativePath: String) -> URL {
|
||||
localURL(for: kind).appendingPathComponent(relativePath)
|
||||
}
|
||||
|
||||
/// 本地该文件当前字节数,不存在返回 0(用于断点续传偏移与跳过判断)。
|
||||
nonisolated func localBytes(for kind: ModelKind, relativePath: String) -> Int {
|
||||
let url = fileURL(for: kind, relativePath: relativePath)
|
||||
guard let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize else { return 0 }
|
||||
return size
|
||||
}
|
||||
|
||||
/// 按清单校验模型是否完整:每个文件都存在且大小等于预期。
|
||||
/// `files` 默认取 `ModelManifest`;测试可注入小清单。
|
||||
nonisolated func isComplete(for kind: ModelKind, files: [ModelFile]? = nil) -> Bool {
|
||||
let manifest = files ?? ModelManifest.files(for: kind)
|
||||
guard !manifest.isEmpty else { return false }
|
||||
for file in manifest where localBytes(for: kind, relativePath: file.path) != file.bytes {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// 旁路导入:把一个含 config.json 的模型文件夹整体拷入沙盒(现场重装兜底)。
|
||||
nonisolated func importModel(_ kind: ModelKind, from sourceFolder: URL) throws {
|
||||
let configPath = sourceFolder.appendingPathComponent(kind.sentinelFilename).path
|
||||
guard FileManager.default.fileExists(atPath: configPath) else {
|
||||
throw ModelStoreError.missingConfig
|
||||
}
|
||||
let target = localURL(for: kind)
|
||||
if FileManager.default.fileExists(atPath: target.path) {
|
||||
try FileManager.default.removeItem(at: target)
|
||||
}
|
||||
try FileManager.default.createDirectory(
|
||||
at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try FileManager.default.copyItem(at: sourceFolder, to: target)
|
||||
}
|
||||
}
|
||||
|
||||
enum ModelStoreError: Error, LocalizedError {
|
||||
case missingConfig
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingConfig:
|
||||
return "所选文件夹缺少 config.json,不是有效的模型目录"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ struct MeView: View {
|
||||
@Query private var reminders: [MetricReminder]
|
||||
@Query private var customMetrics: [CustomMonitorMetric]
|
||||
|
||||
@State private var downloadService = ModelDownloadService.shared
|
||||
|
||||
private var profile: UserProfile? { profiles.first }
|
||||
private var enabledReminderCount: Int { reminders.filter(\.enabled).count }
|
||||
|
||||
@@ -17,9 +19,7 @@ struct MeView: View {
|
||||
profileCard
|
||||
remindersCard
|
||||
customMetricsCard
|
||||
settingsCard(title: "模型管理",
|
||||
detail: "未配置",
|
||||
icon: "cpu")
|
||||
modelManagementCard
|
||||
settingsCard(title: "Face ID 启动锁",
|
||||
detail: "关闭",
|
||||
icon: "faceid")
|
||||
@@ -42,6 +42,7 @@ struct MeView: View {
|
||||
if profiles.isEmpty {
|
||||
_ = UserProfileStore.loadOrCreate(in: ctx)
|
||||
}
|
||||
downloadService.refreshStates()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,6 +162,23 @@ struct MeView: View {
|
||||
return "\(customMetrics.count) 项"
|
||||
}
|
||||
|
||||
private var modelManagementCard: some View {
|
||||
NavigationLink {
|
||||
ModelManagementView()
|
||||
} label: {
|
||||
settingsCard(title: "模型管理", detail: modelDetail, icon: "cpu")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var modelDetail: String {
|
||||
let states = downloadService.states
|
||||
if ModelKind.allCases.allSatisfy({ states[$0]?.phase == .ready }) { return "已就绪" }
|
||||
if downloadService.isAnyDownloading { return "下载中…" }
|
||||
let readyCount = ModelKind.allCases.filter { states[$0]?.phase == .ready }.count
|
||||
return readyCount == 0 ? "未下载" : "\(readyCount)/\(ModelKind.allCases.count) 就绪"
|
||||
}
|
||||
|
||||
private func settingsCard(title: String, detail: String, icon: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
|
||||
226
康康/Features/Me/ModelManagementView.swift
Normal file
226
康康/Features/Me/ModelManagementView.swift
Normal file
@@ -0,0 +1,226 @@
|
||||
import SwiftUI
|
||||
import Network
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// 「我的 · 模型管理」页:分模型卡片显示下载状态/进度,支持下载全部/暂停 + 旁路文件导入。
|
||||
/// 只观察 ModelDownloadService 的状态,不直接碰 URLSession(§3.1)。
|
||||
struct ModelManagementView: View {
|
||||
@State private var service = ModelDownloadService.shared
|
||||
@State private var isCellular = false
|
||||
@State private var showCellularConfirm = false
|
||||
@State private var showImporter = false
|
||||
@State private var importError: String?
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private let monitorQueue = DispatchQueue(label: "kk.netmonitor")
|
||||
|
||||
private var allReady: Bool {
|
||||
ModelKind.allCases.allSatisfy { service.states[$0]?.phase == .ready }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
ForEach(ModelKind.allCases, id: \.self) { kind in
|
||||
modelCard(kind)
|
||||
}
|
||||
|
||||
actionButtons
|
||||
.padding(.top, 4)
|
||||
|
||||
if let importError {
|
||||
Text(importError)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
footer
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.navigationTitle("模型管理")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
service.refreshStates()
|
||||
monitor.pathUpdateHandler = { path in
|
||||
let cellular = path.status == .satisfied && path.usesInterfaceType(.cellular)
|
||||
Task { @MainActor in isCellular = cellular }
|
||||
}
|
||||
monitor.start(queue: monitorQueue)
|
||||
}
|
||||
.onDisappear { monitor.cancel() }
|
||||
.fileImporter(isPresented: $showImporter,
|
||||
allowedContentTypes: [.folder]) { handleImport($0) }
|
||||
.alert("使用蜂窝网络下载?", isPresented: $showCellularConfirm) {
|
||||
Button("取消", role: .cancel) {}
|
||||
Button("继续下载") { service.downloadAll() }
|
||||
} message: {
|
||||
Text("模型约 \(formatBytes(totalAllBytes)),建议在 Wi-Fi 下下载。")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 模型卡片
|
||||
|
||||
private func modelCard(_ kind: ModelKind) -> some View {
|
||||
let state = service.states[kind]
|
||||
?? DownloadState(phase: .idle, receivedBytes: 0,
|
||||
totalBytes: ModelManifest.totalBytes(for: kind), bytesPerSecond: 0)
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(kind.displayName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(subtitle(kind))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
statusBadge(state.phase)
|
||||
}
|
||||
|
||||
if state.phase == .downloading {
|
||||
ProgressView(value: min(max(state.fraction, 0), 1))
|
||||
.tint(Tj.Palette.ink)
|
||||
HStack {
|
||||
Text("\(Int(state.fraction * 100))%")
|
||||
Spacer()
|
||||
Text(speedText(state))
|
||||
}
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
} else {
|
||||
HStack {
|
||||
Text(formatBytes(ModelManifest.totalBytes(for: kind)))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
if case .failed(let message) = state.phase {
|
||||
Text(message)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.tjCard()
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if case .failed = state.phase { service.download(kind) }
|
||||
}
|
||||
}
|
||||
|
||||
private func statusBadge(_ phase: DownloadPhase) -> some View {
|
||||
switch phase {
|
||||
case .idle: return TjBadge(text: "待下载", style: .neutral)
|
||||
case .downloading: return TjBadge(text: "下载中", style: .amber)
|
||||
case .verifying: return TjBadge(text: "校验中", style: .amber)
|
||||
case .ready: return TjBadge(text: "已就绪", style: .leaf)
|
||||
case .failed: return TjBadge(text: "失败 · 重试", style: .brick)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 动作按钮
|
||||
|
||||
@ViewBuilder
|
||||
private var actionButtons: some View {
|
||||
if service.isAnyDownloading {
|
||||
Button {
|
||||
for kind in ModelKind.allCases { service.cancel(kind) }
|
||||
} label: {
|
||||
Text("暂停下载").frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(TjGhostButton())
|
||||
} else if allReady {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
Text("两个模型都已就绪")
|
||||
}
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
} else {
|
||||
Button {
|
||||
if isCellular { showCellularConfirm = true } else { service.downloadAll() }
|
||||
} label: {
|
||||
Text("下载全部模型 · \(formatBytes(totalAllBytes))")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton())
|
||||
}
|
||||
|
||||
Button {
|
||||
importError = nil
|
||||
showImporter = true
|
||||
} label: {
|
||||
Text("从文件导入(离线)").frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(TjGhostButton())
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
VStack(spacing: 8) {
|
||||
TjLockChip()
|
||||
Text("100% 本地推理 · 模型仅需下载一次")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - 旁路导入
|
||||
|
||||
private func handleImport(_ result: Result<URL, Error>) {
|
||||
do {
|
||||
let folder = try result.get()
|
||||
let scoped = folder.startAccessingSecurityScopedResource()
|
||||
defer { if scoped { folder.stopAccessingSecurityScopedResource() } }
|
||||
|
||||
let name = folder.lastPathComponent
|
||||
guard let kind = ModelKind.allCases.first(where: { $0.rawValue == name }) else {
|
||||
importError = "请选择名为 Qwen3-1.7B-4bit 或 Qwen2.5-VL-3B-Instruct-4bit 的文件夹"
|
||||
return
|
||||
}
|
||||
try service.importModel(kind, from: folder)
|
||||
importError = nil
|
||||
} catch {
|
||||
importError = "导入失败:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 辅助
|
||||
|
||||
private var totalAllBytes: Int {
|
||||
ModelKind.allCases.reduce(0) { $0 + ModelManifest.totalBytes(for: $1) }
|
||||
}
|
||||
|
||||
private func subtitle(_ kind: ModelKind) -> String {
|
||||
switch kind {
|
||||
case .llm: return "文本解读 · 趋势 / 问答"
|
||||
case .vl: return "拍照识别报告 → 结构化指标"
|
||||
}
|
||||
}
|
||||
|
||||
private func formatBytes(_ bytes: Int) -> String {
|
||||
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
|
||||
}
|
||||
|
||||
private func speedText(_ state: DownloadState) -> String {
|
||||
guard state.bytesPerSecond > 0 else { return "—" }
|
||||
return formatBytes(Int(state.bytesPerSecond)) + "/s"
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
ModelManagementView()
|
||||
}
|
||||
}
|
||||
147
康康/Services/ModelDownloadService.swift
Normal file
147
康康/Services/ModelDownloadService.swift
Normal file
@@ -0,0 +1,147 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 模型下载编排:遍历 ModelManifest 逐文件串行下载,聚合进度,支持暂停/重试/旁路导入。
|
||||
/// UI 只观察 `states`,不直接碰 URLSession(§3.1 模块边界)。
|
||||
/// 核心下载/校验逻辑在 `FileDownloader`,文件路径/就绪判定在 `ModelStore`。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ModelDownloadService {
|
||||
static let shared = ModelDownloadService()
|
||||
|
||||
private(set) var states: [ModelKind: DownloadState] = [:]
|
||||
|
||||
private let store: ModelStore
|
||||
private var tasks: [ModelKind: Task<Void, Never>] = [:]
|
||||
private var lastSampleTime: [ModelKind: Date] = [:]
|
||||
private var lastSampleBytes: [ModelKind: Int] = [:]
|
||||
|
||||
init(store: ModelStore = .shared) {
|
||||
self.store = store
|
||||
refreshStates()
|
||||
}
|
||||
|
||||
/// 根据沙盒现状刷新每个模型的状态(已完整→ready,否则 idle)。
|
||||
func refreshStates() {
|
||||
for kind in ModelKind.allCases {
|
||||
let total = ModelManifest.totalBytes(for: kind)
|
||||
if store.isComplete(for: kind) {
|
||||
states[kind] = DownloadState(phase: .ready, receivedBytes: total,
|
||||
totalBytes: total, bytesPerSecond: 0)
|
||||
} else if states[kind]?.phase == .downloading {
|
||||
continue // 不打断进行中的下载
|
||||
} else {
|
||||
states[kind] = DownloadState(phase: .idle, receivedBytes: completedBytes(for: kind),
|
||||
totalBytes: total, bytesPerSecond: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isAnyDownloading: Bool {
|
||||
states.values.contains { $0.phase == .downloading }
|
||||
}
|
||||
|
||||
/// 下载某个模型。幂等:已在下载或已就绪则忽略。
|
||||
func download(_ kind: ModelKind) {
|
||||
guard tasks[kind] == nil, states[kind]?.phase != .ready else { return }
|
||||
let total = ModelManifest.totalBytes(for: kind)
|
||||
states[kind] = DownloadState(phase: .downloading, receivedBytes: completedBytes(for: kind),
|
||||
totalBytes: total, bytesPerSecond: 0)
|
||||
lastSampleTime[kind] = Date()
|
||||
lastSampleBytes[kind] = completedBytes(for: kind)
|
||||
|
||||
let task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.run(kind)
|
||||
}
|
||||
tasks[kind] = task
|
||||
}
|
||||
|
||||
func downloadAll() {
|
||||
for kind in ModelKind.allCases { download(kind) }
|
||||
}
|
||||
|
||||
/// 暂停下载。已下载的 .part 保留,下次从断点续传。
|
||||
func cancel(_ kind: ModelKind) {
|
||||
tasks[kind]?.cancel()
|
||||
tasks[kind] = nil
|
||||
let total = ModelManifest.totalBytes(for: kind)
|
||||
states[kind] = DownloadState(phase: .idle, receivedBytes: completedBytes(for: kind),
|
||||
totalBytes: total, bytesPerSecond: 0)
|
||||
}
|
||||
|
||||
/// 旁路导入:从用户选择的文件夹拷入模型(现场重装兜底)。
|
||||
func importModel(_ kind: ModelKind, from folder: URL) throws {
|
||||
try store.importModel(kind, from: folder)
|
||||
refreshStates()
|
||||
}
|
||||
|
||||
// MARK: - 内部
|
||||
|
||||
private func run(_ kind: ModelKind) async {
|
||||
let files = ModelManifest.files(for: kind)
|
||||
let downloader = FileDownloader()
|
||||
var completedBefore = 0
|
||||
|
||||
do {
|
||||
for file in files {
|
||||
if Task.isCancelled { return }
|
||||
let destination = store.fileURL(for: kind, relativePath: file.path)
|
||||
let base = completedBefore
|
||||
try await downloader.download(
|
||||
from: ModelManifest.fileURL(for: kind, file: file),
|
||||
to: destination,
|
||||
expectedBytes: file.bytes,
|
||||
onProgress: { [weak self] received in
|
||||
Task { @MainActor in
|
||||
self?.applyProgress(kind, currentTotal: base + received)
|
||||
}
|
||||
}
|
||||
)
|
||||
completedBefore += file.bytes
|
||||
}
|
||||
finish(kind, success: true, message: nil)
|
||||
} catch {
|
||||
if Task.isCancelled {
|
||||
// cancel() 已设置 idle 状态
|
||||
} else {
|
||||
finish(kind, success: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyProgress(_ kind: ModelKind, currentTotal: Int) {
|
||||
guard var state = states[kind], state.phase == .downloading else { return }
|
||||
let now = Date()
|
||||
if let lastTime = lastSampleTime[kind], let lastBytes = lastSampleBytes[kind] {
|
||||
let dt = now.timeIntervalSince(lastTime)
|
||||
if dt >= 0.5 {
|
||||
state.bytesPerSecond = Double(currentTotal - lastBytes) / dt
|
||||
lastSampleTime[kind] = now
|
||||
lastSampleBytes[kind] = currentTotal
|
||||
}
|
||||
}
|
||||
state.receivedBytes = currentTotal
|
||||
states[kind] = state
|
||||
}
|
||||
|
||||
private func finish(_ kind: ModelKind, success: Bool, message: String?) {
|
||||
tasks[kind] = nil
|
||||
let total = ModelManifest.totalBytes(for: kind)
|
||||
if success {
|
||||
states[kind] = DownloadState(phase: .ready, receivedBytes: total,
|
||||
totalBytes: total, bytesPerSecond: 0)
|
||||
} else {
|
||||
states[kind] = DownloadState(phase: .failed(message ?? "下载失败"),
|
||||
receivedBytes: completedBytes(for: kind),
|
||||
totalBytes: total, bytesPerSecond: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 已完整下载的文件字节之和(用于续传时的起始进度)。
|
||||
private func completedBytes(for kind: ModelKind) -> Int {
|
||||
ModelManifest.files(for: kind).reduce(0) { sum, file in
|
||||
store.localBytes(for: kind, relativePath: file.path) == file.bytes ? sum + file.bytes : sum
|
||||
}
|
||||
}
|
||||
}
|
||||
22
康康/Services/ModelDownloadTypes.swift
Normal file
22
康康/Services/ModelDownloadTypes.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
|
||||
/// 单个模型的下载阶段。
|
||||
enum DownloadPhase: Equatable, Sendable {
|
||||
case idle // 待下载
|
||||
case downloading // 下载中
|
||||
case verifying // 校验中
|
||||
case ready // 已就绪
|
||||
case failed(String) // 失败 · 可重试
|
||||
}
|
||||
|
||||
/// 单个模型的下载状态快照,供 UI 观察。
|
||||
struct DownloadState: Equatable, Sendable {
|
||||
var phase: DownloadPhase
|
||||
var receivedBytes: Int
|
||||
var totalBytes: Int
|
||||
var bytesPerSecond: Double
|
||||
|
||||
var fraction: Double {
|
||||
totalBytes > 0 ? Double(receivedBytes) / Double(totalBytes) : 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user