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

129 lines
4.7 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import SwiftUI
import TermindDomain
import TermindPresentation
/// The "Model Providers" section of the Settings page.
struct ModelProviderSettings: View {
@Environment(AppModel.self) private var app
let edit: (ModelProvider.ID, Bool) -> Void
private var modelProviders: [ModelProvider] { app.aiConfiguration.modelProviders }
var body: some View {
Section(AppStrings.Settings.modelProviders.swiftUIKey) {
ForEach(modelProviders) { provider in
ModelProviderRow(provider: provider) { edit(provider.id, false) }
}
FormNavigationAction(title: AppStrings.Settings.addProvider) {
// Created immediately rather than assembled inside the sheet, so
// there is one code path for "new" and "edit" and no half-built
// provider living in a view's `@State`. Cancelling takes it back
// out again — which is what `isNew` below is for.
let provider = ModelProvider(name: "")
if app.aiConfiguration.saveProvider(provider, endpoint: ProviderEndpoint()) {
edit(provider.id, true)
}
}
if let error = app.aiConfiguration.storageError {
Label(error, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
/// One provider, as a row.
///
/// Reads left to right the way the question is asked: what is it called, what
/// kind is it, is it usable. The toggle sits in the row rather than in the
/// editor because turning a provider off is a thing you do *while looking at the
/// list* — usually to stop a second one's models cluttering a picker.
private struct ModelProviderRow: View {
let provider: ModelProvider
let edit: () -> Void
@Environment(AppModel.self) private var app
var body: some View {
HStack(spacing: Metrics.Space.m) {
Image(systemName: provider.apiFormat.symbol)
.foregroundStyle(provider.isEnabled ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
.frame(width: Metrics.Sidebar.rowIcon)
VStack(alignment: .leading, spacing: 1) {
Text(provider.name)
.lineLimit(1)
Group {
if provider.usesVertexEndpoint {
Text(
ModelProviderForm.hasValidAddress(provider)
? provider.requestURL() : String(localized: AppStrings.Settings.vertexAi))
} else if endpoint.isConfigured {
Text(endpoint.url)
} else {
Text(AppStrings.Settings.needsAUrl)
}
}
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: Metrics.Space.m)
chips
Toggle("", isOn: enabled)
.labelsHidden()
.toggleStyle(.switch)
.controlSize(.mini)
.accessibilityLabel(Text(AppStrings.Settings.enableNamed(provider.name)))
Button(action: edit) {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(AppStrings.Settings.edit(provider.name)))
}
.contentShape(.rect)
// The whole row opens the editor; the toggle and the chevron are
// ordinary controls layered on top and keep their own hit areas.
.onTapGesture(perform: edit)
.help(Text(endpoint.isConfigured ? provider.requestURL() : ""))
}
private var endpoint: ProviderEndpoint {
app.aiConfiguration.endpoint(for: provider.id)
}
private var enabled: Binding<Bool> {
Binding(
get: { provider.isEnabled },
set: { app.aiConfiguration.setModelProviderEnabled($0, for: provider.id) }
)
}
/// Credentials are optional, so model availability determines the status.
@ViewBuilder
private var chips: some View {
CardChip(text: provider.apiFormat.displayName)
if provider.models.isEmpty {
CardChip(text: String(localized: AppStrings.Settings.noModels), symbol: "questionmark.circle")
} else {
CardChip(text: "\(provider.models.count)", symbol: "cube")
}
}
}