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,不是有效的模型目录"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user