diff --git a/CHANGELOG.md b/CHANGELOG.md index 38ce2c9..8edfcf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,54 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an ## [Unreleased] +## [0.8.0] — 2026-05-22 + +Minor — **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) hatten +gar nichts. + +### Hintergrund + +Vier Apps konsumieren `ManaLLM` (Memoro, Pageta, Comicello, Herbatrium). +Memoros 250-Zeilen-LLM-UI (Picker + Prepare + Cellular-Toggle) war +strukturell shared-fähig — wurde rausgehoben, generalisiert und steht +jetzt allen Apps zur Verfügung. + +### Neu + +- `ManaLLMUI`-Product (deps: `ManaLLM`, `ManaLLMShared`) +- `ManaLLMSettingsView` — Convenience-Wrapper, drei Sections in + einem Schwung +- `ManaLLMBackendPickerSection` — Picker mit Availability-Status + + Empfohlen-Badge +- `ManaLLMPrepareSection` — Download/Init-Card mit Progress, nur + sichtbar für Gemma-Backends (`shouldShow(for:)`-Gate) +- `ManaLLMDownloadPolicySection` — WiFi-only-Toggle +- `ManaLLMSettingsState` — `@Observable`-State-Klasse, hält + Backend-Wahl + Availability + Prepare-Progress + delegiert an die + Stores aus `mana-swift-llm` +- `ManaLLMContext` — App-spezifischer Kontext (`useCaseShort` + + `useCaseLong`) für Section-Texte. `.generic` als Fallback + +### Migration + +Apps die heute eigene LLM-Settings-UI haben (Memoro): +- `import ManaLLM` für die Settings-View durch `import ManaLLMUI` ersetzen +- Lokale `llmSection`/`llmPrepareSection`/`llmDownloadPolicySection`- + Bodies entfernen +- `ManaLLMSettingsView(context: ...)` einhängen +- `LLMBackendPreferenceStore` + `LLMDownloadOverCellularStore` bleiben + funktionsgleich — wandern aber nach `ManaLLM` (siehe + `mana-swift-llm` CHANGELOG) + +Apps die heute keine LLM-Settings-UI haben (Pageta, Comicello, +Herbatrium): +- `ManaLLMUI`-Product in `project.yml` adden +- `ManaLLMSettingsView(context: ...)` an passender Stelle einhängen + (eigene Settings-View oder NavigationLink im Profile-Tab) + ## [0.7.0] — 2026-05-22 Minor — **`logoAssetName`** in `ManaBrandConfig`. Apps können jetzt diff --git a/Package.swift b/Package.swift index 7b28ec9..9602174 100644 --- a/Package.swift +++ b/Package.swift @@ -11,12 +11,18 @@ let package = Package( products: [ .library(name: "ManaAuthUI", targets: ["ManaAuthUI"]), .library(name: "ManaWebShell", targets: ["ManaWebShell"]), + .library(name: "ManaLLMUI", targets: ["ManaLLMUI"]), ], dependencies: [ // Lokaler Dev-Pfad. Apps konsumieren beide Pakete parallel über // `path: ../mana-swift-core` bzw. `path: ../mana-swift-ui`. // Release-Wechsel auf `from: "1.1.0"` kommt mit Phase 4. .package(path: "../mana-swift-core"), + // ManaLLMUI baut auf den Backend-Schicht aus mana-swift-llm. + // Apps die nur die Backends headless brauchen, importieren + // weiter direkt `ManaLLM` — ManaLLMUI ist additiv für die + // Settings-Schicht (Picker, Prepare, Cellular-Toggle). + .package(path: "../mana-swift-llm"), ], targets: [ .target( @@ -37,6 +43,17 @@ let package = Package( .enableExperimentalFeature("StrictConcurrency"), ] ), + .target( + name: "ManaLLMUI", + dependencies: [ + .product(name: "ManaLLM", package: "mana-swift-llm"), + .product(name: "ManaLLMShared", package: "mana-swift-llm"), + ], + path: "Sources/ManaLLMUI", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), + ] + ), .testTarget( name: "ManaAuthUITests", dependencies: ["ManaAuthUI"], @@ -47,5 +64,9 @@ let package = Package( dependencies: ["ManaWebShell"], path: "Tests/ManaWebShellTests" ), + // ManaLLMUITests: deklariert, aber `Tests/ManaLLMUITests/` + // wurde nie angelegt — SPM verweigert Resolve. Entfernt + // 2026-05-22, wieder einfügen sobald die ersten ViewModel- + // Tests stehen. ] ) diff --git a/Sources/ManaLLMUI/ByteFormatter.swift b/Sources/ManaLLMUI/ByteFormatter.swift new file mode 100644 index 0000000..73f9929 --- /dev/null +++ b/Sources/ManaLLMUI/ByteFormatter.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Schmaler interner Helper für Bytes-Anzeige in der UI. Bewusst kein +/// `public`, weil das nicht Teil der ManaLLMUI-API ist — Apps sollen +/// ihren eigenen `ByteCountFormatter` haben, wenn sie Bytes +/// formatieren müssen. +enum ByteFormatter { + static func string(fromByteCount bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useGB, .useMB] + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } +} diff --git a/Sources/ManaLLMUI/ManaLLMBackendPickerSection.swift b/Sources/ManaLLMUI/ManaLLMBackendPickerSection.swift new file mode 100644 index 0000000..d4a9d0c --- /dev/null +++ b/Sources/ManaLLMUI/ManaLLMBackendPickerSection.swift @@ -0,0 +1,144 @@ +import ManaLLM +import SwiftUI + +/// Picker über alle `LLMBackendID.allCases` mit Verfügbarkeits-Status, +/// "Empfohlen"-Badge, Icon-Mapping und Backend-spezifischem Footer. +/// +/// Schreibt bei Auswahl in `LLMBackendPreferenceStore` (via State). +/// Sortiert nicht — `LLMBackendID.allCases`-Reihenfolge ist die UX- +/// Reihenfolge (noOp → appleFM → gemmaE2B → gemmaE4B). +public struct ManaLLMBackendPickerSection: View { + @Bindable private var state: ManaLLMSettingsState + private let context: ManaLLMContext + + public init( + state: ManaLLMSettingsState, + context: ManaLLMContext = .generic + ) { + self.state = state + self.context = context + } + + public var body: some View { + Section { + Picker(context.useCaseShort, selection: backendBinding) { + ForEach(LLMBackendID.allCases, id: \.self) { id in + row(for: id).tag(id) + } + } + .pickerStyle(.inline) + .labelsHidden() + } header: { + Text(context.useCaseShort) + } footer: { + Text(footerText) + .font(.caption2) + } + } + + // MARK: - Binding (Picker schreibt durch state.setBackend) + + private var backendBinding: Binding { + Binding( + get: { state.backend }, + set: { state.setBackend($0) } + ) + } + + // MARK: - Row + + @ViewBuilder + private func row(for id: LLMBackendID) -> some View { + let availability = state.availability[id] ?? .unknown("checking") + let isSelectable = availability.isSelectable || id == .noOp + HStack(spacing: 10) { + Image(systemName: icon(for: id)) + .frame(width: 22) + .foregroundStyle(isSelectable ? .primary : .secondary) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(id.displayName) + .foregroundStyle(isSelectable ? .primary : .secondary) + if isRecommended(id) { + Text("Empfohlen") + .font(.caption2.weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.accentColor.opacity(0.15), in: Capsule()) + .foregroundStyle(Color.accentColor) + } + } + Text(availabilityShortText(for: id, status: availability)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Empfehlung + + /// `gemmaE2B` ist "Empfohlen", wenn Apple FM auf diesem Gerät nicht + /// verfügbar ist — der natürliche On-Device-Fallback für User ohne + /// Apple Intelligence. + private func isRecommended(_ id: LLMBackendID) -> Bool { + guard id == .gemmaE2B else { return false } + let appleStatus = state.availability[.appleFM] ?? .unknown("") + return appleStatus != .available + } + + private func icon(for id: LLMBackendID) -> String { + switch id { + case .noOp: "text.alignleft" + case .appleFM: "apple.logo" + case .gemmaE2B, .gemmaE4B: "g.circle" + } + } + + private func availabilityShortText(for id: LLMBackendID, status: LLMAvailability) -> String { + switch status { + case .available: + id == .noOp ? "Immer verfügbar" : "Bereit" + case let .requiresDownload(bytes): + "Lädt \(ByteFormatter.string(fromByteCount: bytes)) beim ersten Use" + case let .downloading(fraction): + "Lädt … \(Int(fraction * 100)) %" + case .unavailableDeviceNotEligible: + "Gerät zu alt" + case .unavailableAppleIntelligenceNotEnabled: + "Apple Intelligence in iOS-Settings aktivieren" + case .unavailableModelNotReady: + "Modell lädt im Hintergrund …" + case .unavailableOSTooOld: + "iOS 26+ erforderlich" + case let .unavailableMissingDependency(name): + "Fehlt: \(name)" + case let .unknown(detail): + "Status: \(detail)" + } + } + + // MARK: - Footer + + private var footerText: String { + // App-Kontext + Modell-Fakten getrennt formuliert. So bleibt der + // Modell-Beschreibungs-Block backend-agnostisch und der + // App-Kontext eine Zeile davor. + let intro = "Die App nutzt das gewählte Modell für: \(context.useCaseLong)." + let modelFacts = switch state.backend { + case .noOp: + "Aktuell gewählt: Kein LLM (Fallback). Der erste Satz des Eingabe-Texts wird " + + "als Überschrift verwendet. Schnell, keine KI, kein Download." + case .appleFM: + "Aktuell gewählt: Apple Foundation Models (~3 B). Kein Download, ANE-beschleunigt. " + + "Funktioniert nur auf Geräten mit Apple Intelligence (iPhone 15 Pro+, M-iPads). " + + "4096 Token Window." + case .gemmaE2B: + "Aktuell gewählt: Gemma 4 E2B (Apache 2.0). Lädt einmalig ~1.3 GB von Hugging Face. " + + "Läuft auf iPhone 14 Pro+ und allen M-iPads. 256 K Token Window." + case .gemmaE4B: + "Aktuell gewählt: Gemma 4 E4B (Apache 2.0). Lädt einmalig ~2.5 GB. Bessere Qualität " + + "als E2B, braucht iPhone 15 Pro+ oder M-iPad. 256 K Token Window." + } + return intro + "\n\n" + modelFacts + } +} diff --git a/Sources/ManaLLMUI/ManaLLMContext.swift b/Sources/ManaLLMUI/ManaLLMContext.swift new file mode 100644 index 0000000..60e1c4d --- /dev/null +++ b/Sources/ManaLLMUI/ManaLLMContext.swift @@ -0,0 +1,33 @@ +import Foundation + +/// App-spezifischer Kontext für die Section-Texte. Apps reichen ein +/// `ManaLLMContext` rein, und die Sections erweitern ihre Footer- +/// und Subtitle-Texte um diesen Kontext. +/// +/// **Beispiele:** +/// - Memoro: `ManaLLMContext(useCaseShort: "Headline + Intro", useCaseLong: "destilliert lange Audio-Transkripte in kurze Texte")` +/// - Pageta: `ManaLLMContext(useCaseShort: "Artikel-Zusammenfassung", useCaseLong: "fasst Artikel in zwei Sätze zusammen")` +/// - Comicello: `ManaLLMContext(useCaseShort: "Story-Synopsis", useCaseLong: "generiert eine kurze Synopsis zu einer Story")` +/// - Herbatrium: `ManaLLMContext(useCaseShort: "Pflanzen-Notizen", useCaseLong: "verdichtet Pflegenotizen in eine kurze Zusammenfassung")` +/// +/// Wenn nicht gesetzt (`nil`), nutzen die Sections generische Texte. +public struct ManaLLMContext: Equatable, Sendable { + /// Kurzer Label-Text, taucht z.B. als Section-Header auf + /// ("KI-Modell für Headline + Intro"). + public let useCaseShort: String + + /// Längerer Erklärtext, taucht im Section-Footer auf + /// ("Die App nutzt das Modell für: ."). + public let useCaseLong: String + + public init(useCaseShort: String, useCaseLong: String) { + self.useCaseShort = useCaseShort + self.useCaseLong = useCaseLong + } + + /// Generischer Fallback wenn die App keinen Kontext mitgibt. + public static let generic = ManaLLMContext( + useCaseShort: "Lokale KI", + useCaseLong: "Texte lokal zusammenfasst, klassifiziert oder generiert" + ) +} diff --git a/Sources/ManaLLMUI/ManaLLMDownloadPolicySection.swift b/Sources/ManaLLMUI/ManaLLMDownloadPolicySection.swift new file mode 100644 index 0000000..20b27b6 --- /dev/null +++ b/Sources/ManaLLMUI/ManaLLMDownloadPolicySection.swift @@ -0,0 +1,35 @@ +import ManaLLM +import SwiftUI + +/// WiFi-only-Default-Toggle für Modell-Downloads. Apps die nur Apple FM +/// und NoOp anbieten brauchen die Section nicht — der Toggle hat dann +/// keine Wirkung. `ManaLLMSettingsView` zeigt die Section trotzdem, +/// weil der Default-Pool aller Backends Gemma enthält. +public struct ManaLLMDownloadPolicySection: View { + @Bindable private var state: ManaLLMSettingsState + + public init(state: ManaLLMSettingsState) { + self.state = state + } + + public var body: some View { + Section { + Toggle("Modelle auch über Mobilfunk laden", isOn: cellularBinding) + } header: { + Text("Modell-Download") + } footer: { + Text( + "Standard: nur über WLAN. Gemma-Modelle sind 1.3–2.5 GB groß — " + + "über Mobilfunk verbraucht das spürbar Datenvolumen." + ) + .font(.caption2) + } + } + + private var cellularBinding: Binding { + Binding( + get: { state.allowCellular }, + set: { state.setAllowCellular($0) } + ) + } +} diff --git a/Sources/ManaLLMUI/ManaLLMPrepareSection.swift b/Sources/ManaLLMUI/ManaLLMPrepareSection.swift new file mode 100644 index 0000000..fe6d063 --- /dev/null +++ b/Sources/ManaLLMUI/ManaLLMPrepareSection.swift @@ -0,0 +1,200 @@ +import ManaLLM +import SwiftUI + +/// Status-Card mit Spinner/Check/Fail, "Modell laden" / "Modell +/// entfernen" und linearer Progress-Bar mit Byte-Anzeige. Nur sinnvoll +/// für Backends die einen Caller-Cache haben — heute Gemma. Apple FM +/// und NoOp brauchen die Section nicht (`shouldShow` ist `false`). +/// +/// Apps die `ManaLLMSettingsView` benutzen kriegen die Section +/// automatisch (mit Sichtbarkeits-Gate). Wer die Section solo +/// einhängt, sollte den Gate selber abfragen: +/// +/// ```swift +/// if ManaLLMPrepareSection.shouldShow(for: state) { +/// ManaLLMPrepareSection(state: state) +/// } +/// ``` +public struct ManaLLMPrepareSection: View { + @Bindable private var state: ManaLLMSettingsState + + public init(state: ManaLLMSettingsState) { + self.state = state + } + + /// Sichtbarkeits-Gate. Heute: nur für Gemma-Backends Section + /// rendern. Apple FM hat kein Caller-Cache, NoOp trivialerweise + /// auch nicht. + public static func shouldShow(for state: ManaLLMSettingsState) -> Bool { + state.currentBackendNeedsPrepare + } + + public var body: some View { + Section { + HStack(spacing: 12) { + statusIcon + VStack(alignment: .leading, spacing: 2) { + Text(statusTitle) + .font(.subheadline.weight(.medium)) + Text(statusSubtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + if state.prepareStatus == .preparing { + downloadProgress + } + if state.prepareStatus != .preparing { + Button { + Task { await state.prepare() } + } label: { + Label( + state.prepareStatus == .ready ? "Erneut prüfen" : "Modell laden", + systemImage: "arrow.down.circle" + ) + } + if state.currentBackendIsCached { + Button(role: .destructive) { + Task { await state.removeCachedModel() } + } label: { + Label("Modell entfernen", systemImage: "trash") + } + } + } + if let error = state.prepareError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } header: { + Text(headerText) + } footer: { + // Bei App-Group-Cache: alle teilnehmenden Apps lesen + // dasselbe Modell. Das gehört in den User-sichtbaren + // Footer, weil 'Modell entfernen' eben auch das Modell + // für andere mana-Apps entfernt. + Text( + "Gemma-Modelle liegen im geteilten App-Group-Container " + + "(group.ev.mana.models). Andere mana-Apps können dasselbe Modell " + + "ohne zweiten Download nutzen — und 'Modell entfernen' " + + "entfernt es auch dort." + ) + .font(.caption2) + } + } + + // MARK: - Header + + private var headerText: String { + switch state.backend { + case .appleFM: "Apple Foundation Models" + case .gemmaE2B, .gemmaE4B: "Gemma-Modell" + case .noOp: "Modell" + } + } + + // MARK: - Status + + private var statusIcon: some View { + Group { + switch state.prepareStatus { + case .idle: + Image(systemName: "questionmark.circle").foregroundStyle(.secondary) + case .preparing: + ProgressView().controlSize(.small) + case .ready: + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + case .failed: + Image(systemName: "exclamationmark.circle.fill").foregroundStyle(.red) + } + } + .font(.title3) + .frame(width: 28) + } + + private var statusTitle: String { + switch state.prepareStatus { + case .idle: + switch state.availability[state.backend] ?? .unknown("") { + case .available: "Bereit" + case .requiresDownload: "Noch nicht geladen" + default: "Status prüfen" + } + case .preparing: "Wird vorbereitet …" + case .ready: "Bereit" + case .failed: "Fehler" + } + } + + private var statusSubtitle: String { + switch state.backend { + case .appleFM: + "Apple verwaltet das Modell automatisch — kein manuelles Vorladen nötig." + case .gemmaE2B: + "Gemma 4 E2B 4-bit von mlx-community auf Hugging Face. ~1.3 GB." + case .gemmaE4B: + "Gemma 4 E4B 4-bit von mlx-community auf Hugging Face. ~2.5 GB." + case .noOp: + "" + } + } + + // MARK: - Progress + + /// Drei Anzeigemodi je nach Datenlage: + /// 1. `fractionCompleted > 0` → linearer Balken + Prozent + Bytes + /// 2. `bytesCompleted > 0` (aber Fraction 0) → linearer Balken aus + /// Bytes-Verhältnis + Bytes-Text + /// 3. Sonst → indeterminierter Spinner + "Verbinde mit Hugging Face …" + /// Damit User nie ein totes "0 %" sieht, während der Download + /// in Wirklichkeit schon Metadaten zieht. + @ViewBuilder + private var downloadProgress: some View { + let byteFraction: Double? = { + guard let total = state.prepareBytesTotal, total > 1, + let done = state.prepareBytesDone, done > 0 + else { return nil } + return Double(done) / Double(total) + }() + let effectiveFraction = state.prepareProgress > 0 ? state.prepareProgress : (byteFraction ?? 0) + VStack(alignment: .leading, spacing: 4) { + if effectiveFraction > 0 { + ProgressView(value: min(effectiveFraction, 1.0)) + .progressViewStyle(.linear) + } else { + ProgressView() + .progressViewStyle(.linear) + } + HStack { + Text(progressLabel) + .font(.caption2) + .foregroundStyle(.secondary) + Spacer() + Text(progressValue(effectiveFraction: effectiveFraction)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + } + + private var progressLabel: String { + if let done = state.prepareBytesDone, done > 0 { + return "Lade von Hugging Face …" + } + return "Verbinde mit Hugging Face …" + } + + private func progressValue(effectiveFraction: Double) -> String { + if let done = state.prepareBytesDone, + let total = state.prepareBytesTotal, total > 1 + { + return "\(ByteFormatter.string(fromByteCount: done)) / " + + "\(ByteFormatter.string(fromByteCount: total))" + } + if effectiveFraction > 0 { + return "\(Int(effectiveFraction * 100)) %" + } + return "…" + } +} diff --git a/Sources/ManaLLMUI/ManaLLMSettingsState.swift b/Sources/ManaLLMUI/ManaLLMSettingsState.swift new file mode 100644 index 0000000..d6bc37f --- /dev/null +++ b/Sources/ManaLLMUI/ManaLLMSettingsState.swift @@ -0,0 +1,141 @@ +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 + } +} diff --git a/Sources/ManaLLMUI/ManaLLMSettingsView.swift b/Sources/ManaLLMUI/ManaLLMSettingsView.swift new file mode 100644 index 0000000..05cdfb8 --- /dev/null +++ b/Sources/ManaLLMUI/ManaLLMSettingsView.swift @@ -0,0 +1,43 @@ +import ManaLLM +import SwiftUI + +/// Drop-in-Komposition aus den drei Sections: BackendPicker, Prepare +/// (sichtbar nur für Gemma-Backends), DownloadPolicy. +/// +/// **Typische Nutzung:** +/// +/// ```swift +/// // In der Settings-Form der App: +/// ManaLLMSettingsView( +/// context: ManaLLMContext( +/// useCaseShort: "Artikel-Zusammenfassung", +/// useCaseLong: "fasst Artikel in zwei Sätze zusammen" +/// ) +/// ) +/// ``` +/// +/// Apps die feinere Kontrolle wollen (z.B. zwischen den Sections eine +/// app-eigene Section einschieben), nutzen die granularen +/// `ManaLLM*Section`-Views direkt und teilen sich einen explizit +/// erzeugten `ManaLLMSettingsState`. +public struct ManaLLMSettingsView: View { + @State private var state = ManaLLMSettingsState() + private let context: ManaLLMContext + + public init(context: ManaLLMContext = .generic) { + self.context = context + } + + public var body: some View { + Group { + ManaLLMBackendPickerSection(state: state, context: context) + if ManaLLMPrepareSection.shouldShow(for: state) { + ManaLLMPrepareSection(state: state) + } + ManaLLMDownloadPolicySection(state: state) + } + .task { + await state.refreshAvailability() + } + } +}