feat(AI): MNN 模型纳入下载体系 ModelKind.mnnLLM(Phase 4)

文本 MNN 模型用 taobao-mnn/Qwen3.5-2B-MNN 官方预转换格式(~1.10GiB),
不再从头转换(避开多模态转文本风险,官方转更可靠)。

- ModelStore.ModelKind 新增 .mnnLLM = "Qwen3.5-2B-MNN"
- ModelManifest:.mnnLLM 文件清单(config.json/llm_config.json/llm.mnn/
  llm.mnn.weight 1.1GB/tokenizer.txt/visual.mnn,HF API 实测字节)
- AIRuntime:mnnModelFolder + 就绪判定改走 ModelStore.isComplete(.mnnLLM)
- ModelManagementView:subtitle 加 .mnnLLM 文案(仅此一处,未动其它 WIP)
- ModelManifestTests:+4 条 mnnLLM 断言(文件数/总字节/必需文件/URL)

模拟器 ModelManifestTests TEST SUCCEEDED。下载经现有链路,需上传到
file.myv0.com/Qwen3.5-2B-MNN/(CDN 清单随附)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
link2026
2026-06-08 19:38:16 +08:00
parent 9da3fbc87e
commit 39b1521f00
5 changed files with 51 additions and 13 deletions

View File

@@ -38,7 +38,7 @@ actor AIRuntime {
private(set) var mnnStatus: Status = .notReady
/// MNN (/ Models/Qwen3.5-2B-MNN)
nonisolated static var mnnModelFolder: URL {
ModelStore.shared.rootURL.appendingPathComponent("Qwen3.5-2B-MNN", isDirectory: true)
ModelStore.shared.localURL(for: .mnnLLM)
}
// MARK: - (§3.1 OOM )
@@ -92,8 +92,7 @@ actor AIRuntime {
func prepare() async throws {
// MNN MNN;( MLX, MNN )退 MLX,
// App (Phase 5)
let mnnReady = FileManager.default.fileExists(
atPath: Self.mnnModelFolder.appendingPathComponent("config.json").path)
let mnnReady = ModelStore.shared.isComplete(for: .mnnLLM)
if InferenceEngine.current == .mnn, mnnReady {
try await prepareMNN()
return
@@ -147,8 +146,7 @@ actor AIRuntime {
if mnnStatus == .ready { return }
let folder = Self.mnnModelFolder
let config = folder.appendingPathComponent("config.json").path
guard FileManager.default.fileExists(atPath: config) else {
guard ModelStore.shared.isComplete(for: .mnnLLM) else {
mnnStatus = .error("MNN 模型未就绪")
throw AIRuntimeError.notReady
}

View File

@@ -59,6 +59,19 @@ nonisolated enum ModelManifest {
ModelFile(path: "preprocessor_config.json", bytes: 782),
ModelFile(path: "video_preprocessor_config.json", bytes: 817),
]
case .mnnLLM:
// taobao-mnn/Qwen3.5-2B-MNN MNN (HF API ,2026-06)
// :config.json(MNN llm )+ llm_config.json()+ llm.mnn()
// + llm.mnn.weight( ~1.1GB)+ tokenizer.txt + visual.mnn(, mllm)
// README/.gitattributes dump(llm.mnn.json / export_args.json)
return [
ModelFile(path: "config.json", bytes: 652),
ModelFile(path: "llm_config.json", bytes: 8_692),
ModelFile(path: "llm.mnn", bytes: 2_148_136),
ModelFile(path: "llm.mnn.weight", bytes: 1_176_647_702),
ModelFile(path: "tokenizer.txt", bytes: 6_465_727),
ModelFile(path: "visual.mnn", bytes: 488_096),
]
}
}

View File

@@ -1,15 +1,19 @@
import Foundation
nonisolated enum ModelKind: String, CaseIterable {
/// HuggingFace mlx-community , Models/
/// LLM Qwen3.5-2B(, mlx-swift-lm qwen3_5 Qwen35Model )
case llm = "Qwen3.5-2B-4bit"
case vl = "Qwen3-VL-4B-Instruct-4bit"
/// Models/ / CDN
/// - llm:MLX(GPU),Qwen3.5-2B(, qwen3_5 )
/// - vl :MLX(GPU),Qwen3-VL-4B
/// - mnnLLM:MNN(CPU/SME2,),Qwen3.5-2B MNN (taobao-mnn)
case llm = "Qwen3.5-2B-4bit"
case vl = "Qwen3-VL-4B-Instruct-4bit"
case mnnLLM = "Qwen3.5-2B-MNN"
var displayName: String {
switch self {
case .llm: return "Qwen3.5-2B"
case .vl: return "Qwen3-VL-4B"
case .llm: return "Qwen3.5-2B (MLX)"
case .vl: return "Qwen3-VL-4B"
case .mnnLLM: return "Qwen3.5-2B (MNN/SME2)"
}
}

View File

@@ -218,8 +218,9 @@ struct ModelManagementView: View {
private func subtitle(_ kind: ModelKind) -> String {
switch kind {
case .llm: return String(appLoc: "文本解读 · 趋势 / 问答")
case .vl: return String(appLoc: "拍照识别报告 → 结构化指标")
case .llm: return String(appLoc: "文本解读 · 趋势 / 问答(MLX 兜底)")
case .vl: return String(appLoc: "拍照识别报告 → 结构化指标")
case .mnnLLM: return String(appLoc: "文本解读 · MNN + SME2 端侧加速")
}
}

View File

@@ -20,6 +20,28 @@ struct ModelManifestTests {
#expect(ModelManifest.totalBytes(for: .vl) == 3_109_729_929)
}
@Test func mnnHasSixFunctionalFiles() {
#expect(ModelManifest.files(for: .mnnLLM).count == 6)
}
@Test func mnnTotalBytesMatchesManifest() {
#expect(ModelManifest.totalBytes(for: .mnnLLM) == 1_185_759_005)
}
@Test func mnnHasEssentialRuntimeFiles() {
let names = ModelManifest.files(for: .mnnLLM).map(\.path)
#expect(names.contains("config.json"))
#expect(names.contains("llm.mnn"))
#expect(names.contains("llm.mnn.weight"))
#expect(names.contains("tokenizer.txt"))
}
@Test func mnnFileURLUsesRepoPath() {
let file = ModelFile(path: "config.json", bytes: 652)
let url = ModelManifest.fileURL(for: .mnnLLM, file: file)
#expect(url.absoluteString == "https://file.myv0.com/Qwen3.5-2B-MNN/config.json")
}
@Test func excludesReadmeAndGitattributes() {
for kind in [ModelKind.llm, .vl] {
let names = ModelManifest.files(for: kind).map(\.path)