mana-swift-ui/Sources/ManaLLMUI/ManaLLMSettingsState.swift
Till JS ad9dc1abba v0.8.0 — feat(llm-ui): neues Library-Product ManaLLMUI
Drop-in-Settings-UI für die lokalen LLM-Backends aus mana-swift-llm.
Pendant zu ManaAuthUI — vorher hatte nur Memoro die UI handgeschrieben,
die drei anderen Konsumenten (pageta, comicello, herbatrium) gar nichts.

Komponenten:
- ManaLLMSettingsView(context:) — Convenience-Wrapper, drei Sections
- ManaLLMBackendPickerSection — Picker + Availability + Empfohlen-Badge
- ManaLLMPrepareSection — Download/Init-Card mit Progress, gated für Gemma
- ManaLLMDownloadPolicySection — WiFi-only-Toggle
- ManaLLMSettingsState (@Observable, @MainActor) — geteilter State,
  delegiert an Stores aus mana-swift-llm 0.2.0
- ManaLLMContext(useCaseShort:useCaseLong:) — app-spezifischer
  Section-Text; .generic als Fallback

Test-Target ManaLLMUITests bewusst noch nicht angelegt (Linter hat
es aus Package.swift entfernt, Comment markiert TODO).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 14:19:58 +02:00

141 lines
5 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?
public init() {
self.backend = LLMBackendPreferenceStore.current
self.allowCellular = LLMDownloadOverCellularStore.isAllowed
}
/// 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).
public var currentBackendNeedsPrepare: Bool {
switch backend {
case .gemmaE2B, .gemmaE4B: true
case .appleFM, .noOp: false
}
}
private func makeRouter() -> LLMRouter {
LLMRouter(
preferred: [backend],
gemmaAllowsCellular: allowCellular
)
}
public enum PrepareStatus: Equatable, Sendable {
case idle
case preparing
case ready
case failed
}
}