diff --git a/康康/AI/AIRuntime.swift b/康康/AI/AIRuntime.swift index 7fcdb26..ad79933 100644 --- a/康康/AI/AIRuntime.swift +++ b/康康/AI/AIRuntime.swift @@ -39,10 +39,8 @@ actor AIRuntime { private(set) var lastGenerateStats: GenerateStats? /// 当前实际生效的后端标签(性能自检 / PPT 截图用)。 + /// 端侧统一走 MLX/GPU(Gemma-3n E2B);MNN/SME2 LLM 路径已移除。 var activeBackendLabel: String { - if InferenceEngine.current == .mnn, mnnStatus == .ready { - return InferenceEngine.cpuSupportsSME2 ? "MNN · SME2" : "MNN · NEON" - } #if targetEnvironment(simulator) return "MLX · CPU(模拟器)" #else @@ -53,12 +51,6 @@ actor AIRuntime { private var llmSession: LLMSession? private var vlSession: VLSession? - // MARK: - MNN 后端(CPU/SME2,挑战赛考核路径) - // .mnn 引擎下,文本生成与 VL(图→文)由同一个 Qwen3.5-2B 多模态 MNN 模型全包(已实测)。 - // 模拟器无 MNN,VL 回退 MLX 的 Qwen3-VL-4B。 - private let mnn = MNNBackend() - private(set) var mnnStatus: Status = .notReady - // MARK: - Gemini 云端后端(hybrid:端侧 Gemma 默认,云端按需增强) // 云端调用不占本机显存,不进 OOM 闸门,可与端侧推理并发。用于「云端深度解读 / 多语言」 // 与「拍报告/药盒多模态读图」——后者恢复端侧 Gemma-3n(MLX 文本版)丢掉的真·视觉能力。 @@ -67,10 +59,6 @@ actor AIRuntime { nonisolated var cloudAvailable: Bool { CloudAI.isConfigured } /// 云端后端标签(性能自检 / 截图用)。 nonisolated static var cloudLabel: String { "Gemini · \(CloudAI.model)" } - /// MNN 模型目录(下载/旁路导入到 Models/Qwen3.5-2B-MNN)。 - nonisolated static var mnnModelFolder: URL { - ModelStore.shared.localURL(for: .mnnLLM) - } // MARK: - 串行推理闸门(§3.1 OOM 防护的真正落地) // @@ -144,18 +132,8 @@ actor AIRuntime { #endif } - /// 加载文本模型。首次调用会真正加载,后续幂等。 - /// 按当前引擎路由:.mnn → MNN(CPU/SME2);.mlx → 现有 MLX(GPU)。 + /// 加载文本模型(端侧 MLX/GPU,Gemma-3n E2B)。首次调用会真正加载,后续幂等。 func prepare() async throws { - // 选了 MNN 且模型已就绪才走 MNN;否则(选 MLX,或 MNN 模型尚未下载)回退 MLX, - // 保证过渡期 App 始终可用。引擎指示器(Phase 5)展示实际生效后端。 - let mnnReady = ModelStore.shared.isComplete(for: .mnnLLM) - if InferenceEngine.current == .mnn, mnnReady { - try await prepareMNN() - return - } - // 走 MLX:先卸 MNN 释放内存(单模型常驻策略)。 - await unloadMNN() // 已有其他调用方在加载时,轮询等其结束再判定结果。 // 不能像旧实现那样裸 return:那会让调用方误以为已 ready,随后 generate 的 // `guard status == .ready` 失败 → 用户撞上「假错误屏」(模型其实正常加载中)。 @@ -195,54 +173,12 @@ actor AIRuntime { } } - /// 加载 MNN 文本模型。幂等。单模型常驻:载入前卸掉 MLX 的 LLM/VL 释放内存。 - private func prepareMNN() async throws { - while mnnStatus == .loading { - try await Task.sleep(nanoseconds: 80_000_000) - } - if mnnStatus == .ready { return } - - let folder = Self.mnnModelFolder - guard ModelStore.shared.isComplete(for: .mnnLLM) else { - mnnStatus = .error("MNN 模型未就绪") - throw AIRuntimeError.notReady - } - - await acquireGate() - defer { releaseGate() } - if mnnStatus == .ready { return } - - // 单模型常驻:卸 MLX LLM/VL,避免与 MNN 模型叠加占内存。 - unloadLLM() - unloadVL() - - mnnStatus = .loading - do { - try await mnn.load(folderURL: folder) - mnnStatus = .ready - } catch { - mnnStatus = .error("\(error)") - throw AIRuntimeError.modelLoadFailed("\(error)") - } - } - - /// 卸载 MNN,释放桥与权重。幂等。 - private func unloadMNN() async { - guard mnnStatus != .notReady else { return } - await mnn.unload() - mnnStatus = .notReady - MLX.Memory.clearCache() - } - /// 流式生成。调用前应先 await prepare()。 /// 注意:返回流是同步创建的,但跨 actor 调用 LLMSession 需要 await。 /// priority = .background 时排队让行、解码中可被前台请求按 token 抢占(CancellationError 透传)。 func generate(prompt: String, maxTokens: Int = 256, priority: InferencePriority = .interactive) -> AsyncThrowingStream { - if InferenceEngine.current == .mnn, mnnStatus == .ready { - return mnnGenerate(prompt: prompt, maxTokens: maxTokens, priority: priority) - } // 在 actor 隔离上下文中捕获快照,Task 内不再访问 self.status / self.llmSession let snapshotStatus = status let snapshotSession = llmSession @@ -282,40 +218,6 @@ actor AIRuntime { } } - /// MNN(CPU/SME2)文本流式生成。结构与 MLX 分支一致:进闸门、串行解码、记录速率。 - private func mnnGenerate(prompt: String, - maxTokens: Int, - priority: InferencePriority) -> AsyncThrowingStream { - let ready = (mnnStatus == .ready) - return AsyncThrowingStream { continuation in - let task = Task { - guard ready else { - continuation.finish(throwing: AIRuntimeError.notReady) - return - } - await self.acquireGate(priority) - defer { self.releaseGate() } // 无论正常结束 / 异常 / 取消都释放闸门,防死锁 - do { - let stream = await self.mnn.generate(prompt: prompt, maxTokens: maxTokens) - for try await chunk in stream { - try Task.checkCancellation() - // 后台任务让位:前台请求在排队时,下一个 token 处主动退出 - //(流终止触发 MNNBackend.onTermination → bridge.cancel())。 - if self.shouldPreempt(priority) { throw CancellationError() } - continuation.yield(chunk) - } - self.lastGenerateStats = await self.mnn.lastStats - continuation.finish() - } catch is CancellationError { - continuation.finish(throwing: CancellationError()) - } catch { - continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)")) - } - } - continuation.onTermination = { _ in task.cancel() } - } - } - // MARK: - Gemini 云端(hybrid 增强) /// 云端流式生成(深度解读 / 多语言)。不进 OOM 闸门,可与端侧并发。 @@ -353,7 +255,7 @@ actor AIRuntime { // MARK: - 端侧 ASR(SenseVoice via sherpa-mnn)互斥入口 - /// 给端侧语音转写(SenseVoice,占 CPU + 内存)用:进推理闸门串行 + 卸掉常驻 LLM/VL/MNN 腾内存, + /// 给端侧语音转写(SenseVoice,占 CPU + 内存)用:进推理闸门串行 + 卸掉常驻 LLM/VL 腾内存, /// 避免与文本/视觉模型同时常驻冲过单 App 内存上限被 jetsam 杀(§3.1 OOM 防护)。 /// /// 问诊流程是「先转写 → 再 organize(会重载 LLM)」严格串行,所以这里卸掉 LLM 是安全的: @@ -365,7 +267,6 @@ actor AIRuntime { defer { releaseGate() } unloadLLM() unloadVL() - await unloadMNN() return try await body() } @@ -373,11 +274,6 @@ actor AIRuntime { /// 加载 VL 模型。幂等,首调真正 load。 func prepareVL() async throws { - // 选了 MNN 且多模态模型就绪:VL 复用同一个 MNN 模型(文本+视觉一肩挑),走 prepareMNN。 - if InferenceEngine.current == .mnn, ModelStore.shared.isComplete(for: .mnnLLM) { - try await prepareMNN() - return - } while vlStatus == .loading { try await Task.sleep(nanoseconds: 80_000_000) } @@ -398,7 +294,6 @@ actor AIRuntime { // OOM 闸门(§3.1):加载 VL(~3GB)前先卸 LLM(~1GB),否则两者常驻叠加冲过内存上限被 jetsam 杀。 unloadLLM() - await unloadMNN() vlStatus = .loading do { @@ -438,16 +333,6 @@ actor AIRuntime { func analyzeReport(imageURLs: [URL], prompt: String, maxTokens: Int = 512) async throws -> String { - // 选了 MNN 且就绪:图→文走同一个 MNN 多模态模型。 - if InferenceEngine.current == .mnn, mnnStatus == .ready { - await acquireGate() - defer { releaseGate() } - do { - return try await mnn.analyze(imageURLs: imageURLs, prompt: prompt, maxTokens: maxTokens) - } catch { - throw AIRuntimeError.inferenceFailed("\(error)") - } - } guard vlStatus == .ready, let session = vlSession else { throw AIRuntimeError.notReady } diff --git a/康康/AI/InferenceEngine.swift b/康康/AI/InferenceEngine.swift deleted file mode 100644 index 7806bc2..0000000 --- a/康康/AI/InferenceEngine.swift +++ /dev/null @@ -1,84 +0,0 @@ -import Foundation - -/// 端侧推理引擎选择。 -/// - mnn:Qwen + MNN + SME2(CPU),挑战赛考核路径,真机默认。 -/// - mlx:Qwen + MLX(Metal GPU),兜底 / 对照。模拟器只有它可用。 -nonisolated enum InferenceEngine: String, CaseIterable, Sendable { - case mnn - case mlx - - var displayName: String { - switch self { - case .mnn: return "MNN · CPU/SME2" - case .mlx: return "MLX · GPU" - } - } - - /// 本构建/设备是否可用。MNN 仅 device 切片有真实内核,模拟器回退 MLX。 - var isAvailable: Bool { - switch self { - case .mlx: return true - case .mnn: return MNNLLMBridge.isAvailable() - } - } - - // MARK: - 持久化(UserDefaults,跨 actor 安全) - - private static let key = "kk.inferenceEngine" - - /// 由偏好(可能是 .auto)解析出的、本次调用实际使用的具体引擎。 - /// AIRuntime / MeView 等消费方只看这个,永远拿到 .mnn 或 .mlx。 - /// 解析后仍做一次可用性兜底,保证总有可用引擎。 - /// - /// 项目已切 Gemma-3n(只走 MLX/GPU):Gemma-3n 跑不了 MNN(无转换模型), - /// 故主模型不再下载 MNN 切片。即便历史偏好写了 "mnn",只要本机没有完整的 MNN 模型, - /// 一律回退 MLX —— 杜绝「标签显示 MNN、实际却跑 MLX」的不一致。 - static var current: InferenceEngine { - let resolved = preference.resolved - guard resolved.isAvailable else { return .mlx } - if resolved == .mnn, !ModelStore.shared.isComplete(for: .mnnLLM) { return .mlx } - return resolved - } - - /// 运行时探测:CPU 是否支持 SME2(A19/iPhone17+)。用于 UI 展示加速状态。 - /// CPU 能力进程内不变,缓存一次,避免每次 UI 刷新都做 sysctl 系统调用。 - static let cpuSupportsSME2: Bool = MNNLLMBridge.cpuSupportsSME2() - - // MARK: - 用户偏好(auto / mnn / mlx) - - /// 用户在设置页的选择。默认 .auto:按本机配置自动择优。 - /// 与具体引擎共用同一 UserDefaults key——历史写入的 "mnn"/"mlx" 仍兼容。 - static var preference: EnginePreference { - get { - let raw = UserDefaults.standard.string(forKey: key) - return raw.flatMap(EnginePreference.init(rawValue:)) ?? .auto - } - set { UserDefaults.standard.set(newValue.rawValue, forKey: key) } - } -} - -/// 推理引擎的「用户偏好」,比具体引擎多一个 .auto。 -/// - auto:本项目已切 Gemma-3n,主模型只走 MLX/GPU,故 auto 一律解析为 .mlx。 -/// (Gemma-3n 跑不了 MNN/SME2;MNN 路径已停用。) -nonisolated enum EnginePreference: String, CaseIterable, Sendable { - case auto - case mnn - case mlx - - var displayName: String { - switch self { - case .auto: return "自动" - case .mnn: return InferenceEngine.mnn.displayName - case .mlx: return InferenceEngine.mlx.displayName - } - } - - /// 把偏好解析成具体引擎(不做可用性兜底,那一步留给 `InferenceEngine.current`)。 - var resolved: InferenceEngine { - switch self { - case .mnn: return .mnn - case .mlx: return .mlx - case .auto: return .mlx // Gemma-3n 只走 MLX/GPU,auto 即 MLX - } - } -} diff --git a/康康/AI/MNN/MNNLLMBridge.h b/康康/AI/MNN/MNNLLMBridge.h deleted file mode 100644 index afe64c5..0000000 --- a/康康/AI/MNN/MNNLLMBridge.h +++ /dev/null @@ -1,55 +0,0 @@ -// -// MNNLLMBridge.h -// 康康 -// -// Objective-C 接口,封装 MNN-LLM(Qwen)的加载与流式推理。 -// 真实实现在 .mm 中以 ObjC++ 调用 ;模拟器下编为可用性返回 NO 的桩 -// (MNN.framework 仅 device arm64 切片有真实 CPU/SME2 内核,模拟器走 MLX 兜底)。 -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -/// 末次生成的性能统计(取自 MNN LlmContext)。 -@interface MNNGenerateStats : NSObject -@property (nonatomic, readonly) int promptTokens; -@property (nonatomic, readonly) int genTokens; -@property (nonatomic, readonly) double prefillMs; -@property (nonatomic, readonly) double decodeMs; -/// 解码速率 tok/s = genTokens / (decodeMs/1000)。demo 卖点 #6 / Live Activity 用。 -@property (nonatomic, readonly) double decodeTokensPerSecond; -@end - -@interface MNNLLMBridge : NSObject - -/// 本构建是否含真实 MNN 运行时(device=YES,simulator 桩=NO)。 -+ (BOOL)isAvailable; -/// CPU 是否支持 SME2(运行时探测);A19/iPhone17 YES,A17/iPhone15Pro NO。仅用于 UI 展示加速状态。 -+ (BOOL)cpuSupportsSME2; - -/// 用 MNN llm 的 config.json 路径加载模型(目录含 llm.mnn / 权重 / tokenizer)。失败返回 nil。 -- (nullable instancetype)initWithConfigPath:(NSString *)configPath; - -@property (nonatomic, readonly) BOOL isLoaded; - -/// 纯文本流式生成。onToken 每解码出一段文本回调一次(在调用线程,同步阻塞直到生成结束)。 -/// 返回末次统计。 -- (MNNGenerateStats *)generateText:(NSString *)prompt - maxTokens:(int)maxTokens - onToken:(void (^)(NSString *piece))onToken; - -/// 图→文(VL,需 MNN_BUILD_LLM_OMNI 构建)。imagePaths 为本地文件路径。 -/// 当前文本构建未含 OMNI 时返回 nil 并置 error。 -- (nullable MNNGenerateStats *)analyzeImages:(NSArray *)imagePaths - prompt:(NSString *)prompt - maxTokens:(int)maxTokens - onToken:(void (^)(NSString *piece))onToken - error:(NSError *_Nullable *_Nullable)error; - -/// 请求取消当前生成(best-effort:置标志,后续 token 不再回调)。 -- (void)cancel; - -@end - -NS_ASSUME_NONNULL_END diff --git a/康康/AI/MNN/MNNLLMBridge.mm b/康康/AI/MNN/MNNLLMBridge.mm deleted file mode 100644 index 38f279a..0000000 --- a/康康/AI/MNN/MNNLLMBridge.mm +++ /dev/null @@ -1,216 +0,0 @@ -// -// MNNLLMBridge.mm -// 康康 -// -// ObjC++ 实现。device 真机用 ;模拟器编为桩(返回不可用,上层回退 MLX)。 -// - -#import "MNNLLMBridge.h" -#include - -// MARK: - 性能统计(私有 readwrite 重声明) -@interface MNNGenerateStats () -@property (nonatomic, readwrite) int promptTokens; -@property (nonatomic, readwrite) int genTokens; -@property (nonatomic, readwrite) double prefillMs; -@property (nonatomic, readwrite) double decodeMs; -@end - -@implementation MNNGenerateStats -- (double)decodeTokensPerSecond { - return self.decodeMs > 0 ? (self.genTokens / (self.decodeMs / 1000.0)) : 0; -} -@end - -// MARK: - SME2 / 可用性探测(device + simulator 都可编) - -static BOOL kk_sysctlFlag(const char *name) { - int64_t v = 0; size_t sz = sizeof(v); - if (sysctlbyname(name, &v, &sz, NULL, 0) != 0) return NO; - return v != 0; -} - -#if TARGET_OS_SIMULATOR - -// ============ 模拟器桩:无真实 MNN ============ -@implementation MNNLLMBridge -+ (BOOL)isAvailable { return NO; } -+ (BOOL)cpuSupportsSME2 { return NO; } -- (nullable instancetype)initWithConfigPath:(NSString *)configPath { return nil; } -- (BOOL)isLoaded { return NO; } -- (MNNGenerateStats *)generateText:(NSString *)prompt maxTokens:(int)maxTokens - onToken:(void (^)(NSString *))onToken { return [MNNGenerateStats new]; } -- (nullable MNNGenerateStats *)analyzeImages:(NSArray *)imagePaths prompt:(NSString *)prompt - maxTokens:(int)maxTokens onToken:(void (^)(NSString *))onToken - error:(NSError **)error { - if (error) *error = [NSError errorWithDomain:@"MNN" code:-1 - userInfo:@{NSLocalizedDescriptionKey: @"MNN 在模拟器不可用"}]; - return nil; -} -- (void)cancel {} -@end - -#else - -// ============ 真机:真实 MNN-LLM ============ -// MNN 第三方头文件的文档注释不规范,会触发一堆 -Wdocumentation 警告(Executor/ -// Tensor/Interpreter/ImageProcess.hpp)。只在解析 MNN 头时关掉该警告,不影响本项目。 -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdocumentation" -#include -#pragma clang diagnostic pop -#include -#include -#include -#include - -using MNN::Transformer::Llm; - -namespace { -/// 把 MNN 写入 ostream 的解码文本转成 NSString 回调;按 UTF-8 完整边界聚合,避免截断多字节。 -class TokenStreamBuf : public std::streambuf { -public: - TokenStreamBuf(void (^onToken)(NSString *), std::atomic *cancel) - : _onToken(onToken), _cancel(cancel) {} - void flush() { - if (_pending.empty()) return; - emitPending(); // 末尾尽力 emit(即便非完整 UTF-8 也交出去) - _pending.clear(); - } -protected: - std::streamsize xsputn(const char *s, std::streamsize n) override { - append(s, (size_t)n); - return n; - } - int overflow(int c) override { - if (c != EOF) { char ch = (char)c; append(&ch, 1); } - return c; - } -private: - void append(const char *s, size_t n) { - if (_cancel && _cancel->load()) return; // 已取消,吞掉不回调 - _pending.append(s, n); - // 仅当整个 pending 是合法 UTF-8 才 emit(token 通常是完整字/词,边界自然对齐) - NSString *str = [[NSString alloc] initWithBytes:_pending.data() - length:_pending.size() - encoding:NSUTF8StringEncoding]; - if (str) { if (_onToken) _onToken(str); _pending.clear(); } - } - void emitPending() { - NSString *str = [[NSString alloc] initWithBytes:_pending.data() - length:_pending.size() - encoding:NSUTF8StringEncoding]; - if (str && _onToken) _onToken(str); - } - void (^_onToken)(NSString *); - std::atomic *_cancel; - std::string _pending; -}; -} // namespace - -@implementation MNNLLMBridge { - Llm *_llm; - std::atomic _cancel; - BOOL _loaded; -} - -+ (BOOL)isAvailable { return YES; } - -+ (BOOL)cpuSupportsSME2 { - // Apple 通过 sysctl 暴露 ARM 特性位:FEAT_SME2(A19/iPhone17+)。 - return kk_sysctlFlag("hw.optional.arm.FEAT_SME2"); -} - -- (nullable instancetype)initWithConfigPath:(NSString *)configPath { - self = [super init]; - if (!self) return nil; - _cancel = false; - _llm = Llm::createLLM(std::string(configPath.UTF8String)); - if (_llm == nullptr) return nil; - // load 前以 merge-patch 调三件事(只翻这几个叶子,保留 chat_template 等其余配置): - // ① enable_thinking=false:config.json 默认 true,模板会给每个 assistant 回合硬塞 - // \n 开启思考,吞掉 token 预算并污染 JSON(prompt 里的 /no_think 对此模板无效)。 - // ② 降温:config.json 默认 temperature=1.0 对结构化 JSON 太高,随机性大→经常吐成非 JSON。 - // 本 App 所有任务都是"直答/JSON",压到 0.3 + topP 0.85 让输出更确定、JSON 更稳。 - // ③ 重复惩罚:MNN 默认 mixed_samplers 不含 "penalty"、penalty/ngram_factor=1.0(全关), - // 叠加低温 → 长文本(如「关键指标」列表)会陷入逐行复读死循环(收缩压 107 mmHg ×N)。 - // 显式把 "penalty" 放进 mixed 链首,开 repetition penalty(1.1)+ n-gram 惩罚(ngram_factor 1.05): - // n-gram 命中整段重复时惩罚升到 max_penalty,直接掐断逐行复读。 - _llm->set_config("{" - "\"jinja\":{\"context\":{\"enable_thinking\":false}}," - "\"sampler_type\":\"mixed\"," - "\"mixed_samplers\":[\"penalty\",\"topK\",\"topP\",\"temperature\"]," - "\"temperature\":0.3,\"topP\":0.85,\"topK\":40," - "\"penalty\":1.1,\"n_gram\":8,\"ngram_factor\":1.05" - "}"); - _loaded = _llm->load(); - if (!_loaded) { Llm::destroy(_llm); _llm = nullptr; return nil; } - return self; -} - -- (void)dealloc { - if (_llm) { Llm::destroy(_llm); _llm = nullptr; } -} - -- (BOOL)isLoaded { return _loaded; } - -- (void)cancel { _cancel = true; } - -// 统一生成:full 已是最终 prompt(文本,或含 路径 标签)。 -// 多模态模型 createLLM 返回 Omni,response 解析 标签并对路径 CV::imread(OMNI 框架内)。 -- (MNNGenerateStats *)runResponse:(NSString *)full - maxTokens:(int)maxTokens - onToken:(void (^)(NSString *))onToken { - _cancel = false; - TokenStreamBuf buf(onToken, &_cancel); - std::ostream os(&buf); - if (_llm) { - // 红线:本 App 每次 generate/analyze 都是一次性独立推理(无多轮对话语义)。 - // MNN 的 Llm::response 默认把本轮 prompt+输出累积进 history_tokens / KV cache, - // 不 reset 的话第二次导出会把上一次的完整上下文叠加进来 → all_seq_len 暴涨、 - // 冲过上下文上限 → 崩溃(用户报「再次导出死机」)。每轮先 reset 清空历史, - // 与 MLX LLMSession 的「每次 generate 无状态」保持一致。 - _llm->reset(); - _llm->response(std::string(full.UTF8String), &os, nullptr, maxTokens); - } - buf.flush(); - return [self statsFromContext]; -} - -- (MNNGenerateStats *)generateText:(NSString *)prompt - maxTokens:(int)maxTokens - onToken:(void (^)(NSString *))onToken { - return [self runResponse:prompt maxTokens:maxTokens onToken:onToken]; -} - -- (nullable MNNGenerateStats *)analyzeImages:(NSArray *)imagePaths - prompt:(NSString *)prompt - maxTokens:(int)maxTokens - onToken:(void (^)(NSString *))onToken - error:(NSError **)error { - // 在 prompt 前拼 本地路径;Omni 解析标签并对路径 imread(需 OMNI 框架)。 - NSMutableString *full = [NSMutableString string]; - for (NSString *p in imagePaths) { - [full appendFormat:@"%@", p]; - } - [full appendString:prompt]; - return [self runResponse:full maxTokens:maxTokens onToken:onToken]; -} - -- (MNNGenerateStats *)statsFromContext { - MNNGenerateStats *s = [MNNGenerateStats new]; - if (_llm) { - const MNN::Transformer::LlmContext *ctx = _llm->getContext(); - if (ctx) { - s.promptTokens = ctx->prompt_len; - s.genTokens = ctx->gen_seq_len; - s.prefillMs = ctx->prefill_us / 1000.0; - s.decodeMs = ctx->decode_us / 1000.0; - } - } - return s; -} - -@end - -#endif diff --git a/康康/AI/MNNBackend.swift b/康康/AI/MNNBackend.swift deleted file mode 100644 index bee2acf..0000000 --- a/康康/AI/MNNBackend.swift +++ /dev/null @@ -1,113 +0,0 @@ -import Foundation - -/// MNN(CPU / SME2)推理后端,封装 `MNNLLMBridge` 的文本流式生成。 -/// 与 `LLMSession`/`VLSession` 同款 actor 隔离;跨调用的串行化由上游 `AIRuntime` 闸门保证。 -/// -/// 文本与视觉(图→文)由同一个 Qwen3.5-2B 多模态 MNN 模型承担:`generate` 走文本, -/// `analyze` 把图片拼成 标签交给 Omni 内核 imread 解码(需 OMNI 构建,xcframework 已含)。 -/// 已实测可用,真机走此单模型全包路径;模拟器无 MNN,VL 仍回退 MLX(见 `AIRuntime`)。 -actor MNNBackend { - private var bridge: MNNLLMBridge? - - /// 末次生成统计(供 AIRuntime 在流结束后取走,性能自检用)。 - private(set) var lastStats: GenerateStats? - - private func record(_ s: GenerateStats) { lastStats = s } - - 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 { - 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) { - let stats = box.value.generateText(prompt, maxTokens: Int32(maxTokens)) { piece in - let rate = meter.tick() - continuation.yield(TokenChunk(text: piece, decodeRate: rate)) - } - // ObjC 统计对象先抽成 Sendable 的 GenerateStats 再跨 actor 记录。 - await self.record(GenerateStats( - promptTokens: Int(stats.promptTokens), - genTokens: Int(stats.genTokens), - prefillSeconds: stats.prefillMs / 1000.0, - decodeSeconds: stats.decodeMs / 1000.0 - )) - continuation.finish() - } - continuation.onTermination = { _ in - box.value.cancel() - task.cancel() - } - } - } - - /// 图→文(VL)。一次性收集(JSON 抽取不需流式)。桥接里把图片路径拼成 标签, - /// 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 { - let stats = try box.value.analyzeImages(paths, prompt: prompt, maxTokens: Int32(maxTokens)) { piece in - sink.append(piece) - } - await self.record(GenerateStats( - promptTokens: Int(stats.promptTokens), - genTokens: Int(stats.genTokens), - prefillSeconds: stats.prefillMs / 1000.0, - decodeSeconds: stats.decodeMs / 1000.0 - )) - 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: @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 - } -} diff --git a/康康/Features/Capture/UnifiedCaptureFlow.swift b/康康/Features/Capture/UnifiedCaptureFlow.swift index e0cf29b..2d1265f 100644 --- a/康康/Features/Capture/UnifiedCaptureFlow.swift +++ b/康康/Features/Capture/UnifiedCaptureFlow.swift @@ -298,8 +298,7 @@ struct UnifiedCaptureFlow: View { } } -/// 跨 detached 边界安全携带 UIImage 数组:图片只读、不并发改,封装免 Sendable 报错 -/// (同 MNNBackend.MNNUncheckedBox 思路)。 +/// 跨 detached 边界安全携带 UIImage 数组:图片只读、不并发改,封装免 Sendable 报错。 private struct UncheckedImageBox: @unchecked Sendable { let images: [UIImage] } diff --git a/康康/Features/Me/InferenceSettingsView.swift b/康康/Features/Me/InferenceSettingsView.swift index d09f9c7..f3b9fed 100644 --- a/康康/Features/Me/InferenceSettingsView.swift +++ b/康康/Features/Me/InferenceSettingsView.swift @@ -1,10 +1,9 @@ import SwiftUI -/// 推理引擎设置:本项目已切 Gemma-3n(端侧 4bit),主模型只走 MLX/GPU。 -/// MNN/SME2 路径已停用(Gemma-3n 无 MNN 转换模型),引擎行保留但 MNN 置灰。 -/// 切换只改持久化选择;下一次 AI 调用(prepare/generate)按新引擎加载。 +/// 推理引擎设置:本项目主模型 Gemma-3n(端侧 4bit),**端侧统一走 MLX/GPU**。 +/// MNN/SME2 LLM 引擎已移除(Gemma-3n 无 MNN 转换模型),不再提供引擎选择—— +/// 端侧只此一种,页面改为展示当前端侧后端 + 性能自检 + 云端 AI 开关。 struct InferenceSettingsView: View { - @AppStorage("kk.inferenceEngine") private var engineRaw = EnginePreference.auto.rawValue // 云端 AI(Gemini)开关与 key —— 键名与 CloudAI 对齐,@AppStorage 写入即被后端读到。 @AppStorage("cloud_ai_gemini_enabled") private var cloudEnabled = false @AppStorage("cloud_ai_gemini_key") private var cloudKey = "" @@ -12,10 +11,6 @@ struct InferenceSettingsView: View { /// 性能自检改为当前页就地展开,不再 push 新页面。 @State private var showSelfTest = false - private var selected: EnginePreference { - EnginePreference(rawValue: engineRaw) ?? .auto - } - /// 性能自检需要主模型(Gemma-3n,MLX)就绪。 private var modelReady: Bool { modelService.states[.llm]?.phase == .ready @@ -33,9 +28,7 @@ struct InferenceSettingsView: View { .padding(.top, 4) .padding(.bottom, 6) - ForEach(EnginePreference.allCases, id: \.self) { engine in - engineRow(engine) - } + localEngineCard selfTestSection cloudSection @@ -48,6 +41,35 @@ struct InferenceSettingsView: View { .onAppear { modelService.refreshStates() } } + /// 端侧后端信息卡(只此一种,不可切换):MLX · Metal GPU,端侧 Gemma-3n E2B。 + private var localEngineCard: some View { + HStack(spacing: 12) { + ZStack { + Circle().fill(Tj.Palette.amber.opacity(0.25)) + Image(systemName: "bolt.fill") + .font(.tjScaled(18)) + .foregroundStyle(Tj.Palette.ink) + } + .frame(width: 44, height: 44) + + VStack(alignment: .leading, spacing: 2) { + Text("MLX · GPU") + .font(.tjScaled(15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text("Metal GPU · 端侧推理 Gemma-3n E2B") + .font(.tjScaled(12)) + .foregroundStyle(Tj.Palette.text3) + .lineLimit(2) + } + Spacer() + Image(systemName: "checkmark.circle.fill") + .font(.tjScaled(18)) + .foregroundStyle(Tj.Palette.leaf) + } + .padding(14) + .tjCard() + } + /// 性能自检入口:它是「动作/工具」而非引擎选择,所以做成描边动作按钮(TjGhostButton), /// 从视觉类别上和上方引擎/状态卡区分开。模型未就绪时禁用并给出下载提示而非死链。 @ViewBuilder @@ -97,75 +119,6 @@ struct InferenceSettingsView: View { } } - private func engineRow(_ engine: EnginePreference) -> some View { - let available = isAvailable(engine) - let isOn = (selected == engine) - return Button { - guard available else { return } - engineRaw = engine.rawValue - } label: { - HStack(spacing: 12) { - ZStack { - Circle().fill(isOn ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2) - Image(systemName: iconName(engine)) - .font(.tjScaled(18)) - .foregroundStyle(isOn ? Tj.Palette.ink : Tj.Palette.text2) - } - .frame(width: 44, height: 44) - - VStack(alignment: .leading, spacing: 2) { - Text(engine.displayName) - .font(.tjScaled(15, weight: .semibold)) - .foregroundStyle(Tj.Palette.text) - Text(subtitle(engine, available: available)) - .font(.tjScaled(12)) - .foregroundStyle(Tj.Palette.text3) - .lineLimit(2) - } - Spacer() - if isOn { - Image(systemName: "checkmark.circle.fill") - .font(.tjScaled(18)) - .foregroundStyle(Tj.Palette.leaf) - } - } - .padding(14) - .tjCard() - .opacity(available ? 1 : 0.45) - } - .buttonStyle(.plain) - .disabled(!available) - } - - /// .auto 永远可用;MLX 看自身可用性。MNN 已停用(Gemma-3n 无 MNN 模型),恒置灰。 - private func isAvailable(_ engine: EnginePreference) -> Bool { - switch engine { - case .auto: return true - case .mnn: return false - case .mlx: return InferenceEngine.mlx.isAvailable - } - } - - private func iconName(_ engine: EnginePreference) -> String { - switch engine { - case .auto: return "wand.and.stars" - case .mnn: return "cpu.fill" - case .mlx: return "bolt.fill" - } - } - - private func subtitle(_ engine: EnginePreference, available: Bool) -> String { - switch engine { - case .auto: - // 已切 Gemma-3n,auto 恒解析为 MLX。 - return String(appLoc: "按本机配置选择 · 当前 MLX · GPU") - case .mnn: - return String(appLoc: "已停用:Gemma-3n 无 MNN 转换模型,统一走 MLX") - case .mlx: - return String(appLoc: "Metal GPU · 端侧推理 Gemma-3n E2B") - } - } - private var cloudConfigured: Bool { cloudEnabled && !cloudKey.trimmingCharacters(in: .whitespaces).isEmpty } diff --git a/康康/Features/Me/MeView.swift b/康康/Features/Me/MeView.swift index cf6c493..71494f3 100644 --- a/康康/Features/Me/MeView.swift +++ b/康康/Features/Me/MeView.swift @@ -167,12 +167,8 @@ struct MeView: View { .buttonStyle(.plain) } - private var engineDetail: String { - switch InferenceEngine.current { - case .mnn: return InferenceEngine.cpuSupportsSME2 ? "MNN · SME2" : "MNN · CPU" - case .mlx: return "MLX · GPU" - } - } + /// 端侧统一走 MLX/GPU(Gemma-3n E2B);MNN/SME2 LLM 引擎已移除。 + private var engineDetail: String { "MLX · GPU" } private var languageCard: some View { NavigationLink { diff --git a/康康/Features/Me/ModelSelfTestView.swift b/康康/Features/Me/ModelSelfTestView.swift index 8da8749..00b7308 100644 --- a/康康/Features/Me/ModelSelfTestView.swift +++ b/康康/Features/Me/ModelSelfTestView.swift @@ -1,7 +1,7 @@ import SwiftUI -/// 性能自检:跑固定 prompt,展示当前后端(MNN·SME2 / MNN·NEON / MLX·GPU)的 -/// prefill / decode 实测速度,并按后端存档对比 —— 挑战赛考核点的可见证据(§12 卖点 2/6)。 +/// 性能自检:跑固定 prompt,展示当前端侧后端(MLX·GPU,Gemma-3n E2B)的 +/// prefill / decode 实测速度,并按后端存档对比 —— 端侧推理性能的可见证据(§12 卖点 2/6)。 struct ModelSelfTestView: View { @State private var output = "" @State private var phase: Phase = .idle diff --git a/康康/Features/Trends/TrendDetailView.swift b/康康/Features/Trends/TrendDetailView.swift index 11db1f7..1528f97 100644 --- a/康康/Features/Trends/TrendDetailView.swift +++ b/康康/Features/Trends/TrendDetailView.swift @@ -507,7 +507,7 @@ private struct TrendInsightCard: View { text = try await TrendInsightService.shared.generate(for: bucket) } catch { // 区分「模型没下载」与「下载了但这次推理没成功」,前者给下载引导(CLAUDE.md §4)。 - let downloaded = ModelStore.shared.isComplete(for: .mnnLLM) || ModelStore.shared.isComplete(for: .llm) + let downloaded = ModelStore.shared.isComplete(for: .llm) failedMessage = downloaded ? String(appLoc: "本地推理这次没成功,点右上「解读」重试") : String(appLoc: "AI 解读需先在「我的 · 模型管理」下载模型") diff --git a/康康/Services/BenchmarkService.swift b/康康/Services/BenchmarkService.swift index 4137c54..bdbe517 100644 --- a/康康/Services/BenchmarkService.swift +++ b/康康/Services/BenchmarkService.swift @@ -1,6 +1,6 @@ import Foundation -/// 单次性能自检结果。按后端标签归档,供「MNN·SME2 vs MLX·GPU」对比展示(§12 卖点 2/6)。 +/// 单次性能自检结果。按后端标签归档,供端侧(MLX·GPU)与云端(Gemini)对比展示(§12 卖点 2/6)。 struct BenchmarkResult: Codable, Equatable { var backendLabel: String var promptTokens: Int diff --git a/康康/Services/DiaryAssistService.swift b/康康/Services/DiaryAssistService.swift index 920d189..3ff899a 100644 --- a/康康/Services/DiaryAssistService.swift +++ b/康康/Services/DiaryAssistService.swift @@ -65,7 +65,7 @@ struct DiaryAssistService { let prompt = DiaryAssistPrompts.suggest(content: content, coveredDimensions: coveredDimensions) - // 低温采样下 MNN 仍偶发吐非 JSON / 漏掉外层 {"questions":…} 包裹(换 MNN 后比 MLX 更常见)。 + // 端侧小模型低温采样下偶发吐非 JSON / 漏掉外层 {"questions":…} 包裹。 // 首次解析不出就自动重试一次,两次都失败才报错 —— 守 §10.5「失败回退,不让用户卡在 AI 错误屏」。 var lastRate: Double = 0 var parsedButEmpty = false diff --git a/康康/康康-Bridging-Header.h b/康康/康康-Bridging-Header.h index 9ae6c86..2644495 100644 --- a/康康/康康-Bridging-Header.h +++ b/康康/康康-Bridging-Header.h @@ -3,5 +3,4 @@ // 把 Objective-C 接口暴露给 Swift。 // -#import "AI/MNN/MNNLLMBridge.h" #import "AI/MNN/SenseVoiceBridge.h"