import Foundation /// 云端 AI(Google Gemini)配置。**隐私优先:默认关闭**,用户在「我的 · 云端 AI」显式开启并填入 /// AI Studio 的 API key 后才会启用。key 优先取 UserDefaults(用户填写),其次 Info.plist /// `GEMINI_API_KEY` / 环境变量(开发期注入),三处都没有则视为未配置。 /// /// 设计取舍:demo 阶段用 AI Studio 直发 API key 的 REST 方案,零新增 SPM 依赖、即时可编译; /// 生产级应升级到 Firebase AI Logic(App Check 防盗用、不在客户端裸存 key、内建 hybrid), /// 见 `docs/release` 的接入说明。 nonisolated enum CloudAI { private static let enabledKey = "cloud_ai_gemini_enabled" private static let apiKeyKey = "cloud_ai_gemini_key" private static let modelKey = "cloud_ai_gemini_model" /// 默认模型:快、便宜、原生多模态(文本+图像)。可在设置覆盖。 static let defaultModel = "gemini-2.5-flash" /// 用户是否开启云端增强(默认 false —— 不上云是默认态)。 static var isEnabled: Bool { get { UserDefaults.standard.bool(forKey: enabledKey) } set { UserDefaults.standard.set(newValue, forKey: enabledKey) } } /// Gemini API key。用户填写优先,其次构建期注入(Info.plist / 环境变量)。 static var apiKey: String? { get { if let k = UserDefaults.standard.string(forKey: apiKeyKey), !k.trimmingCharacters(in: .whitespaces).isEmpty { return k } if let k = Bundle.main.object(forInfoDictionaryKey: "GEMINI_API_KEY") as? String, !k.isEmpty { return k } if let k = ProcessInfo.processInfo.environment["GEMINI_API_KEY"], !k.isEmpty { return k } return nil } set { UserDefaults.standard.set(newValue, forKey: apiKeyKey) } } static var model: String { get { UserDefaults.standard.string(forKey: modelKey) ?? defaultModel } set { UserDefaults.standard.set(newValue, forKey: modelKey) } } /// 云端是否可用:已开启 + 有 key。具体网络可达性由调用方在失败时回退端侧。 static var isConfigured: Bool { isEnabled && apiKey != nil } } enum GeminiError: Error, LocalizedError { case notConfigured case http(Int, String) case decode(String) var errorDescription: String? { switch self { case .notConfigured: return String(appLoc: "云端 AI 未配置(请在「我的 · 云端 AI」开启并填入 key)") case .http(let c, let m): return String(appLoc: "Gemini 请求失败(\(c)):\(m)") case .decode(let m): return String(appLoc: "Gemini 响应解析失败:\(m)") } } } /// Google Gemini 云端后端。经 REST(URLSession)调用 Generative Language API: /// - `generate`:`streamGenerateContent`(SSE 流式),用于「云端深度解读 / 多语言」。 /// - `analyze`:`generateContent`(多模态),把报告/药盒图片直传 Gemini 读出结构化结果—— /// 恢复端侧 Gemma 4(MLX 文本版)丢掉的真·视觉能力(§拍照→结构化)。 /// /// 云端调用不占本机显存,**不进 AIRuntime 的 OOM 闸门**,可与端侧推理并发。 actor GeminiBackend { private let endpointBase = "https://generativelanguage.googleapis.com/v1beta/models" private(set) var lastStats: GenerateStats? // MARK: - 流式文本生成 /// 流式生成。返回流被取消时内部 Task 取消、连带断开底层连接。 func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream { AsyncThrowingStream { continuation in let task = Task { do { guard let key = CloudAI.apiKey else { throw GeminiError.notConfigured } let model = CloudAI.model var req = URLRequest(url: URL(string: "\(endpointBase)/\(model):streamGenerateContent?alt=sse&key=\(key)")!) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try Self.requestBody(textParts: [prompt], imageParts: [], maxTokens: maxTokens) let (bytes, response) = try await URLSession.shared.bytes(for: req) if let http = response as? HTTPURLResponse, http.statusCode != 200 { var body = "" for try await line in bytes.lines { body += line } throw GeminiError.http(http.statusCode, String(body.prefix(300))) } let start = Date() var firstAt: Date? var produced = 0 var usage: GeminiResponse.Usage? for try await line in bytes.lines { if Task.isCancelled { break } guard line.hasPrefix("data:") else { continue } let payload = line.dropFirst(5).trimmingCharacters(in: .whitespaces) guard !payload.isEmpty, payload != "[DONE]", let data = payload.data(using: .utf8) else { continue } let chunk = try? JSONDecoder().decode(GeminiResponse.self, from: data) if let u = chunk?.usageMetadata { usage = u } let text = chunk?.candidates?.first?.content?.parts? .compactMap(\.text).joined() ?? "" guard !text.isEmpty else { continue } if firstAt == nil { firstAt = Date() } produced += 1 let elapsed = Date().timeIntervalSince(firstAt ?? start) let rate = elapsed > 0 ? Double(produced) / elapsed : 0 continuation.yield(TokenChunk(text: text, decodeRate: rate)) } // 归一统计:prefill = 首 chunk 前耗时;decode = 其后耗时;token 数取 usageMetadata。 let ttf = (firstAt ?? Date()).timeIntervalSince(start) let total = Date().timeIntervalSince(start) self.lastStats = GenerateStats( promptTokens: usage?.promptTokenCount ?? 0, genTokens: usage?.candidatesTokenCount ?? produced, prefillSeconds: max(ttf, 0.0001), decodeSeconds: max(total - ttf, 0.0001) ) continuation.finish() } catch is CancellationError { continuation.finish(throwing: CancellationError()) } catch { continuation.finish(throwing: error) } } continuation.onTermination = { _ in task.cancel() } } } // MARK: - 多模态(图 → 文) /// 多模态识别:图片 + prompt → 文本(通常是 JSON)。非流式,一次返回。 /// 调用方负责解析 + 失败回退端侧(§3.2)。 func analyze(imageURLs: [URL], prompt: String, maxTokens: Int) async throws -> String { guard let key = CloudAI.apiKey else { throw GeminiError.notConfigured } let model = CloudAI.model var req = URLRequest(url: URL(string: "\(endpointBase)/\(model):generateContent?key=\(key)")!) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try Self.requestBody(textParts: [prompt], imageParts: imageURLs, maxTokens: maxTokens) let (data, response) = try await URLSession.shared.data(for: req) if let http = response as? HTTPURLResponse, http.statusCode != 200 { let body = String(data: data, encoding: .utf8) ?? "" throw GeminiError.http(http.statusCode, String(body.prefix(300))) } let decoded = try JSONDecoder().decode(GeminiResponse.self, from: data) if let msg = decoded.error?.message { throw GeminiError.http(0, msg) } guard let text = decoded.candidates?.first?.content?.parts? .compactMap(\.text).joined(), !text.isEmpty else { throw GeminiError.decode(String(appLoc: "无文本返回")) } if let u = decoded.usageMetadata { self.lastStats = GenerateStats(promptTokens: u.promptTokenCount ?? 0, genTokens: u.candidatesTokenCount ?? 0, prefillSeconds: 0.0001, decodeSeconds: 0.0001) } return text } // MARK: - 请求体 private static func requestBody(textParts: [String], imageParts: [URL], maxTokens: Int) throws -> Data { var parts: [[String: Any]] = textParts.map { ["text": $0] } for url in imageParts { guard let raw = try? Data(contentsOf: url) else { continue } // 控制单图体积,避免请求过大;Vault 原图已是 JPEG。 let mime = url.pathExtension.lowercased() == "png" ? "image/png" : "image/jpeg" parts.append(["inline_data": ["mime_type": mime, "data": raw.base64EncodedString()]]) } let body: [String: Any] = [ "contents": [["role": "user", "parts": parts]], "generationConfig": [ "maxOutputTokens": maxTokens, "temperature": 0.3, "topP": 0.85 ] ] return try JSONSerialization.data(withJSONObject: body) } } /// Gemini `GenerateContentResponse` 的最小可解码子集。 private struct GeminiResponse: Decodable { struct Candidate: Decodable { let content: Content? } struct Content: Decodable { let parts: [Part]? } struct Part: Decodable { let text: String? } struct Usage: Decodable { let promptTokenCount: Int? let candidatesTokenCount: Int? } struct APIError: Decodable { let message: String? } let candidates: [Candidate]? let usageMetadata: Usage? let error: APIError? }