Files
kangkang/康康/AI/ModelStore.swift
link2026 cbacd9461a feat(AI): MNN 文本模型升到 Qwen3.5-4B(taobao-mnn 预转换)
现场机 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>
2026-06-08 20:28:14 +08:00

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,不是有效的模型目录")
}
}
}