Files
Termind/TFM/Features/Settings/ModelProviderEditorSheet.swift

239 lines
9.2 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPresentation
@Observable
final class ModelProviderEditorSession {
var secret = ""
var name = ""
var endpoint = ProviderEndpoint()
var projectID = ""
var location = ""
var error: String?
private var didBegin = false
func begin(with provider: ModelProvider, in store: AIConfigurationModel, isNew: Bool = false) {
guard !didBegin else { return }
didBegin = true
name = isNew ? "" : provider.name
endpoint = isNew ? ProviderEndpoint() : store.endpoint(for: provider.id)
projectID = provider.vertex.projectID
location = isNew ? "" : provider.vertex.location
secret = store.secret(for: provider.credentialRef) ?? ""
}
}
/// Everything about one model provider: where it points, what it authenticates with,
/// and which models it offers.
///
/// Edits write through immediately. Models live on the next page, keeping both
/// jobs full-width and giving each a stable Back path instead of opening a sheet.
struct ModelProviderEditorPage: View {
@Environment(\.locale) private var locale
let modelProviderID: ModelProvider.ID
var isNew = false
let showModels: () -> Void
let deleted: () -> Void
@Environment(AppModel.self) private var app
/// The API key as typed. Held here rather than read back out of the store,
/// because the store hands out secrets through one accessor and a
/// `SecureField` bound straight to it would put the key in the view's
/// identity as well as in its buffer.
@State private var session = ModelProviderEditorSession()
private var provider: ModelProvider? { app.aiConfiguration.modelProvider(modelProviderID) }
var body: some View {
Group {
if let provider {
settings(provider)
} else {
ContentUnavailableView(AppStrings.Settings.providerDeleted.swiftUIKey, systemImage: "trash")
}
}
.onAppear(perform: loadSecrets)
}
private var brokenRoles: [AIRole] {
app.aiConfiguration.roles(using: modelProviderID)
}
private var brokenRolesSummary: String {
let names = brokenRoles.map(\.displayName).formatted(.list(type: .and).locale(locale))
return String(localized: AppStrings.Settings.wouldGoBackToAutomatic(names))
}
private var deleteHelp: Text {
Text(AppStrings.Settings.permanentlyDeletesThisProviderItsKeyAndItsModels)
}
// MARK: - Settings column
private func settings(_ provider: ModelProvider) -> some View {
Form {
ModelProviderConnectionFields(
name: Binding(get: { session.name }, set: saveName),
endpoint: Binding(get: { session.endpoint }, set: saveEndpoint),
isEnabled: Binding(
get: { app.aiConfiguration.modelProvider(modelProviderID)?.isEnabled ?? false },
set: { value in updateProvider { $0.isEnabled = value } }),
showsEndpoint: !provider.usesVertexEndpoint)
if !provider.usesVertexEndpoint { credentialSection }
if session.endpoint.apiType == .generateContent { vertexSection(provider) }
if let error = session.error { Text(error).foregroundStyle(.red) }
Section(AppStrings.Settings.models.swiftUIKey) {
Button(action: showModels) {
HStack {
Label(AppStrings.Settings.manageModels.swiftUIKey, systemImage: "cube")
Spacer()
Text(provider.models.count, format: .number)
.foregroundStyle(.secondary)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
}
.buttonStyle(.plain)
}
Section {
Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) {
app.aiConfiguration.delete(modelProviderID: modelProviderID)
deleted()
}
.help(deleteHelp)
if !brokenRoles.isEmpty {
Text(brokenRolesSummary)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
.frame(maxWidth: .infinity)
}
@ViewBuilder
private var credentialSection: some View {
Section {
RevealableSecretField(
AppStrings.Settings.apiKey,
text: Binding(
get: { session.secret },
set: {
session.secret = $0
store(secret: $0)
}
),
prompt: Text(AppStrings.Common.optional)
)
}
}
// MARK: - Secrets
private func loadSecrets() {
guard let provider else { return }
session.begin(with: provider, in: app.aiConfiguration, isNew: isNew)
}
/// Mints a reference the first time something is typed, and clears the
/// reference entirely when the field is emptied.
///
/// Clearing the ref rather than storing an empty string is what makes
/// `hasCredential` mean what it says: a provider pointing at a Keychain
/// entry that holds nothing is not credentialled, and it should not look
/// like it is.
private func store(secret value: String) {
guard let provider else { return }
do {
try saveCredential(provider, kind: .apiKey, replacement: value.isEmpty ? nil : value)
session.error = nil
} catch { session.error = error.localizedDescription }
}
private func saveName(_ value: String) {
session.name = value
guard var provider else { return }
provider.name = value
app.aiConfiguration.saveProvider(provider, endpoint: app.aiConfiguration.endpoint(for: modelProviderID))
}
private func saveEndpoint(_ value: ProviderEndpoint) {
session.endpoint = value
guard var provider else { return }
if session.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { provider.name = "" }
app.aiConfiguration.saveProvider(provider, endpoint: value)
}
private func vertexSection(_ provider: ModelProvider) -> some View {
ModelProviderVertexFields(
isEnabled: Binding(
get: { provider.usesVertex },
set: { value in
updateProvider { $0.usesVertex = value }
}),
projectID: Binding(
get: { session.projectID },
set: { value in
session.projectID = value
updateProvider { $0.vertex.projectID = value }
}),
location: Binding(
get: { session.location },
set: { value in
session.location = value
updateProvider { $0.vertex.location = value }
}),
clientEmail: provider.vertex.clientEmail, hasCredential: !provider.vertex.credentialRef.isEmpty,
requestURL: ModelProviderForm.hasValidAddress(provider) ? provider.requestURL() : nil,
readFile: { try await ModelProviderServiceAccountFile.read($0) },
importJSON: { json in
let account = try ModelProviderServiceAccount(json: json)
guard var current = self.provider else { return }
current.vertex.clientEmail = account.clientEmail
if current.vertex.projectID.isEmpty { current.vertex.projectID = account.projectID }
try saveCredential(current, kind: .serviceAccount, replacement: account.json)
session.projectID = current.vertex.projectID
},
remove: {
guard var current = self.provider else { return }
current.vertex.clientEmail = ""
try saveCredential(current, kind: .serviceAccount, replacement: nil)
})
}
private func updateProvider(_ change: (inout ModelProvider) -> Void) {
guard var provider else { return }
change(&provider)
app.aiConfiguration.saveProvider(provider, endpoint: app.aiConfiguration.endpoint(for: modelProviderID))
}
private func saveCredential(_ value: ModelProvider, kind: ModelProviderCredentialKind, replacement: String?) throws
{
let ai = app.aiConfiguration
let reference = kind.reference(in: value)
let previous = ai.secret(for: reference)
if !reference.isEmpty, let error = ai.secretStorageError {
throw AIConfigurationModel.CredentialError.message(error)
}
try ai.saveProvider(
value, endpoint: ai.endpoint(for: value.id),
credentials: [
.init(kind: kind, reference: reference, previous: previous, replacement: replacement)
])
}
}