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>
144 lines
5.4 KiB
Swift
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
|
|
}
|
|
}
|