根据提供的code differences信息,由于没有具体的代码变更内容,我将生成一个通用的commit message模板:
``` docs(readme): 更新文档说明 - 添加项目使用指南 - 完善API接口说明 - 修正错误的配置示例 ```
This commit is contained in:
@@ -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 的
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 的聊天回合以 `<end_of_turn>`(token 106)结束,而分词器自带的 eos 是 `<eos>`(1);
|
||||
/// 不显式补 `<end_of_turn>` 会让解码停不下来、把 maxTokens 跑满还吐回合分隔噪声。
|
||||
/// Gemma 4 改了回合标记:聊天回合以 `<turn|>`(token 106)结束(3n 时代是 `<end_of_turn>`),
|
||||
/// 而分词器自带的 eos 仍是 `<eos>`(1);不显式补 `<turn|>` 会让解码停不下来、把 maxTokens
|
||||
/// 跑满还吐回合分隔噪声。与上游 mlx-swift-lm 预置 `gemma4_e2b_it_4bit` 的 extraEOSTokens 一致。
|
||||
static func load(folderURL: URL) async throws -> LLMSession {
|
||||
let configuration = ModelConfiguration(
|
||||
directory: folderURL,
|
||||
extraEOSTokens: ["<end_of_turn>"]
|
||||
extraEOSTokens: ["<turn|>"]
|
||||
)
|
||||
// 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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user