mana-swift-ui/Sources/ManaLLMUI/ManaLLMBackendPickerSection.swift
Till JS 4a3bb780df ManaLLMUI: BYOK-Quelle + Schlüssel-Verwaltung
Natives Pendant zur Web-LlmSourceSettings/ByokKeyManager:
- ManaLLMByokSection — Schlüssel pro Anbieter (OpenAI/Anthropic/Gemini/
  Mistral) hinzufügen/ändern/löschen, Standard-Anbieter-Picker; Vault =
  ByokKeyVault (Keychain, app-privat). Secret nie zurück in die UI.
- ManaLLMSettingsState verwaltet BYOK + reicht den Vault-Resolver in
  makeRouter(), damit .byok in der Availability-Map .available wird.
- ManaLLMSettingsView(showByok:) + Picker includeByok — Apps mit
  On-Device-Versprechen (memoro) blenden BYOK komplett aus.
- Erschöpfende LLMBackendID-Switches um .byok ergänzt (mana-swift-llm
  >=0.3.0 fügte den Case hinzu).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:30:02 +02:00

169 lines
6.7 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
private let includeByok: Bool
/// - Parameters:
/// - includeByok: Nimmt die Quelle `.byok` in den Picker auf.
/// Default `true`. Apps mit On-Device-Datenschutz-Versprechen
/// (z.B. memoro, dessen Transkripte das Gerät bewusst nicht
/// verlassen) setzen `false` — dann taucht BYOK gar nicht erst
/// als wählbare Quelle auf.
public init(
state: ManaLLMSettingsState,
context: ManaLLMContext = .generic,
includeByok: Bool = true
) {
self.state = state
self.context = context
self.includeByok = includeByok
}
/// Sichtbare Quellen — `.byok` nur wenn `includeByok`.
private var visibleBackends: [LLMBackendID] {
LLMBackendID.allCases.filter { includeByok || $0 != .byok }
}
public var body: some View {
Section {
Picker(context.useCaseShort, selection: backendBinding) {
ForEach(visibleBackends, 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"
case .byok: "key.fill"
}
}
private func availabilityShortText(for id: LLMBackendID, status: LLMAvailability) -> String {
// BYOK ohne Schlüssel: konkreter Hinweis auf die Verwaltungs-
// Section statt des generischen „Fehlt: …".
if id == .byok, case .unavailableMissingDependency = status {
return "Schlüssel unten unter „Eigene Schlüssel“ hinterlegen"
}
return 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."
case .byok:
"Aktuell gewählt: Eigener Schlüssel (BYOK). Der Inhalt geht direkt von deinem Gerät "
+ "zum gewählten Anbieter (OpenAI/Anthropic/Gemini/Mistral) mit deinem eigenen "
+ "Schlüssel. mana sieht weder Schlüssel noch Inhalt. Schlüssel verwaltest du "
+ "unten unter „Eigene Schlüssel“."
}
return intro + "\n\n" + modelFacts
}
}