mana-swift-ui/Sources/ManaLLMUI/ManaLLMPrepareSection.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

200 lines
7 KiB
Swift

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