mana-swift-ui/Sources/ManaLLMUI/ManaLLMSettingsState.swift
Till JS 4a3bb780df ManaLLMUI: BYOK-Quelle + Schlüssel-Verwaltung
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>
2026-06-05 12:30:02 +02:00

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)
}
}
}