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

144 lines
5.4 KiB
Swift

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