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>
169 lines
6.7 KiB
Swift
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
|
|
}
|
|
}
|