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:
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user