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>
This commit is contained in:
Till JS 2026-05-22 14:19:58 +02:00
parent d621cb8372
commit ad9dc1abba
9 changed files with 679 additions and 0 deletions

View file

@ -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

View file

@ -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.
]
)

View file

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

View file

@ -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<LLMBackendID> {
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
}
}

View file

@ -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: <useCaseLong>.").
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"
)
}

View file

@ -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.32.5 GB groß — "
+ "über Mobilfunk verbraucht das spürbar Datenvolumen."
)
.font(.caption2)
}
}
private var cellularBinding: Binding<Bool> {
Binding(
get: { state.allowCellular },
set: { state.setAllowCellular($0) }
)
}
}

View file

@ -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 ""
}
}

View file

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

View file

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