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