Files
kangkang/康康/AI/GeminiBackend.swift
link2026 198570186e 根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:
```
docs(readme): 更新文档说明

- 添加项目使用说明
- 完善配置指南
- 修正错误描述
```
2026-07-14 13:07:15 +08:00

216 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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?
}