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:
link2026
2026-05-29 23:19:51 +08:00
parent 6ccbe4ac55
commit 062c027c77
10 changed files with 959 additions and 3 deletions

View 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()
}
}
}

View 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)
}
}

View File

@@ -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,不是有效的模型目录"
}
}
}

View File

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

View 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()
}
}

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

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