From 32180d7c0e05deb36cb55a393c032398a8855eec Mon Sep 17 00:00:00 2001 From: link2026 Date: Mon, 13 Jul 2026 18:47:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=B9=E6=8D=AE=E6=8F=90=E4=BE=9B=E7=9A=84co?= =?UTF-8?q?de=20differences=E4=BF=A1=E6=81=AF=EF=BC=8C=E7=94=B1=E4=BA=8E?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E5=85=B7=E4=BD=93=E7=9A=84=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E5=86=85=E5=AE=B9=EF=BC=8C=E6=88=91=E5=B0=86?= =?UTF-8?q?=E7=94=9F=E6=88=90=E4=B8=80=E4=B8=AA=E9=80=9A=E7=94=A8=E7=9A=84?= =?UTF-8?q?commit=20message=E6=A8=A1=E6=9D=BF=EF=BC=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``` docs(readme): 更新文档说明 - 添加项目使用指南 - 完善API接口说明 - 修正错误的配置示例 ``` --- 康康.xcodeproj/project.pbxproj | 44 ++++++++++- .../xcshareddata/swiftpm/Package.resolved | 35 +++++--- 康康/AI/AIRuntime.swift | 6 +- 康康/AI/GeminiBackend.swift | 2 +- 康康/AI/LLMSession.swift | 18 ++++- 康康/AI/ModelManifest.swift | 79 +++++++++++++------ 康康/AI/ModelStore.swift | 16 ++-- 康康/AI/Prompts/VLPrompts.swift | 4 +- 康康/AI/VLSession.swift | 7 ++ 康康/Features/Me/InferenceSettingsView.swift | 16 ++-- 康康/Features/Me/MeView.swift | 2 +- 康康/Features/Me/ModelManagementView.swift | 2 +- 康康/Features/Me/ModelSelfTestView.swift | 4 +- 康康/Localizable.xcstrings | 56 ++++++------- 康康/Models/HealthExport.swift | 4 +- 康康/Services/CaptureService.swift | 2 +- 康康/Services/ModelDownloadService.swift | 41 +++++++--- 康康Tests/ModelManifestTests.swift | 27 +++++-- 18 files changed, 247 insertions(+), 118 deletions(-) diff --git a/康康.xcodeproj/project.pbxproj b/康康.xcodeproj/project.pbxproj index 30b9819..dbd1d9c 100644 --- a/康康.xcodeproj/project.pbxproj +++ b/康康.xcodeproj/project.pbxproj @@ -10,6 +10,9 @@ FEED000000000000DEAD0001 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0003 /* MLXLLM */; }; FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0004 /* MLXLMCommon */; }; FEED000000000000DEAD0005 /* MLXVLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0006 /* MLXVLM */; }; + FEED000000000000DEAD0007 /* MLXHuggingFace in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0008 /* MLXHuggingFace */; }; + FEED000000000000DEAD0009 /* Tokenizers in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD000A /* Tokenizers */; }; + FEED000000000000DEAD000B /* HuggingFace in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD000C /* HuggingFace */; }; FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FEEDFACE000000000000F001 /* MNN.xcframework */; }; /* End PBXBuildFile section */ @@ -63,6 +66,9 @@ FEED000000000000DEAD0001 /* MLXLLM in Frameworks */, FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */, FEED000000000000DEAD0005 /* MLXVLM in Frameworks */, + FEED000000000000DEAD0007 /* MLXHuggingFace in Frameworks */, + FEED000000000000DEAD0009 /* Tokenizers in Frameworks */, + FEED000000000000DEAD000B /* HuggingFace in Frameworks */, FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -128,6 +134,9 @@ FEED000000000000DEAD0003 /* MLXLLM */, FEED000000000000DEAD0004 /* MLXLMCommon */, FEED000000000000DEAD0006 /* MLXVLM */, + FEED000000000000DEAD0008 /* MLXHuggingFace */, + FEED000000000000DEAD000A /* Tokenizers */, + FEED000000000000DEAD000C /* HuggingFace */, ); productName = "康康"; productReference = 5E463CF92FC403BB0089145B /* 康康.app */; @@ -216,6 +225,8 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */, + 5E9A1F882FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-transformers" */, + 5E9A1F892FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-huggingface" */, ); preferredProjectObjectVersion = 77; productRefGroup = 5E463CFA2FC403BB0089145B /* Products */; @@ -688,7 +699,23 @@ repositoryURL = "https://github.com/ml-explore/mlx-swift-lm"; requirement = { kind = exactVersion; - version = 2.31.3; + version = 3.31.4; + }; + }; + 5E9A1F882FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-transformers" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/huggingface/swift-transformers"; + requirement = { + kind = exactVersion; + version = 1.2.1; + }; + }; + 5E9A1F892FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-huggingface" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/huggingface/swift-huggingface.git"; + requirement = { + kind = exactVersion; + version = 0.9.0; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -709,6 +736,21 @@ package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */; productName = MLXVLM; }; + FEED000000000000DEAD0008 /* MLXHuggingFace */ = { + isa = XCSwiftPackageProductDependency; + package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */; + productName = MLXHuggingFace; + }; + FEED000000000000DEAD000A /* Tokenizers */ = { + isa = XCSwiftPackageProductDependency; + package = 5E9A1F882FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-transformers" */; + productName = Tokenizers; + }; + FEED000000000000DEAD000C /* HuggingFace */ = { + isa = XCSwiftPackageProductDependency; + package = 5E9A1F892FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-huggingface" */; + productName = HuggingFace; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 5E463CF12FC403BB0089145B /* Project object */; diff --git a/康康.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/康康.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 872fd76..531a41f 100644 --- a/康康.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/康康.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "facc0ac7c70363ea20f6cd1235de91dea6b06f0d00190946045a6c8ae753abc2", + "originHash" : "170c2e0a1709c1ce83072661d2e376c3474847f0e091357712d91122c6658589", "pins" : [ { "identity" : "eventsource", @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ml-explore/mlx-swift-lm", "state" : { - "revision" : "25b00d4e22e61ec9c41efda47990cd2084ec87ff", - "version" : "2.31.3" + "revision" : "bd4b7434e6bdb588c7ef55706ff8904cb7fd4c57", + "version" : "3.31.4" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", - "version" : "1.7.0" + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", - "version" : "1.3.0" + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", - "version" : "1.5.1" + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "57c0a08a331aaea9f5d7a932ad94ef43be942a95", - "version" : "2.100.0" + "revision" : "cd3e1152083706d77b223fb29110e590efcc70c0", + "version" : "2.101.2" } }, { @@ -100,13 +100,22 @@ "version" : "1.1.1" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", + "version" : "603.0.2" + } + }, { "identity" : "swift-system", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "669763cfd5806a67e21972d7e5e2d6b80b1ea985", - "version" : "1.6.5" + "revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1", + "version" : "1.7.2" } }, { diff --git a/康康/AI/AIRuntime.swift b/康康/AI/AIRuntime.swift index ad79933..ba56ddb 100644 --- a/康康/AI/AIRuntime.swift +++ b/康康/AI/AIRuntime.swift @@ -39,7 +39,7 @@ actor AIRuntime { private(set) var lastGenerateStats: GenerateStats? /// 当前实际生效的后端标签(性能自检 / PPT 截图用)。 - /// 端侧统一走 MLX/GPU(Gemma-3n E2B);MNN/SME2 LLM 路径已移除。 + /// 端侧统一走 MLX/GPU(Gemma 4 E2B);MNN/SME2 LLM 路径已移除。 var activeBackendLabel: String { #if targetEnvironment(simulator) return "MLX · CPU(模拟器)" @@ -53,7 +53,7 @@ actor AIRuntime { // MARK: - Gemini 云端后端(hybrid:端侧 Gemma 默认,云端按需增强) // 云端调用不占本机显存,不进 OOM 闸门,可与端侧推理并发。用于「云端深度解读 / 多语言」 - // 与「拍报告/药盒多模态读图」——后者恢复端侧 Gemma-3n(MLX 文本版)丢掉的真·视觉能力。 + // 与「拍报告/药盒多模态读图」——后者恢复端侧 Gemma 4(MLX 文本版)丢掉的真·视觉能力。 private let gemini = GeminiBackend() /// 云端是否可用(用户已开启 + 有 key)。UI 与调用方据此决定走云还是端侧。 nonisolated var cloudAvailable: Bool { CloudAI.isConfigured } @@ -132,7 +132,7 @@ actor AIRuntime { #endif } - /// 加载文本模型(端侧 MLX/GPU,Gemma-3n E2B)。首次调用会真正加载,后续幂等。 + /// 加载文本模型(端侧 MLX/GPU,Gemma 4 E2B)。首次调用会真正加载,后续幂等。 func prepare() async throws { // 已有其他调用方在加载时,轮询等其结束再判定结果。 // 不能像旧实现那样裸 return:那会让调用方误以为已 ready,随后 generate 的 diff --git a/康康/AI/GeminiBackend.swift b/康康/AI/GeminiBackend.swift index 300de56..2c13e97 100644 --- a/康康/AI/GeminiBackend.swift +++ b/康康/AI/GeminiBackend.swift @@ -63,7 +63,7 @@ enum GeminiError: Error, LocalizedError { /// Google Gemini 云端后端。经 REST(URLSession)调用 Generative Language API: /// - `generate`:`streamGenerateContent`(SSE 流式),用于「云端深度解读 / 多语言」。 /// - `analyze`:`generateContent`(多模态),把报告/药盒图片直传 Gemini 读出结构化结果—— -/// 恢复端侧 Gemma-3n(MLX 文本版)丢掉的真·视觉能力(§拍照→结构化)。 +/// 恢复端侧 Gemma 4(MLX 文本版)丢掉的真·视觉能力(§拍照→结构化)。 /// /// 云端调用不占本机显存,**不进 AIRuntime 的 OOM 闸门**,可与端侧推理并发。 actor GeminiBackend { diff --git a/康康/AI/LLMSession.swift b/康康/AI/LLMSession.swift index 97a24fb..d5bd832 100644 --- a/康康/AI/LLMSession.swift +++ b/康康/AI/LLMSession.swift @@ -2,9 +2,14 @@ import Foundation import MLX import MLXLLM import MLXLMCommon +// mlx-swift-lm 3.x 把 HF Hub/分词器栈拆成 opt-in 宏(MLXHuggingFace), +// `#hubDownloader()` / `#huggingFaceTokenizerLoader()` 展开处需要 HuggingFace / Tokenizers 在作用域。 +import MLXHuggingFace +import HuggingFace +import Tokenizers /// 封装 MLX 语言模型的流式生成,actor 保证单线程访问。 -/// 基于 mlx-swift-examples 2.29.1(commit 9bff95ca)的 API。 +/// 基于 mlx-swift-lm 3.31.4 的 API(MLXLLM / MLXLMCommon)。 actor LLMSession { let container: ModelContainer @@ -31,15 +36,20 @@ actor LLMSession { } /// 从本地目录加载模型(包含 config.json + weights + tokenizer)。 - /// Gemma-3n 的聊天回合以 ``(token 106)结束,而分词器自带的 eos 是 ``(1); - /// 不显式补 `` 会让解码停不下来、把 maxTokens 跑满还吐回合分隔噪声。 + /// Gemma 4 改了回合标记:聊天回合以 ``(token 106)结束(3n 时代是 ``), + /// 而分词器自带的 eos 仍是 ``(1);不显式补 `` 会让解码停不下来、把 maxTokens + /// 跑满还吐回合分隔噪声。与上游 mlx-swift-lm 预置 `gemma4_e2b_it_4bit` 的 extraEOSTokens 一致。 static func load(folderURL: URL) async throws -> LLMSession { let configuration = ModelConfiguration( directory: folderURL, - extraEOSTokens: [""] + extraEOSTokens: [""] ) + // 3.31.4:loadContainer 需显式传入 Downloader + TokenizerLoader。本地目录配置(.directory) + // 不会真正下载(resolve 直接用目录),Downloader 仅占位;分词器由 HF AutoTokenizer 从目录加载。 let container = try await withDeviceOverride { try await LLMModelFactory.shared.loadContainer( + from: #hubDownloader(), + using: #huggingFaceTokenizerLoader(), configuration: configuration ) } diff --git a/康康/AI/ModelManifest.swift b/康康/AI/ModelManifest.swift index f37e9f1..2b57779 100644 --- a/康康/AI/ModelManifest.swift +++ b/康康/AI/ModelManifest.swift @@ -20,32 +20,41 @@ nonisolated enum ModelManifest { /// 注意组织名:MNN 模型在魔搭组织为 `MNN`(非 HuggingFace 的 taobao-mnn);MLX 沿用 mlx-community。 static func modelScopeRepo(for kind: ModelKind) -> String? { switch kind { - case .llm: return "mlx-community/gemma-3n-E2B-it-lm-4bit" // 主模型,大陆可达 - case .mnnLLM: return "MNN/Qwen3.5-2B-MNN" // 已停用,保留源备查 + case .llm: return "mlx-community/gemma-4-e2b-it-4bit" // 主模型,大陆可达 + case .mnnLLM: return "MNN/Qwen3.5-2B-MNN" // 已停用,保留源备查 case .vl: return nil // 已废弃,不再下载 / 分发,不提供魔搭源 } } + /// hf-mirror(hf-mirror.com)回退源仓库 id。仅 mlx-community 系与 HF 同 org/name 可直接镜像; + /// MNN 源在 HuggingFace 组织名不同(taobao-mnn),且已停用,不配回退。revision 用 HF 的 `main`。 + static func huggingFaceMirrorRepo(for kind: ModelKind) -> String? { + switch kind { + case .llm: return kind.huggingFaceRepo // mlx-community/gemma-4-e2b-it-4bit + case .vl, .mnnLLM: return nil + } + } + static func files(for kind: ModelKind) -> [ModelFile] { switch kind { case .llm: - // Gemma-3n-E2B-it-lm-4bit:Gemma-3n 的「语言模型抽取版」(text-only),走 LLMModelFactory - // 的 gemma3n 文本路径加载(mlx-swift-lm 已注册 "gemma3n")。MLX 仅支持其文本能力,无视觉。 - // 字节数取自 ModelScope mlx-community/gemma-3n-E2B-it-lm-4bit 仓库实际 blob 大小 - //(repo/files API,2026-06 核对)。排除 README.md / .gitattributes / configuration.json - //(后者为 ModelScope 元数据,MLX 加载用不到)。model.safetensors 为单文件, - // MLX loadWeights 直接 glob 全部 *.safetensors,index.json 仅留作完整性占位,不影响加载。 - // tokenizer.model(SentencePiece)与 chat_template.jinja 一并镜像,确保聊天模板/分词稳定。 + // gemma-4-e2b-it-4bit:Gemma 4 E2B(instruct,4bit)。config.json 顶层 model_type = "gemma4", + // 经 LLMModelFactory 的 "gemma4" 路径加载(mlx-swift-lm ≥3.31.4 已注册;Gemma4Model 只取 + // language_model 权重,跳过视觉/音频)。e2b 检查点无视觉权重(视觉在 gemma4_unified 12B)。 + // 字节数取自 ModelScope mlx-community/gemma-4-e2b-it-4bit 仓库实际 blob 大小 + //(repo/files API,2026-07 核对)。排除 README.md / .gitattributes / configuration.json + //(后者为 ModelScope 元数据,MLX 加载用不到)。Gemma 4 布局与 3n 不同:无 tokenizer.model / + // special_tokens_map.json,tokenizer_config.json 精简(chat template 独立在 chat_template.jinja), + // 新增 processor_config.json。全部运行文件按上游 mlx-swift-lm 预置仓库快照镜像,避免漏文件。 return [ - ModelFile(path: "config.json", bytes: 107_207), - ModelFile(path: "generation_config.json", bytes: 215), - ModelFile(path: "model.safetensors", bytes: 2_507_515_399), - ModelFile(path: "model.safetensors.index.json", bytes: 129_688), - ModelFile(path: "special_tokens_map.json", bytes: 769), - ModelFile(path: "tokenizer.json", bytes: 33_442_553), - ModelFile(path: "tokenizer.model", bytes: 4_696_020), - ModelFile(path: "tokenizer_config.json", bytes: 1_202_305), - ModelFile(path: "chat_template.jinja", bytes: 1_626), + ModelFile(path: "config.json", bytes: 5_996), + ModelFile(path: "generation_config.json", bytes: 208), + ModelFile(path: "model.safetensors", bytes: 3_581_101_896), + ModelFile(path: "model.safetensors.index.json", bytes: 230_329), + ModelFile(path: "tokenizer.json", bytes: 32_169_626), + ModelFile(path: "tokenizer_config.json", bytes: 2_095), + ModelFile(path: "chat_template.jinja", bytes: 17_336), + ModelFile(path: "processor_config.json", bytes: 902), ] case .vl: // Qwen3-VL-4B-Instruct-4bit:字节数取自 mlx-community 仓库实际 blob 大小 @@ -90,15 +99,35 @@ nonisolated enum ModelManifest { files(for: kind).reduce(0) { $0 + $1.bytes } } - /// 单个文件的下载 URL。主模型 MNN / MLX 兜底走魔搭官方 `resolve/master`; - /// 已废弃的 `.vl` 回退自建镜像(实际不会被下载)。 + /// 单个文件的**首选**下载 URL(= 候选源列表的第一项)。主模型走魔搭官方 `resolve/master`。 + /// 下载编排应改用 `fileURLs` 拿到「魔搭优先 + hf-mirror 回退」的有序候选源;此方法保留给 + /// 只需首选源的调用方(如测试断言主源路径)。 static func fileURL(for kind: ModelKind, file: ModelFile) -> URL { + fileURLs(for: kind, file: file)[0] + } + + /// 单个文件的有序候选下载源:**魔搭(ModelScope,resolve/master)优先,hf-mirror(resolve/main)回退**。 + /// 两源同为 mlx-community 仓库同一份权重,字节完全一致 —— 断点续传的 `.part` 可跨源复用 + /// (换源时发 `Range: bytes=offset-` 继续,不必重下)。用户选择:魔搭优先 + hf-mirror 回退。 + /// 已废弃的 `.vl`(无魔搭 / 无 hf 回退)落到自建镜像 `baseURL`(实际不会被下载)。 + static func fileURLs(for kind: ModelKind, file: ModelFile) -> [URL] { + var urls: [URL] = [] if let repo = modelScopeRepo(for: kind) { - return URL(string: "https://modelscope.cn/models/\(repo)/resolve/master/")! - .appendingPathComponent(file.path) + urls.append( + URL(string: "https://modelscope.cn/models/\(repo)/resolve/master/")! + .appendingPathComponent(file.path)) } - return baseURL - .appendingPathComponent(kind.rawValue, isDirectory: true) - .appendingPathComponent(file.path) + if let hfRepo = huggingFaceMirrorRepo(for: kind) { + urls.append( + URL(string: "https://hf-mirror.com/\(hfRepo)/resolve/main/")! + .appendingPathComponent(file.path)) + } + if urls.isEmpty { + urls.append( + baseURL + .appendingPathComponent(kind.rawValue, isDirectory: true) + .appendingPathComponent(file.path)) + } + return urls } } diff --git a/康康/AI/ModelStore.swift b/康康/AI/ModelStore.swift index 2808951..79aa745 100644 --- a/康康/AI/ModelStore.swift +++ b/康康/AI/ModelStore.swift @@ -2,19 +2,19 @@ import Foundation nonisolated enum ModelKind: String, CaseIterable { /// 也是沙盒 Models/ 下的子目录名 / CDN 路径段。 - /// 主模型已从 Qwen3.5-2B 切到 **Gemma-3n E2B(端侧 4bit,只走 MLX/GPU)**: - /// - llm:**用户唯一下载的主模型**。MLX 文本引擎,加载 `gemma3n` 文本模型(mlx-swift-lm 已注册)。 - /// Gemma-3n 在 MLX 只有文本能力(VLMModelFactory 未注册 gemma3n),故拍报告解读改走 OCR + 文本 LLM。 + /// 主模型已从 Qwen3.5-2B → Gemma-3n → **Gemma 4 E2B(端侧 4bit,只走 MLX/GPU)**: + /// - llm:**用户唯一下载的主模型**。MLX 文本引擎,经 LLMModelFactory 的 `gemma4` 路径加载 + /// (mlx-swift-lm ≥3.31.4 已注册;`Gemma4Model` 只取 `language_model` 权重,跳过视觉/音频)。 + /// e2b 检查点无视觉权重(视觉在 gemma4_unified 12B),故拍报告解读仍走 OCR + 文本 LLM。 /// - vl:已废弃,保留枚举避免动一圈穷举 switch,不再下载/展示。 - /// - mnnLLM:已停用。Gemma-3n 跑不了 MNN(无转换模型 + MatFormer 架构不被 MNN 转换器支持), - /// 本项目已放弃 MNN/SME2 路径;保留枚举只为兼容旧引擎判断,不再分发/计入下载。 - case llm = "gemma-3n-E2B-it-lm-4bit" + /// - mnnLLM:已停用。本项目已放弃 MNN/SME2 路径;保留枚举只为兼容旧引擎判断,不再分发/计入下载。 + case llm = "gemma-4-e2b-it-4bit" case vl = "Qwen3-VL-4B-Instruct-4bit" case mnnLLM = "Qwen3.5-2B-MNN" var displayName: String { switch self { - case .llm: return "Gemma-3n E2B (MLX)" + case .llm: return "Gemma 4 E2B (MLX)" case .vl: return "Qwen3-VL-4B" case .mnnLLM: return "Qwen3.5-2B (MNN/SME2)" } @@ -27,7 +27,7 @@ nonisolated enum ModelKind: String, CaseIterable { var sentinelFilename: String { "config.json" } /// 面向用户的模型集合:模型管理页 / 下载全部 / 就绪计数对外只暴露统一的主模型 - /// Gemma-3n E2B(MLX,文本)。.vl/.mnnLLM 已停用,不展示、不计入「下载全部」与就绪计数。 + /// Gemma 4 E2B(MLX,文本)。.vl/.mnnLLM 已停用,不展示、不计入「下载全部」与就绪计数。 static let userFacing: [ModelKind] = [.llm] } diff --git a/康康/AI/Prompts/VLPrompts.swift b/康康/AI/Prompts/VLPrompts.swift index 59a518d..2b64a92 100644 --- a/康康/AI/Prompts/VLPrompts.swift +++ b/康康/AI/Prompts/VLPrompts.swift @@ -116,8 +116,8 @@ JSON schema(严格): // MARK: - 报告整份解读(OCR 文本 → 完整 ParsedReport,纯文本 LLM) - /// C2「重新解读」/ 拍照整份识别走这条:主模型 Gemma-3n 只有文本能力(MLX 无 gemma3n 视觉), - /// 故先 Vision OCR 出纯文本,再交文本 LLM 一次抽出报告级 meta + 全部指标。 + /// C2「重新解读」/ 拍照整份识别走这条:主模型 Gemma 4 E2B 无视觉权重(e2b 检查点仅文本, + /// 视觉在 gemma4_unified 12B),故先 Vision OCR 出纯文本,再交文本 LLM 一次抽出报告级 meta + 全部指标。 /// 与 reportExtraction(多模态)同 schema,但去掉 source_page / source_box —— OCR 文本没有版面坐标, /// 让模型编框会污染原图证据高亮,统一不输出。 static func reportExtractionFromText(_ ocrText: String, today: Date = .now) -> String { diff --git a/康康/AI/VLSession.swift b/康康/AI/VLSession.swift index 20666bf..af243d6 100644 --- a/康康/AI/VLSession.swift +++ b/康康/AI/VLSession.swift @@ -2,6 +2,10 @@ import Foundation import MLX import MLXVLM import MLXLMCommon +// 3.x HF Hub/分词器栈拆成 opt-in 宏(见 LLMSession 注释)。 +import MLXHuggingFace +import HuggingFace +import Tokenizers /// 封装 MLX VL 模型(Qwen3-VL)的图像 → 文本推理。 /// 与 LLMSession 同款 actor 隔离,串行化由上游 AIRuntime 统一保证。 @@ -25,8 +29,11 @@ actor VLSession { /// 从本地目录加载 VL 模型(包含 config.json + weights + tokenizer + processor)。 static func load(folderURL: URL) async throws -> VLSession { let configuration = ModelConfiguration(directory: folderURL) + // 3.31.4:同 LLMSession,显式传 Downloader(占位)+ HF 分词器加载器。 let container = try await withDeviceOverride { try await VLMModelFactory.shared.loadContainer( + from: #hubDownloader(), + using: #huggingFaceTokenizerLoader(), configuration: configuration ) } diff --git a/康康/Features/Me/InferenceSettingsView.swift b/康康/Features/Me/InferenceSettingsView.swift index f3b9fed..11a0531 100644 --- a/康康/Features/Me/InferenceSettingsView.swift +++ b/康康/Features/Me/InferenceSettingsView.swift @@ -1,7 +1,7 @@ import SwiftUI -/// 推理引擎设置:本项目主模型 Gemma-3n(端侧 4bit),**端侧统一走 MLX/GPU**。 -/// MNN/SME2 LLM 引擎已移除(Gemma-3n 无 MNN 转换模型),不再提供引擎选择—— +/// 推理引擎设置:本项目主模型 Gemma 4(端侧 4bit),**端侧统一走 MLX/GPU**。 +/// MNN/SME2 LLM 引擎已移除(Gemma 4 无 MNN 转换模型),不再提供引擎选择—— /// 端侧只此一种,页面改为展示当前端侧后端 + 性能自检 + 云端 AI 开关。 struct InferenceSettingsView: View { // 云端 AI(Gemini)开关与 key —— 键名与 CloudAI 对齐,@AppStorage 写入即被后端读到。 @@ -11,7 +11,7 @@ struct InferenceSettingsView: View { /// 性能自检改为当前页就地展开,不再 push 新页面。 @State private var showSelfTest = false - /// 性能自检需要主模型(Gemma-3n,MLX)就绪。 + /// 性能自检需要主模型(Gemma 4,MLX)就绪。 private var modelReady: Bool { modelService.states[.llm]?.phase == .ready } @@ -41,7 +41,7 @@ struct InferenceSettingsView: View { .onAppear { modelService.refreshStates() } } - /// 端侧后端信息卡(只此一种,不可切换):MLX · Metal GPU,端侧 Gemma-3n E2B。 + /// 端侧后端信息卡(只此一种,不可切换):MLX · Metal GPU,端侧 Gemma 4 E2B。 private var localEngineCard: some View { HStack(spacing: 12) { ZStack { @@ -56,7 +56,7 @@ struct InferenceSettingsView: View { Text("MLX · GPU") .font(.tjScaled(15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) - Text("Metal GPU · 端侧推理 Gemma-3n E2B") + Text("Metal GPU · 端侧推理 Gemma 4 E2B") .font(.tjScaled(12)) .foregroundStyle(Tj.Palette.text3) .lineLimit(2) @@ -141,7 +141,7 @@ struct InferenceSettingsView: View { Text("启用云端增强") .font(.tjScaled(15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) - Text("默认端侧 Gemma-3n;开启后「读报告原图 / 深度解读 / 多语言」走 Google Gemini。") + Text("默认端侧 Gemma 4;开启后「读报告原图 / 深度解读 / 多语言」走 Google Gemini。") .font(.tjScaled(12)) .foregroundStyle(Tj.Palette.text3) } @@ -166,7 +166,7 @@ struct InferenceSettingsView: View { .font(.tjScaled(12)) .foregroundStyle(Tj.Palette.text3) } - Text("Key 仅存本机;断网或额度用尽时自动回退端侧 Gemma-3n,功能不中断。") + Text("Key 仅存本机;断网或额度用尽时自动回退端侧 Gemma 4,功能不中断。") .font(.tjScaled(11)) .foregroundStyle(Tj.Palette.text3) } @@ -177,7 +177,7 @@ struct InferenceSettingsView: View { } private var noteCard: some View { - Text("隐私优先:默认端侧 Gemma-3n(MLX · Metal GPU)推理,数据不出设备;仅在你开启「云端 AI」后,深度任务才走 Google Gemini。切换后下一次 AI 调用生效。") + Text("隐私优先:默认端侧 Gemma 4(MLX · Metal GPU)推理,数据不出设备;仅在你开启「云端 AI」后,深度任务才走 Google Gemini。切换后下一次 AI 调用生效。") .font(.tjScaled(12)) .foregroundStyle(Tj.Palette.text3) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/康康/Features/Me/MeView.swift b/康康/Features/Me/MeView.swift index 71494f3..64c9ca9 100644 --- a/康康/Features/Me/MeView.swift +++ b/康康/Features/Me/MeView.swift @@ -167,7 +167,7 @@ struct MeView: View { .buttonStyle(.plain) } - /// 端侧统一走 MLX/GPU(Gemma-3n E2B);MNN/SME2 LLM 引擎已移除。 + /// 端侧统一走 MLX/GPU(Gemma 4 E2B);MNN/SME2 LLM 引擎已移除。 private var engineDetail: String { "MLX · GPU" } private var languageCard: some View { diff --git a/康康/Features/Me/ModelManagementView.swift b/康康/Features/Me/ModelManagementView.swift index 40ae629..3f32d84 100644 --- a/康康/Features/Me/ModelManagementView.swift +++ b/康康/Features/Me/ModelManagementView.swift @@ -141,7 +141,7 @@ struct ModelManagementView: View { } else if allReady { HStack(spacing: 6) { Image(systemName: "checkmark.seal.fill") - Text("Gemma-3n E2B 已就绪") + Text("Gemma 4 E2B 已就绪") } .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.leaf) diff --git a/康康/Features/Me/ModelSelfTestView.swift b/康康/Features/Me/ModelSelfTestView.swift index 00b7308..efc2624 100644 --- a/康康/Features/Me/ModelSelfTestView.swift +++ b/康康/Features/Me/ModelSelfTestView.swift @@ -1,6 +1,6 @@ import SwiftUI -/// 性能自检:跑固定 prompt,展示当前端侧后端(MLX·GPU,Gemma-3n E2B)的 +/// 性能自检:跑固定 prompt,展示当前端侧后端(MLX·GPU,Gemma 4 E2B)的 /// prefill / decode 实测速度,并按后端存档对比 —— 端侧推理性能的可见证据(§12 卖点 2/6)。 struct ModelSelfTestView: View { @State private var output = "" @@ -177,7 +177,7 @@ struct ModelSelfTestView: View { } } } - Text("Gemma-3n E2B 在端侧 MLX(Metal GPU)推理,100% 本地、不上云。") + Text("Gemma 4 E2B 在端侧 MLX(Metal GPU)推理,100% 本地、不上云。") .font(.tjScaled( 10)) .foregroundStyle(Tj.Palette.text3) } diff --git a/康康/Localizable.xcstrings b/康康/Localizable.xcstrings index 04f3783..c362d22 100644 --- a/康康/Localizable.xcstrings +++ b/康康/Localizable.xcstrings @@ -1743,46 +1743,46 @@ } } }, - "Gemma-3n E2B 在端侧 MLX(Metal GPU)推理,100% 本地、不上云。" : { + "Gemma 4 E2B 在端侧 MLX(Metal GPU)推理,100% 本地、不上云。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Gemma-3n E2B runs on-device with MLX (Metal GPU) — 100% local, nothing goes to the cloud." + "value" : "Gemma 4 E2B runs on-device with MLX (Metal GPU) — 100% local, nothing goes to the cloud." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Gemma-3n E2B は端末上の MLX(Metal GPU)で推論します。100% ローカルで、クラウドには送信しません。" + "value" : "Gemma 4 E2B は端末上の MLX(Metal GPU)で推論します。100% ローカルで、クラウドには送信しません。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Gemma-3n E2B는 기기 내 MLX(Metal GPU)에서 추론합니다. 100% 로컬이며 클라우드로 전송하지 않습니다." + "value" : "Gemma 4 E2B는 기기 내 MLX(Metal GPU)에서 추론합니다. 100% 로컬이며 클라우드로 전송하지 않습니다." } } } }, - "Gemma-3n E2B 已就绪" : { + "Gemma 4 E2B 已就绪" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Gemma-3n E2B is ready" + "value" : "Gemma 4 E2B is ready" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Gemma-3n E2B の準備ができました" + "value" : "Gemma 4 E2B の準備ができました" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Gemma-3n E2B 준비 완료" + "value" : "Gemma 4 E2B 준비 완료" } } } @@ -1790,24 +1790,24 @@ "hi" : { }, - "Key 仅存本机;断网或额度用尽时自动回退端侧 Gemma-3n,功能不中断。" : { + "Key 仅存本机;断网或额度用尽时自动回退端侧 Gemma 4,功能不中断。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your key is stored only on this device; if you're offline or out of quota, it automatically falls back to on-device Gemma-3n, so features keep working." + "value" : "Your key is stored only on this device; if you're offline or out of quota, it automatically falls back to on-device Gemma 4, so features keep working." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "キーは本体にのみ保存されます。オフライン時や利用枠を使い切った場合は自動で端末上の Gemma-3n に切り替わり、機能は途切れません。" + "value" : "キーは本体にのみ保存されます。オフライン時や利用枠を使い切った場合は自動で端末上の Gemma 4 に切り替わり、機能は途切れません。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "키는 기기에만 저장됩니다. 오프라인이거나 할당량을 모두 사용하면 자동으로 기기 내 Gemma-3n으로 전환되어 기능이 중단되지 않습니다." + "value" : "키는 기기에만 저장됩니다. 오프라인이거나 할당량을 모두 사용하면 자동으로 기기 내 Gemma 4으로 전환되어 기능이 중단되지 않습니다." } } } @@ -1818,24 +1818,24 @@ "lo" : { }, - "Metal GPU · 端侧推理 Gemma-3n E2B" : { + "Metal GPU · 端侧推理 Gemma 4 E2B" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Metal GPU · On-device inference with Gemma-3n E2B" + "value" : "Metal GPU · On-device inference with Gemma 4 E2B" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Metal GPU · 端末上で Gemma-3n E2B を推論" + "value" : "Metal GPU · 端末上で Gemma 4 E2B を推論" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Metal GPU · 기기 내 Gemma-3n E2B 추론" + "value" : "Metal GPU · 기기 내 Gemma 4 E2B 추론" } } } @@ -7231,24 +7231,24 @@ } } }, - "已停用:Gemma-3n 无 MNN 转换模型,统一走 MLX" : { + "已停用:Gemma 4 无 MNN 转换模型,统一走 MLX" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Disabled: Gemma-3n has no MNN-converted model, so it runs on MLX" + "value" : "Disabled: Gemma 4 has no MNN-converted model, so it runs on MLX" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "無効:Gemma-3n には MNN 変換モデルがないため、MLX で統一します" + "value" : "無効:Gemma 4 には MNN 変換モデルがないため、MLX で統一します" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "사용 중지: Gemma-3n은 MNN 변환 모델이 없어 MLX로 통일합니다" + "value" : "사용 중지: Gemma 4은 MNN 변환 모델이 없어 MLX로 통일합니다" } } } @@ -19327,24 +19327,24 @@ } } }, - "隐私优先:默认端侧 Gemma-3n(MLX · Metal GPU)推理,数据不出设备;仅在你开启「云端 AI」后,深度任务才走 Google Gemini。切换后下一次 AI 调用生效。" : { + "隐私优先:默认端侧 Gemma 4(MLX · Metal GPU)推理,数据不出设备;仅在你开启「云端 AI」后,深度任务才走 Google Gemini。切换后下一次 AI 调用生效。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Privacy first: by default, inference runs on-device with Gemma-3n (MLX · Metal GPU) and your data never leaves the device; only after you turn on \"Cloud AI\" do deep tasks use Google Gemini. The change takes effect on your next AI call." + "value" : "Privacy first: by default, inference runs on-device with Gemma 4 (MLX · Metal GPU) and your data never leaves the device; only after you turn on \"Cloud AI\" do deep tasks use Google Gemini. The change takes effect on your next AI call." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "プライバシー優先:既定では端末内の Gemma-3n(MLX・Metal GPU)で推論し、データは端末外に出ません。「クラウド AI」をオンにした場合のみ、高度なタスクが Google Gemini を使います。切り替えは次回の AI 呼び出しから有効になります。" + "value" : "プライバシー優先:既定では端末内の Gemma 4(MLX・Metal GPU)で推論し、データは端末外に出ません。「クラウド AI」をオンにした場合のみ、高度なタスクが Google Gemini を使います。切り替えは次回の AI 呼び出しから有効になります。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "개인정보 우선: 기본적으로 기기 내 Gemma-3n(MLX · Metal GPU)으로 추론하며 데이터는 기기를 벗어나지 않아요. \"클라우드 AI\"를 켠 경우에만 심층 작업이 Google Gemini를 사용해요. 전환은 다음 AI 호출부터 적용돼요." + "value" : "개인정보 우선: 기본적으로 기기 내 Gemma 4(MLX · Metal GPU)으로 추론하며 데이터는 기기를 벗어나지 않아요. \"클라우드 AI\"를 켠 경우에만 심층 작업이 Google Gemini를 사용해요. 전환은 다음 AI 호출부터 적용돼요." } } } @@ -19638,24 +19638,24 @@ } } }, - "默认端侧 Gemma-3n;开启后「读报告原图 / 深度解读 / 多语言」走 Google Gemini。" : { + "默认端侧 Gemma 4;开启后「读报告原图 / 深度解读 / 多语言」走 Google Gemini。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "On-device Gemma-3n by default; once enabled, \"read report images / deep interpretation / multilingual\" use Google Gemini." + "value" : "On-device Gemma 4 by default; once enabled, \"read report images / deep interpretation / multilingual\" use Google Gemini." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "既定は端末内の Gemma-3n。オンにすると「レポート原本の読み取り/深い解釈/多言語」が Google Gemini を使います。" + "value" : "既定は端末内の Gemma 4。オンにすると「レポート原本の読み取り/深い解釈/多言語」が Google Gemini を使います。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "기본은 기기 내 Gemma-3n. 켜면 \"리포트 원본 읽기 / 심층 해석 / 다국어\"가 Google Gemini를 사용해요." + "value" : "기본은 기기 내 Gemma 4. 켜면 \"리포트 원본 읽기 / 심층 해석 / 다국어\"가 Google Gemini를 사용해요." } } } diff --git a/康康/Models/HealthExport.swift b/康康/Models/HealthExport.swift index fb2cf51..2359223 100644 --- a/康康/Models/HealthExport.swift +++ b/康康/Models/HealthExport.swift @@ -28,7 +28,7 @@ final class HealthExport { var inferredLabelCN: String? // demo 卖点凭证 - /// 模型 tag,如 "gemma-3n-E2B-it-lm-4bit"(MLX 端侧主模型)。截图能证明本地推理。 + /// 模型 tag,如 "gemma-4-e2b-it-4bit"(MLX 端侧主模型)。截图能证明本地推理。 var modelTag: String /// 末次 tok/s,对应 demo 卖点 #6 Live Activity 数据。 var decodeRate: Double @@ -44,7 +44,7 @@ final class HealthExport { inferredTimeToDate: Date? = nil, inferredIntent: String? = nil, inferredLabelCN: String? = nil, - modelTag: String = "gemma-3n-E2B-it-lm-4bit", + modelTag: String = "gemma-4-e2b-it-4bit", decodeRate: Double = 0) { self.prompt = prompt self.content = content diff --git a/康康/Services/CaptureService.swift b/康康/Services/CaptureService.swift index 75fe2fb..98df012 100644 --- a/康康/Services/CaptureService.swift +++ b/康康/Services/CaptureService.swift @@ -178,7 +178,7 @@ actor CaptureService { /// /// hybrid 双路: /// - **云端可用(Gemini)**:图片直传 Gemini 多模态读图(真·VL,恢复 source_box 证据高亮), - /// OCR 文本作数字「抄写员」一并注入。这是端侧 Gemma-3n(MLX 文本版,无视觉)拿不到的能力。 + /// OCR 文本作数字「抄写员」一并注入。这是端侧 Gemma 4(MLX 文本版,无视觉)拿不到的能力。 /// - **离线/未开云端**:回退 Vision OCR(本地,<1s/页)→ 端侧 Gemma 文本 LLM 抽 meta+指标。 /// 云端任何失败(离线/超时/解析失败)都静默回退端侧,绝不卡死(§3.2 失败回退红线)。 private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport { diff --git a/康康/Services/ModelDownloadService.swift b/康康/Services/ModelDownloadService.swift index d35e870..2211ca6 100644 --- a/康康/Services/ModelDownloadService.swift +++ b/康康/Services/ModelDownloadService.swift @@ -88,17 +88,38 @@ final class ModelDownloadService { if Task.isCancelled { return } let destination = store.fileURL(for: kind, relativePath: file.path) let base = completedBefore - try await downloader.download( - from: ModelManifest.fileURL(for: kind, file: file), - to: destination, - expectedBytes: file.bytes, - onProgress: { [weak self] received in - guard let self else { return } - Task { @MainActor in - self.applyProgress(kind, currentTotal: base + received) - } + + // 魔搭优先 + hf-mirror 回退:按序尝试候选源,任一成功即止; + // 换源续传复用同一 .part(两源字节一致,Range 续传穿透)。 + let sources = ModelManifest.fileURLs(for: kind, file: file) + var lastError: Error? + var downloaded = false + for (idx, source) in sources.enumerated() { + if Task.isCancelled { return } + do { + try await downloader.download( + from: source, + to: destination, + expectedBytes: file.bytes, + onProgress: { [weak self] received in + guard let self else { return } + Task { @MainActor in + self.applyProgress(kind, currentTotal: base + received) + } + } + ) + downloaded = true + break + } catch { + lastError = error + #if DEBUG + let more = idx + 1 < sources.count ? ",回退下一个源" : "" + print("[ModelDownload] \(file.path) 源 \(idx + 1)/\(sources.count) 失败:" + + "\(error.localizedDescription)\(more)") + #endif } - ) + } + if !downloaded { throw lastError ?? DownloadError.badStatus(0) } completedBefore += file.bytes } finish(kind, success: true, message: nil) diff --git a/康康Tests/ModelManifestTests.swift b/康康Tests/ModelManifestTests.swift index 1011d2b..403a162 100644 --- a/康康Tests/ModelManifestTests.swift +++ b/康康Tests/ModelManifestTests.swift @@ -4,9 +4,10 @@ import Foundation struct ModelManifestTests { - @Test func llmHasNineFunctionalFiles() { - // 主模型已切 Gemma-3n E2B(text-only),9 个运行文件。 - #expect(ModelManifest.files(for: .llm).count == 9) + @Test func llmHasEightFunctionalFiles() { + // 主模型已切 Gemma 4 E2B(text-only),8 个运行文件 + //(无 tokenizer.model / special_tokens_map.json,新增 processor_config.json)。 + #expect(ModelManifest.files(for: .llm).count == 8) } @Test func vlHasFourteenFunctionalFiles() { @@ -14,8 +15,8 @@ struct ModelManifestTests { } @Test func llmTotalBytesMatchesManifest() { - // Gemma-3n-E2B-it-lm-4bit 全部运行文件字节之和(ModelScope repo/files 实测,2026-06)。 - #expect(ModelManifest.totalBytes(for: .llm) == 2_547_095_782) + // gemma-4-e2b-it-4bit 全部运行文件字节之和(ModelScope repo/files 实测,2026-07)。 + #expect(ModelManifest.totalBytes(for: .llm) == 3_613_528_388) } @Test func vlTotalBytesMatchesManifest() { @@ -66,10 +67,20 @@ struct ModelManifestTests { } @Test func llmFileURLUsesModelScopeRepo() { - // 主模型走 ModelScope 官方 resolve/master(大陆可达,302 跳 OSS 支持 Range 续传)。 - let file = ModelFile(path: "config.json", bytes: 107_207) + // 首选源走 ModelScope 官方 resolve/master(大陆可达,302 跳 OSS 支持 Range 续传)。 + let file = ModelFile(path: "config.json", bytes: 5_996) let url = ModelManifest.fileURL(for: .llm, file: file) #expect(url.absoluteString == - "https://modelscope.cn/models/mlx-community/gemma-3n-E2B-it-lm-4bit/resolve/master/config.json") + "https://modelscope.cn/models/mlx-community/gemma-4-e2b-it-4bit/resolve/master/config.json") + } + + @Test func llmFileURLsAreModelScopeThenHFMirror() { + // 用户选择:魔搭优先 + hf-mirror 回退。候选源顺序必须为 [ModelScope master, hf-mirror main]。 + let file = ModelFile(path: "config.json", bytes: 5_996) + let urls = ModelManifest.fileURLs(for: .llm, file: file).map(\.absoluteString) + #expect(urls == [ + "https://modelscope.cn/models/mlx-community/gemma-4-e2b-it-4bit/resolve/master/config.json", + "https://hf-mirror.com/mlx-community/gemma-4-e2b-it-4bit/resolve/main/config.json", + ]) } }