Natives Pendant zur Web-LlmSourceSettings/ByokKeyManager: - ManaLLMByokSection — Schlüssel pro Anbieter (OpenAI/Anthropic/Gemini/ Mistral) hinzufügen/ändern/löschen, Standard-Anbieter-Picker; Vault = ByokKeyVault (Keychain, app-privat). Secret nie zurück in die UI. - ManaLLMSettingsState verwaltet BYOK + reicht den Vault-Resolver in makeRouter(), damit .byok in der Availability-Map .available wird. - ManaLLMSettingsView(showByok:) + Picker includeByok — Apps mit On-Device-Versprechen (memoro) blenden BYOK komplett aus. - Erschöpfende LLMBackendID-Switches um .byok ergänzt (mana-swift-llm >=0.3.0 fügte den Case hinzu). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
238 lines
9 KiB
Swift
238 lines
9 KiB
Swift
import Foundation
|
|
import ManaLLM
|
|
import SwiftUI
|
|
|
|
/// Geteilter `@Observable`-State für die drei `ManaLLMUI`-Sections.
|
|
/// Apps instantiieren genau einen State und reichen ihn an die Sections
|
|
/// weiter — entweder direkt (`ManaLLMSettingsView()` macht das intern)
|
|
/// oder explizit, wenn nur eine Section benutzt wird.
|
|
///
|
|
/// **Responsibility:** hält UI-State + delegiert Schreib-/Lese-Ops an
|
|
/// die Stores (`LLMBackendPreferenceStore`,
|
|
/// `LLMDownloadOverCellularStore`) und die LLM-Backends (`LLMRouter`).
|
|
/// Views bleiben dünn — nur Bindings + Layout.
|
|
///
|
|
/// **Router-Lifecycle:** Wir instantiieren pro Operation einen frischen
|
|
/// `LLMRouter` (in `makeRouter`), damit der aktuell gewählte
|
|
/// `backend`-Pick und der `allowCellular`-Toggle zur Runtime
|
|
/// honoriert werden. Würde `LLMRouter.shared` nehmen ginge der
|
|
/// App-Wunsch verloren.
|
|
@Observable
|
|
@MainActor
|
|
public final class ManaLLMSettingsState {
|
|
public var backend: LLMBackendID
|
|
public var allowCellular: Bool
|
|
public var availability: [LLMBackendID: LLMAvailability] = [:]
|
|
public var prepareStatus: PrepareStatus = .idle
|
|
public var prepareProgress: Double = 0
|
|
public var prepareError: String?
|
|
public var prepareBytesDone: Int64?
|
|
public var prepareBytesTotal: Int64?
|
|
|
|
// MARK: - BYOK (Bring Your Own Key)
|
|
|
|
//
|
|
// Spiegelt das Web-Pendant (`ByokKeyManager` + `createLlmSettingsStore`):
|
|
// Schlüssel pro Anbieter liegen in der `ByokKeyVault` (Keychain, app-
|
|
// privat), der `byokDefaultProvider` wird in UserDefaults persistiert.
|
|
// Der Vault füttert via `makeRouter()` den `setByokResolver` des
|
|
// Routers — sowohl für die Availability-Anzeige hier als auch (wenn
|
|
// die App denselben Vault in ihren eigenen Router reicht) für echte
|
|
// Calls.
|
|
|
|
/// Keychain-Tresor für die BYOK-Schlüssel. Apps können denselben
|
|
/// Vault auch in ihren eigenen Router reichen.
|
|
public let byokVault: ByokKeyVault
|
|
|
|
/// Anbieter, für die aktuell ein Schlüssel hinterlegt ist.
|
|
public var byokStoredProviders: [ByokProviderID] = []
|
|
|
|
/// Wunsch-Anbieter, wenn ein Call keinen angibt (persistiert).
|
|
public var byokDefaultProvider: ByokProviderID?
|
|
|
|
public init(byokVault: ByokKeyVault = ByokKeyVault()) {
|
|
self.byokVault = byokVault
|
|
backend = LLMBackendPreferenceStore.current
|
|
allowCellular = LLMDownloadOverCellularStore.isAllowed
|
|
let stored = byokVault.storedProviders()
|
|
byokStoredProviders = stored
|
|
byokDefaultProvider = ByokDefaultProviderStore.current ?? stored.first
|
|
}
|
|
|
|
/// Setzt das Backend und persistiert es. Resettet den Prepare-State,
|
|
/// weil die alte Anzeige (z.B. "ready" für Apple FM) auf das neue
|
|
/// Backend nicht mehr stimmt.
|
|
public func setBackend(_ id: LLMBackendID) {
|
|
guard id != backend else { return }
|
|
backend = id
|
|
LLMBackendPreferenceStore.set(id)
|
|
prepareStatus = .idle
|
|
prepareProgress = 0
|
|
prepareError = nil
|
|
prepareBytesDone = nil
|
|
prepareBytesTotal = nil
|
|
}
|
|
|
|
public func setAllowCellular(_ value: Bool) {
|
|
guard value != allowCellular else { return }
|
|
allowCellular = value
|
|
LLMDownloadOverCellularStore.set(value)
|
|
}
|
|
|
|
/// Re-Fetcht den Availability-Status aller Backends. Sollte
|
|
/// regelmäßig getriggert werden (z.B. `.task` auf der View, oder
|
|
/// nach `prepare`/`removeCachedModel`).
|
|
public func refreshAvailability() async {
|
|
availability = await makeRouter().availabilityMap()
|
|
}
|
|
|
|
/// Lädt/initialisiert das aktuell gewählte Backend. Für Apple FM
|
|
/// effektiv ein Status-Check; für Gemma der HF-Download.
|
|
public func prepare() async {
|
|
prepareStatus = .preparing
|
|
prepareError = nil
|
|
prepareProgress = 0
|
|
prepareBytesDone = nil
|
|
prepareBytesTotal = nil
|
|
let instance = await makeRouter().backend(for: backend)
|
|
do {
|
|
try await instance.prepare { update in
|
|
Task { @MainActor in
|
|
// Monotone Updates — out-of-order-Callbacks dürfen
|
|
// die Anzeige nicht zurückspringen lassen.
|
|
if update.fractionCompleted >= self.prepareProgress {
|
|
self.prepareProgress = update.fractionCompleted
|
|
}
|
|
if let done = update.bytesCompleted,
|
|
done >= (self.prepareBytesDone ?? 0)
|
|
{
|
|
self.prepareBytesDone = done
|
|
}
|
|
if let total = update.bytesTotal {
|
|
self.prepareBytesTotal = total
|
|
}
|
|
}
|
|
}
|
|
prepareStatus = .ready
|
|
prepareProgress = 1.0
|
|
await refreshAvailability()
|
|
} catch {
|
|
prepareStatus = .failed
|
|
prepareError = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
|
}
|
|
}
|
|
|
|
/// Löscht den lokalen Modell-Cache des aktuell gewählten Backends.
|
|
/// Backends ohne Caller-Cache (Apple FM, NoOp) sind No-Op.
|
|
public func removeCachedModel() async {
|
|
let instance = await makeRouter().backend(for: backend)
|
|
try? await instance.removeCachedModel()
|
|
prepareStatus = .idle
|
|
prepareProgress = 0
|
|
prepareError = nil
|
|
await refreshAvailability()
|
|
}
|
|
|
|
/// `true` wenn das aktuelle Backend cached ist (== verfügbar).
|
|
public var currentBackendIsCached: Bool {
|
|
switch availability[backend] ?? .unknown("") {
|
|
case .available: true
|
|
default: false
|
|
}
|
|
}
|
|
|
|
/// `true` wenn das aktuelle Backend einen Prepare-Schritt braucht
|
|
/// (heute: nur Gemma-Varianten). BYOK + Apple FM + NoOp brauchen
|
|
/// keinen Download.
|
|
public var currentBackendNeedsPrepare: Bool {
|
|
switch backend {
|
|
case .gemmaE2B, .gemmaE4B: true
|
|
case .appleFM, .noOp, .byok: false
|
|
}
|
|
}
|
|
|
|
// MARK: - BYOK-Mutationen
|
|
|
|
/// Legt den Schlüssel eines Anbieters ab (überschreibt vorhandenen)
|
|
/// und aktualisiert Availability + Stored-Liste. Leerer Key = No-Op.
|
|
public func saveByokKey(provider: ByokProviderID, apiKey: String, model: String) {
|
|
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
let resolvedModel = model.trimmingCharacters(in: .whitespaces).isEmpty
|
|
? provider.defaultModel
|
|
: model
|
|
try? byokVault.save(
|
|
ByokSelection(provider: provider, apiKey: trimmed, model: resolvedModel)
|
|
)
|
|
byokStoredProviders = byokVault.storedProviders()
|
|
if byokDefaultProvider == nil { setByokDefaultProvider(provider) }
|
|
Task { await refreshAvailability() }
|
|
}
|
|
|
|
/// Entfernt den Schlüssel eines Anbieters. Rückt den Default-Anbieter
|
|
/// nach, wenn der gelöschte der Default war.
|
|
public func deleteByokKey(_ provider: ByokProviderID) {
|
|
byokVault.delete(provider)
|
|
byokStoredProviders = byokVault.storedProviders()
|
|
if byokDefaultProvider == provider {
|
|
setByokDefaultProvider(byokStoredProviders.first)
|
|
}
|
|
Task { await refreshAvailability() }
|
|
}
|
|
|
|
/// Hinterlegtes Modell eines Anbieters (für den Editor-Prefill).
|
|
/// Gibt **nicht** den Schlüssel preis.
|
|
public func byokModel(for provider: ByokProviderID) -> String? {
|
|
byokVault.load(provider)?.model
|
|
}
|
|
|
|
/// Setzt den Wunsch-Anbieter und persistiert ihn.
|
|
public func setByokDefaultProvider(_ provider: ByokProviderID?) {
|
|
byokDefaultProvider = provider
|
|
ByokDefaultProviderStore.set(provider)
|
|
Task { await refreshAvailability() }
|
|
}
|
|
|
|
/// Frischer Router für die aktuelle Auswahl. Reicht — wenn Schlüssel
|
|
/// hinterlegt sind — den Vault-Resolver durch, damit `.byok` in der
|
|
/// Availability-Map als `.available` erscheint.
|
|
private func makeRouter() async -> LLMRouter {
|
|
let router = LLMRouter(
|
|
preferred: [backend],
|
|
gemmaAllowsCellular: allowCellular
|
|
)
|
|
if !byokStoredProviders.isEmpty {
|
|
await router.setByokResolver(
|
|
byokVault.makeResolver(defaultProvider: byokDefaultProvider),
|
|
defaultProvider: byokDefaultProvider
|
|
)
|
|
}
|
|
return router
|
|
}
|
|
|
|
public enum PrepareStatus: Equatable, Sendable {
|
|
case idle
|
|
case preparing
|
|
case ready
|
|
case failed
|
|
}
|
|
}
|
|
|
|
/// Persistiert den BYOK-Wunsch-Anbieter (UserDefaults). Web-Pendant:
|
|
/// `byokDefaultProvider` im `createLlmSettingsStore`.
|
|
enum ByokDefaultProviderStore {
|
|
private static let key = "ev.mana.byok.defaultProvider"
|
|
|
|
static var current: ByokProviderID? {
|
|
guard let raw = UserDefaults.standard.string(forKey: key) else { return nil }
|
|
return ByokProviderID(rawValue: raw)
|
|
}
|
|
|
|
static func set(_ provider: ByokProviderID?) {
|
|
if let provider {
|
|
UserDefaults.standard.set(provider.rawValue, forKey: key)
|
|
} else {
|
|
UserDefaults.standard.removeObject(forKey: key)
|
|
}
|
|
}
|
|
}
|