216 lines
10 KiB
Swift
216 lines
10 KiB
Swift
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<TokenChunk, Error> {
|
||
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` 的最小可解码子集。
|
||
/// nonisolated:工程默认 MainActor 隔离,不标会把 Decodable 合成实现推成 MainActor,
|
||
/// 在 GeminiBackend 的后台解码上下文用不了(Swift 6 报错)。
|
||
private nonisolated struct GeminiResponse: Decodable {
|
||
nonisolated struct Candidate: Decodable { let content: Content? }
|
||
nonisolated struct Content: Decodable { let parts: [Part]? }
|
||
nonisolated struct Part: Decodable { let text: String? }
|
||
nonisolated struct Usage: Decodable {
|
||
let promptTokenCount: Int?
|
||
let candidatesTokenCount: Int?
|
||
}
|
||
nonisolated struct APIError: Decodable { let message: String? }
|
||
let candidates: [Candidate]?
|
||
let usageMetadata: Usage?
|
||
let error: APIError?
|
||
}
|