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>
141 lines
5 KiB
Swift
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
|
|
}
|
|
}
|