Files
Termind/TFI/Features/Settings/MobileAISettingsView.swift
T

498 lines
21 KiB
Swift

import AgentKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPresentation
struct MobileAISettingsView: View {
let app: MobileAppModel
var body: some View {
List {
ForEach(MobileAISettingsDestination.allCases) { destination in
NavigationLink {
destinationView(destination)
} label: {
Label(destination.title, systemImage: destination.symbol)
}
}
}
.navigationTitle("AI")
}
@ViewBuilder
private func destinationView(_ destination: MobileAISettingsDestination) -> some View {
switch destination {
case .models:
MobileModelsSettingsView(ai: app.ai)
case .memory:
MemorySettingsSection(memory: app.memory, ai: app.ai, layout: .page).navigationTitle("Hermes Memory")
case .skills:
MobileSkillSettings(skills: app.skills)
case .mcp:
MobileMCPSettings(ai: app.ai, pro: app.pro)
}
}
}
enum MobileAISettingsDestination: String, CaseIterable, Identifiable {
case models
case skills
case mcp
case memory
var id: Self { self }
var title: LocalizedStringKey {
switch self {
case .models: "Models"
case .skills: "Skills"
case .memory: "Hermes Memory"
case .mcp: "MCP"
}
}
var symbol: String {
switch self {
case .models: "cube"
case .skills: "doc.text"
case .memory: "brain"
case .mcp: "network"
}
}
}
struct MobileModelsSettingsView: View {
let ai: AIConfigurationModel
@State private var editing: ModelProvider?
var body: some View {
List {
if ai.modelProviders.isEmpty {
ContentUnavailableView(
"No Providers", systemImage: "brain",
description: Text("Add a provider to configure AI models.")
)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} else {
Section("Model Providers") {
ForEach(ai.modelProviders) { provider in
Button {
editing = provider
} label: {
HStack {
Image(systemName: "brain").foregroundStyle(.secondary).accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text(provider.name).foregroundStyle(.primary)
Text(provider.requestURL()).font(.caption).foregroundStyle(.secondary)
.lineLimit(1).truncationMode(.middle)
Text("\(provider.models.count) Models").font(.caption).foregroundStyle(.secondary)
if !provider.isEnabled {
Text("Disabled").font(.caption).foregroundStyle(.secondary)
}
}.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "chevron.right").font(.caption.weight(.semibold)).foregroundStyle(
.tertiary)
}.contentShape(.rect)
}
.buttonStyle(.plain)
.contextMenu {
Button("Edit", systemImage: "pencil") { editing = provider }
Button("Delete", systemImage: "trash", role: .destructive) {
ai.delete(modelProviderID: provider.id)
}
}
.swipeActions {
Button("Delete", systemImage: "trash", role: .destructive) {
ai.delete(modelProviderID: provider.id)
}
}
}
}
}
ModelRoleSettings(ai: ai)
if let error = ai.storageError ?? ai.secretStorageError { Text(error).foregroundStyle(.red) }
}
.navigationTitle("Models")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Add Provider", systemImage: "plus") { editing = ModelProvider(name: "") }
.labelStyle(.iconOnly)
}
}
.sheet(item: $editing) { provider in MobileProviderEditor(ai: ai, provider: provider) }
}
}
struct MobileSecretFields: View {
@Bindable var key: MobileSecretDraft
var isToken = false
var hasExistingCredential = true
var requiresCredential = false
var body: some View {
Section(isToken ? "Bearer Token" : "API Key") {
if hasExistingCredential {
Picker("Credential", selection: $key.action) {
Text(isToken ? "Keep Existing Token" : "Keep Existing Key").tag(
MobileSecretDraft.Action.preserve)
Text(isToken ? "Replace Token" : "Replace Key").tag(MobileSecretDraft.Action.replace)
Text(isToken ? "Clear Token" : "Clear Key").tag(MobileSecretDraft.Action.clear)
}
if key.action == .replace {
RevealableSecretField(isToken ? "Bearer Token" : "API Key", text: $key.text)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.multilineTextAlignment(.leading)
}
} else {
RevealableSecretField(
isToken ? "Bearer Token" : "API Key",
text: Binding(
get: { key.text },
set: { value in
key.text = value
key.action = value.isEmpty && !requiresCredential ? .preserve : .replace
}
),
prompt: Text(requiresCredential ? "Required" : "Optional")
)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.multilineTextAlignment(.leading)
}
}
}
}
struct MobileProviderEditor: View {
let ai: AIConfigurationModel
@State private var model: MobileProviderEditorModel
@State private var discovery = MobileModelDiscovery()
@State private var probes = MobileModelTests()
private enum Sheet: Identifiable {
case fetch
case add
case edit(AIModel)
var id: String {
switch self {
case .fetch: "fetch"
case .add: "add"
case .edit(let model): "edit-\(model.id)"
}
}
}
@State private var presentedSheet: Sheet?
@State private var query = ""
@State private var error: String?
init(ai: AIConfigurationModel, provider: ModelProvider) {
self.ai = ai
_model = State(initialValue: MobileProviderEditorModel(ai: ai, provider: provider))
}
var body: some View {
MobileEditorSheet(
title: String(localized: "Model Provider"), isDirty: model.isDirty, canSave: model.canSave,
onCancel: { model.discardPlaintext() },
save: { try model.save() },
content: {
ModelProviderConnectionFields(
name: $model.draft.name, endpoint: $model.endpoint, isEnabled: $model.draft.isEnabled,
showsEndpoint: !model.requestProvider.usesVertexEndpoint
)
.textInputAutocapitalization(.never)
if !model.requestProvider.usesVertexEndpoint {
MobileSecretFields(key: model.key, hasExistingCredential: model.hasExistingKey)
}
if model.endpoint.apiType == .generateContent {
ModelProviderVertexFields(
isEnabled: $model.draft.usesVertex, projectID: $model.draft.vertex.projectID,
location: $model.draft.vertex.location, clientEmail: model.draft.vertex.clientEmail,
hasCredential: model.hasServiceAccount,
requestURL: ModelProviderForm.hasValidAddress(model.requestProvider)
? model.requestProvider.requestURL() : nil,
readFile: { try await ModelProviderServiceAccountFile.read($0) },
importJSON: model.importServiceAccount, remove: model.removeServiceAccount)
}
modelSection.buttonStyle(.borderless)
if let error { Text(error).foregroundStyle(.red) }
}
)
.sheet(item: $presentedSheet) { sheet in
switch sheet {
case .fetch:
MobileModelFetchSheet(discovery: discovery, existing: Set(model.draft.models.map(\.id)), fetch: fetch) {
values in
model.add(values)
model.draft.modelsFetchedAt = .now
}
case .add:
MobileAddModelSheet(existing: Set(model.draft.models.map(\.id))) { model.add(id: $0) }
case .edit(let value):
MobileModelEditorSheet(model: value, provider: model.requestProvider) { model.update($0) }
}
}
.onChange(of: model.requestProvider) { _, _ in cancelRequests() }
.onChange(of: model.key.action) { _, _ in cancelRequests() }
.onChange(of: model.key.text) { _, _ in cancelRequests() }
.onChange(of: model.serviceAccount.action) { _, _ in cancelRequests() }
.onChange(of: model.serviceAccount.text) { _, _ in cancelRequests() }
.onChange(of: model.endpoint.apiType) { _, type in
if type != .generateContent { model.draft.usesVertex = false }
}
.onChange(of: ai.modelProvider(model.original.id)) { _, _ in cancelRequests() }
.onDisappear { cancelRequests() }
}
private var modelSection: some View {
Section("Models") {
TextField("Filter", text: $query.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
.textFieldStyle(.plain)
.multilineTextAlignment(.leading)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
ForEach(
model.draft.models.filter {
query.isEmpty || $0.id.localizedCaseInsensitiveContains(query)
|| $0.label.localizedCaseInsensitiveContains(query)
}
) { value in
VStack(alignment: .leading, spacing: 8) {
Button {
presentedSheet = .edit(value)
} label: {
HStack {
Image(systemName: "cube").foregroundStyle(.secondary).accessibilityHidden(true)
VStack(alignment: .leading) {
Text(value.label)
Text(value.id).font(.caption.monospaced()).foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "chevron.right").font(.caption.weight(.semibold))
.foregroundStyle(.tertiary).accessibilityHidden(true)
}.contentShape(.rect)
}
.buttonStyle(.plain)
.contextMenu {
Button("Test", systemImage: "play") { test([value]) }.disabled(probes.running)
Button("Delete", systemImage: "trash", role: .destructive) { remove([value.id]) }
}
if let result = probes.results[value.id] { Text(result).font(.caption).textSelection(.enabled) }
}
}
if model.draft.models.isEmpty {
Text("No Models Available").foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Button {
presentedSheet = .fetch
} label: {
Label("Fetch", systemImage: "arrow.down.circle").frame(maxWidth: .infinity, minHeight: 44)
}.disabled(!model.canSave || !model.requestProvider.canFetchModels)
Button {
presentedSheet = .add
} label: {
Label("Add", systemImage: "plus").frame(maxWidth: .infinity, minHeight: 44)
}
}.buttonStyle(.bordered)
}
}
private func remove(_ ids: Set<String>) { model.remove(ids) }
private func fetch() {
do { discovery.start(provider: model.requestProvider, secret: try model.requestSecret()) } catch {
discovery.error = error.localizedDescription
}
}
private func test(_ values: [AIModel]) {
do {
error = nil
probes.start(models: values, provider: model.requestProvider, secret: try model.requestSecret())
} catch { self.error = error.localizedDescription }
}
private func cancelRequests() {
discovery.cancel()
probes.cancel()
}
}
struct MobileModelEditorSheet: View {
let original: AIModel
let provider: ModelProvider
let save: (AIModel) -> Void
@State private var draft: AIModel
init(model: AIModel, provider: ModelProvider, save: @escaping (AIModel) -> Void) {
original = model
self.provider = provider
self.save = save
_draft = State(initialValue: model)
}
var body: some View {
MobileEditorSheet(
title: String(localized: "Model"), isDirty: draft != original,
save: { save(draft) },
content: { MobileModelFields(model: $draft, provider: provider) }
)
}
}
struct MobileModelFetchSheet: View {
let discovery: MobileModelDiscovery
let existing: Set<String>
let fetch: () -> Void
let add: ([AIModel]) -> Void
@Environment(\.dismiss) private var dismiss
@State private var selected = Set<String>()
@State private var query = ""
private var filtered: [AIModel] {
discovery.candidates.filter {
query.isEmpty || $0.id.localizedCaseInsensitiveContains(query)
|| $0.label.localizedCaseInsensitiveContains(query)
}
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
if discovery.running {
Spacer()
ProgressView()
Spacer()
} else if let error = discovery.error {
Spacer()
Text(error).foregroundStyle(.secondary).multilineTextAlignment(.center).padding()
Button("Retry", systemImage: "arrow.clockwise") {
selected = []
fetch()
}.buttonStyle(.bordered)
Spacer()
} else {
HStack {
Text("\(discovery.candidates.count) Models Offered").font(.caption).foregroundStyle(.secondary)
Spacer()
Menu {
Button("Select All", systemImage: "checkmark.circle") {
selected.formUnion(filtered.map(\.id).filter { !existing.contains($0) })
}
Button("Select None", systemImage: "circle") { selected.subtract(filtered.map(\.id)) }
Button("Fetch", systemImage: "arrow.down.circle") {
selected = []
fetch()
}
} label: {
MobileOverflowMenuLabel("Available Models").frame(minWidth: 44, minHeight: 44)
.contentShape(.rect)
}
}.padding(.horizontal)
if filtered.isEmpty {
ContentUnavailableView("No Models Available", systemImage: "cube")
} else {
List(filtered) { candidate in
Button {
if !selected.insert(candidate.id).inserted { selected.remove(candidate.id) }
} label: {
HStack(spacing: 12) {
Image(systemName: "cube").foregroundStyle(.secondary).accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
Text(candidate.label).foregroundStyle(.primary)
if candidate.label != candidate.id {
Text(candidate.id).font(.caption.monospaced()).foregroundStyle(.secondary)
}
}.frame(maxWidth: .infinity, alignment: .leading)
if existing.contains(candidate.id) {
Text("Added").font(.caption).foregroundStyle(.secondary)
} else if selected.contains(candidate.id) {
Image(systemName: "checkmark").font(.body.weight(.semibold))
.accessibilityHidden(true)
}
}.frame(minHeight: 36).contentShape(.rect)
}
.buttonStyle(.plain)
.disabled(existing.contains(candidate.id))
.accessibilityAddTraits(selected.contains(candidate.id) ? [.isSelected] : [])
}.listStyle(.plain)
}
}
}
.searchable(text: $query.singleLineText, prompt: "Filter")
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.background(Color(uiColor: .systemBackground))
.toolbarBackground(Color(uiColor: .systemBackground), for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.navigationTitle("Available Models").navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", systemImage: "xmark") { dismiss() }.labelStyle(.iconOnly)
}
ToolbarItem(placement: .confirmationAction) {
Button("Add", systemImage: "plus") {
add(discovery.candidates.filter { selected.contains($0.id) })
dismiss()
}
.labelStyle(.iconOnly).disabled(selected.isEmpty || discovery.running)
.accessibilityLabel(Text("Add Selected"))
}
}
}
.mobileSheetPresentation(compact: true)
.presentationBackground(Color(uiColor: .systemBackground))
.task { fetch() }
.onDisappear { discovery.cancel() }
}
}
struct MobileAddModelSheet: View {
let existing: Set<String>
let add: (String) -> Void
@State private var modelID = ""
@FocusState private var focused: Bool
@Environment(\.dismiss) private var dismiss
private var trimmedID: String { modelID.trimmingCharacters(in: .whitespacesAndNewlines) }
var body: some View {
NavigationStack {
Form {
Section("Model ID") {
TextField("Model ID", text: $modelID.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(
1
)
.textFieldStyle(.plain).multilineTextAlignment(.leading)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.focused($focused).onSubmit(submit)
if existing.contains(trimmedID) { Text("Added").foregroundStyle(.secondary) }
}
}
.scrollContentBackground(.hidden)
.background(Color(uiColor: .systemGroupedBackground))
.toolbarBackground(Color(uiColor: .systemGroupedBackground), for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.navigationTitle("Add Model").navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", systemImage: "xmark") { dismiss() }.labelStyle(.iconOnly)
}
ToolbarItem(placement: .confirmationAction) {
Button("Add", systemImage: "plus", action: submit).labelStyle(.iconOnly).disabled(
trimmedID.isEmpty || existing.contains(trimmedID))
}
}
}
.mobileSheetPresentation(compact: true)
.presentationBackground(Color(uiColor: .systemBackground))
.task { focused = true }
}
private func submit() {
guard !trimmedID.isEmpty, !existing.contains(trimmedID) else { return }
add(trimmedID)
dismiss()
}
}