diff --git a/康康/AI/AIRuntime.swift b/康康/AI/AIRuntime.swift index b4f7bba..e9d2153 100644 --- a/康康/AI/AIRuntime.swift +++ b/康康/AI/AIRuntime.swift @@ -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 } diff --git a/康康/AI/ModelManifest.swift b/康康/AI/ModelManifest.swift index 18b1e99..951d07e 100644 --- a/康康/AI/ModelManifest.swift +++ b/康康/AI/ModelManifest.swift @@ -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), + ] } } diff --git a/康康/AI/ModelStore.swift b/康康/AI/ModelStore.swift index dd31e67..c325ae5 100644 --- a/康康/AI/ModelStore.swift +++ b/康康/AI/ModelStore.swift @@ -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)" } } diff --git a/康康/Features/Me/ModelManagementView.swift b/康康/Features/Me/ModelManagementView.swift index 82e1e3f..67f73c8 100644 --- a/康康/Features/Me/ModelManagementView.swift +++ b/康康/Features/Me/ModelManagementView.swift @@ -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 端侧加速") } } diff --git a/康康Tests/ModelManifestTests.swift b/康康Tests/ModelManifestTests.swift index acbab04..eaecdaf 100644 --- a/康康Tests/ModelManifestTests.swift +++ b/康康Tests/ModelManifestTests.swift @@ -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)