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:
parent
d621cb8372
commit
ad9dc1abba
9 changed files with 679 additions and 0 deletions
48
CHANGELOG.md
48
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
]
|
||||
)
|
||||
|
|
|
|||
14
Sources/ManaLLMUI/ByteFormatter.swift
Normal file
14
Sources/ManaLLMUI/ByteFormatter.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
144
Sources/ManaLLMUI/ManaLLMBackendPickerSection.swift
Normal file
144
Sources/ManaLLMUI/ManaLLMBackendPickerSection.swift
Normal 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
|
||||
}
|
||||
}
|
||||
33
Sources/ManaLLMUI/ManaLLMContext.swift
Normal file
33
Sources/ManaLLMUI/ManaLLMContext.swift
Normal 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"
|
||||
)
|
||||
}
|
||||
35
Sources/ManaLLMUI/ManaLLMDownloadPolicySection.swift
Normal file
35
Sources/ManaLLMUI/ManaLLMDownloadPolicySection.swift
Normal 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.3–2.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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
200
Sources/ManaLLMUI/ManaLLMPrepareSection.swift
Normal file
200
Sources/ManaLLMUI/ManaLLMPrepareSection.swift
Normal 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 "…"
|
||||
}
|
||||
}
|
||||
141
Sources/ManaLLMUI/ManaLLMSettingsState.swift
Normal file
141
Sources/ManaLLMUI/ManaLLMSettingsState.swift
Normal 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
|
||||
}
|
||||
}
|
||||
43
Sources/ManaLLMUI/ManaLLMSettingsView.swift
Normal file
43
Sources/ManaLLMUI/ManaLLMSettingsView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue