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

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