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

168 lines
6.2 KiB
Swift

import ManaLLM
import SwiftUI
/// Verwaltet die BYOK-Schlüssel (ein Eintrag pro Anbieter) — natives
/// Pendant zum Web-`ByokKeyManager`. Schlüssel landen in der
/// `ByokKeyVault` (Keychain, app-privat, keine Cross-Device-Sync); der
/// Inhalt eines Calls geht direkt vom Gerät zum Anbieter, der Schlüssel
/// verlässt das Gerät nie Richtung mana.
///
/// Wird typischerweise unter dem `ManaLLMBackendPickerSection` gezeigt
/// (siehe `ManaLLMSettingsView`). Sobald mindestens ein Schlüssel
/// hinterlegt ist, wird die Quelle „Eigener Schlüssel (BYOK)" im Picker
/// auswählbar.
///
/// **Datenschutz-Disziplin:** BYOK schickt Inhalte zu Dritt-SaaS. Die
/// Regel „sensible Inhalte per Default NICHT über BYOK" setzt die App
/// durch (siehe `mana/docs/COMPLIANCE.md` §5) — diese Section verwaltet
/// nur die Schlüssel.
public struct ManaLLMByokSection: View {
@Bindable private var state: ManaLLMSettingsState
@State private var editingProvider: ByokProviderID?
@State private var draftKey: String = ""
@State private var draftModel: String = ""
public init(state: ManaLLMSettingsState) {
self.state = state
}
public var body: some View {
Section {
ForEach(ByokProviderID.allCases, id: \.self) { provider in
row(for: provider)
}
if state.byokStoredProviders.count > 1 {
defaultProviderPicker
}
} header: {
Text("Eigene Schlüssel (BYOK)")
} footer: {
Text(
"Hinterlege deinen eigenen API-Schlüssel eines Anbieters. Der Call geht direkt "
+ "von deinem Gerät zum Anbieter — der Schlüssel verlässt dein Gerät nie "
+ "Richtung mana. Wähle danach oben „Eigener Schlüssel (BYOK)“ als Quelle. "
+ "Schlüssel liegen in der Geräte-Keychain (app-privat, keine iCloud-Sync)."
)
.font(.caption2)
}
}
// MARK: - Provider-Zeile
@ViewBuilder
private func row(for provider: ByokProviderID) -> some View {
let isStored = state.byokStoredProviders.contains(provider)
let isEditing = editingProvider == provider
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Image(systemName: isStored ? "key.fill" : "key")
.frame(width: 22)
.foregroundStyle(isStored ? Color.accentColor : .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(provider.displayName)
Text(statusText(provider: provider, isStored: isStored))
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
if isStored {
Button(role: .destructive) {
if isEditing { closeEditor() }
state.deleteByokKey(provider)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.accessibilityLabel("Schlüssel löschen")
}
Button {
isEditing ? closeEditor() : openEditor(provider)
} label: {
Image(systemName: isEditing ? "chevron.up" : (isStored ? "pencil" : "plus"))
}
.buttonStyle(.borderless)
.accessibilityLabel(isStored ? "Schlüssel ändern" : "Schlüssel hinzufügen")
}
if isEditing {
editor(for: provider, isStored: isStored)
}
}
.padding(.vertical, 2)
}
private func statusText(provider: ByokProviderID, isStored: Bool) -> String {
guard isStored else { return "Kein Schlüssel" }
let model = state.byokModel(for: provider) ?? provider.defaultModel
return "Hinterlegt · \(model)"
}
// MARK: - Editor
private func editor(for provider: ByokProviderID, isStored: Bool) -> some View {
VStack(alignment: .leading, spacing: 10) {
SecureField(
isStored ? "Neuen Schlüssel eingeben (ersetzt vorhandenen)" : "API-Schlüssel",
text: $draftKey
)
.textFieldStyle(.roundedBorder)
#if os(iOS)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
#endif
Picker("Modell", selection: $draftModel) {
ForEach(provider.availableModels, id: \.self) { model in
Text(model).tag(model)
}
}
HStack {
Button("Speichern") {
state.saveByokKey(provider: provider, apiKey: draftKey, model: draftModel)
closeEditor()
}
.buttonStyle(.borderedProminent)
.disabled(draftKey.trimmingCharacters(in: .whitespaces).isEmpty)
Button("Abbrechen") { closeEditor() }
.buttonStyle(.bordered)
}
.font(.callout)
}
.padding(.top, 4)
}
// MARK: - Standard-Anbieter
private var defaultProviderPicker: some View {
Picker("Standard-Anbieter", selection: defaultBinding) {
ForEach(state.byokStoredProviders, id: \.self) { provider in
Text(provider.displayName).tag(provider)
}
}
}
private var defaultBinding: Binding<ByokProviderID> {
Binding(
get: { state.byokDefaultProvider ?? state.byokStoredProviders.first ?? .openai },
set: { state.setByokDefaultProvider($0) }
)
}
// MARK: - Editor-Lifecycle
/// Öffnet den Editor für einen Anbieter. Prefillt das Modell (nicht
/// den Schlüssel — Secrets werden nie zurück in die UI geschrieben).
private func openEditor(_ provider: ByokProviderID) {
editingProvider = provider
draftKey = ""
draftModel = state.byokModel(for: provider) ?? provider.defaultModel
}
private func closeEditor() {
editingProvider = nil
draftKey = ""
draftModel = ""
}
}