现场机 iPhone 17(A19/SME2)内存与加速均可承载 4B,质量优于 2B。 - ModelKind.mnnLLM rawValue → "Qwen3.5-4B-MNN",displayName → Qwen3.5-4B (MNN/SME2) - ModelManifest:7 个运行时文件(llm.mnn.weight ~2.45GB + 拆分的 visual.mnn.weight 188MB),总计 2,836,770,850 bytes(~2.64GiB) - ModelManifestTests:文件数 7 / 总字节 / URL 更新到 Qwen3.5-4B-MNN - CLAUDE.md §2:MNN 主模型记为 Qwen3.5-4B,MLX 兜底仍 2B 模拟器 ModelManifestTests TEST SUCCEEDED。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
144 lines
5.7 KiB
Swift
144 lines
5.7 KiB
Swift
import Foundation
|
|
|
|
nonisolated enum ModelKind: String, CaseIterable {
|
|
/// 也是沙盒 Models/ 下的子目录名 / CDN 路径段。
|
|
/// - llm:MLX(GPU)文本兜底,Qwen3.5-2B(多模态权重,走 qwen3_5 文本路径)。
|
|
/// - vl :MLX(GPU)拍照识别,Qwen3-VL-4B。
|
|
/// - mnnLLM:MNN(CPU/SME2,挑战赛考核路径)文本,Qwen3.5-4B 预转换 MNN 格式(taobao-mnn)。
|
|
case llm = "Qwen3.5-2B-4bit"
|
|
case vl = "Qwen3-VL-4B-Instruct-4bit"
|
|
case mnnLLM = "Qwen3.5-4B-MNN"
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .llm: return "Qwen3.5-2B (MLX)"
|
|
case .vl: return "Qwen3-VL-4B"
|
|
case .mnnLLM: return "Qwen3.5-4B (MNN/SME2)"
|
|
}
|
|
}
|
|
|
|
/// HuggingFace 仓库 ID(org/name),用于下载
|
|
var huggingFaceRepo: String { "mlx-community/\(rawValue)" }
|
|
|
|
/// 用于判定该模型是否已就绪的最小标志文件
|
|
var sentinelFilename: String { "config.json" }
|
|
}
|
|
|
|
/// `@unchecked Sendable`:rootURL 是 let,方法只读 filesystem(线程安全),
|
|
/// 可被任意 actor / Task 跨边界访问。
|
|
/// 实例方法显式 `nonisolated`:项目开了 `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`,
|
|
/// 默认会把方法推到 MainActor,导致从 `AIRuntime` 等 actor 调用报错。
|
|
final class ModelStore: @unchecked Sendable {
|
|
nonisolated static let shared: ModelStore = {
|
|
do {
|
|
let appSupport = try FileManager.default.url(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask,
|
|
appropriateFor: nil,
|
|
create: true
|
|
)
|
|
let root = appSupport.appendingPathComponent("Models", isDirectory: true)
|
|
return try ModelStore(rootURL: root)
|
|
} catch {
|
|
fatalError("ModelStore.shared init failed: \(error)")
|
|
}
|
|
}()
|
|
|
|
let rootURL: URL
|
|
|
|
init(rootURL: URL) throws {
|
|
self.rootURL = rootURL
|
|
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
|
|
}
|
|
|
|
nonisolated func localURL(for kind: ModelKind) -> URL {
|
|
rootURL.appendingPathComponent(kind.rawValue, isDirectory: true)
|
|
}
|
|
|
|
nonisolated func isReady(_ kind: ModelKind) -> Bool {
|
|
let sentinel = localURL(for: kind).appendingPathComponent(kind.sentinelFilename)
|
|
return FileManager.default.fileExists(atPath: sentinel.path)
|
|
}
|
|
|
|
nonisolated func totalBytes(for kind: ModelKind) -> Int {
|
|
let folder = localURL(for: kind)
|
|
guard let enumerator = FileManager.default.enumerator(
|
|
at: folder,
|
|
includingPropertiesForKeys: [.fileSizeKey]
|
|
) else { return 0 }
|
|
var sum = 0
|
|
for case let url as URL in enumerator {
|
|
if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
|
sum += size
|
|
}
|
|
}
|
|
return sum
|
|
}
|
|
|
|
/// Demo 现场旁路:从 Bundle 拷贝预装模型(W6 才真正使用,本周占位)
|
|
nonisolated func seedFromBundle(_ kind: ModelKind) throws {
|
|
guard let bundleURL = Bundle.main.url(forResource: kind.rawValue, withExtension: nil) else {
|
|
#if DEBUG
|
|
assertionFailure("Bundle 缺少 \(kind.rawValue),检查资源是否加入 target")
|
|
#endif
|
|
return
|
|
}
|
|
let target = localURL(for: kind)
|
|
if FileManager.default.fileExists(atPath: target.path) {
|
|
try FileManager.default.removeItem(at: target)
|
|
}
|
|
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 String(appLoc: "所选文件夹缺少 config.json,不是有效的模型目录")
|
|
}
|
|
}
|
|
}
|