Files
kangkang/康康/AI/MNNBackend.swift
link2026 ddfd474bb3 feat(AI): MNN 4B 多模态一肩挑文本+视觉,合并为单模型(MLX 仍兜底)
利用 Qwen3.5-4B-MNN 本身是多模态(含 visual.mnn),让同一个 MNN 模型
同时做文本生成与拍照识别 → MNN 路径只需下 1 个模型(7.4GB→2.64GB)。
MLX(.llm/.vl)保留作兜底,尤其开发机 iPhone 15 Pro(A17 无 SME2)。

- MNN.xcframework 重建为 OMNI(MNN_BUILD_LLM_OMNI=ON,加 OpenCV 图像解码);
  构建脚本同步加 OMNI flag
- MNNLLMBridge.analyzeImages:把图片路径拼成 <img>路径</img> 标签 + response,
  Omni 内部 CV::imread 加载(无需桥接 include OpenCV);与 generateText 共用 runResponse
- MNNBackend.analyze:detached 线程跑 blocking VL 调用,聚合为字符串
- AIRuntime:engine=.mnn 且就绪时,prepareVL→prepareMNN、analyzeReport→mnn.analyze;
  否则回退 MLX VL

device + 模拟器 BUILD SUCCEEDED,0 error,OMNI 框架链接干净。
VL 实际识别质量需真机用化验单 A/B(demo 核心)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:52:58 +08:00

95 lines
3.9 KiB
Swift

import Foundation
/// MNN(CPU / SME2), `MNNLLMBridge`
/// `LLMSession`/`VLSession` actor ; `AIRuntime`
///
/// VL() MNN OMNI (OpenCV ),;`analyze` ,
/// VL 退 MLX( `AIRuntime`)
actor MNNBackend {
private var bridge: MNNLLMBridge?
var isLoaded: Bool { bridge?.isLoaded ?? false }
/// MNN ( MNN llm config.json + llm.mnn + + tokenizer)
func load(folderURL: URL) throws {
let configPath = folderURL.appendingPathComponent("config.json").path
guard FileManager.default.fileExists(atPath: configPath) else {
throw AIRuntimeError.notReady
}
guard let b = MNNLLMBridge(configPath: configPath) else {
throw AIRuntimeError.modelLoadFailed("MNN createLLM/load 失败")
}
bridge = b
}
func unload() { bridge = nil }
/// `bridge.generateText` , detached 线,
/// yield `TokenChunk`( tok/s) `bridge.cancel()`
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
guard let bridge else {
return AsyncThrowingStream { $0.finish(throwing: AIRuntimeError.notReady) }
}
let box = MNNUncheckedBox(bridge)
return AsyncThrowingStream { continuation in
let meter = MNNRateMeter()
let task = Task.detached(priority: .userInitiated) {
_ = box.value.generateText(prompt, maxTokens: Int32(maxTokens)) { piece in
let rate = meter.tick()
continuation.yield(TokenChunk(text: piece, decodeRate: rate))
}
continuation.finish()
}
continuation.onTermination = { _ in
box.value.cancel()
task.cancel()
}
}
}
/// (VL)(JSON ) <img> ,
/// MNN Omni imread ( OMNI );blocking detached 线
func analyze(imageURLs: [URL], prompt: String, maxTokens: Int) async throws -> String {
guard let bridge else { throw AIRuntimeError.notReady }
let paths = imageURLs.map(\.path)
let box = MNNUncheckedBox(bridge)
return try await withCheckedThrowingContinuation { cont in
Task.detached(priority: .userInitiated) {
let sink = MNNTextSink()
do {
_ = try box.value.analyzeImages(paths, prompt: prompt, maxTokens: Int32(maxTokens)) { piece in
sink.append(piece)
}
cont.resume(returning: sink.text)
} catch {
cont.resume(throwing: AIRuntimeError.inferenceFailed(error.localizedDescription))
}
}
}
}
}
/// 线,
private nonisolated final class MNNTextSink: @unchecked Sendable {
private(set) var text = ""
func append(_ s: String) { text += s }
}
/// Sendable ObjC detached
/// `AIRuntime` :,访
private nonisolated struct MNNUncheckedBox<T>: @unchecked Sendable {
let value: T
init(_ value: T) { self.value = value }
}
/// :线,
private nonisolated final class MNNRateMeter: @unchecked Sendable {
private let start = Date()
private var produced = 0
func tick() -> Double {
produced += 1
let elapsed = Date().timeIntervalSince(start)
return elapsed > 0 ? Double(produced) / elapsed : 0
}
}