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>
168 lines
6.2 KiB
Swift
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 = ""
|
|
}
|
|
}
|