2025-12-07 00:10:35 +01:00
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
|
struct ConfigSettings: View {
|
2025-12-09 18:04:11 +01:00
|
|
|
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
2025-12-20 21:32:06 +01:00
|
|
|
|
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
2025-12-17 19:14:54 +00:00
|
|
|
|
private let state = AppStateStore.shared
|
2025-12-13 15:55:31 +00:00
|
|
|
|
private let labelColumnWidth: CGFloat = 120
|
2025-12-13 19:53:17 +00:00
|
|
|
|
private static let browserAttachOnlyHelp =
|
|
|
|
|
|
"When enabled, the browser server will only connect if the clawd browser is already running."
|
|
|
|
|
|
private static let browserProfileNote =
|
|
|
|
|
|
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
|
|
|
|
|
|
+ "so it won’t interfere with your daily browser."
|
2025-12-07 00:10:35 +01:00
|
|
|
|
@State private var configModel: String = ""
|
|
|
|
|
|
@State private var configSaving = false
|
|
|
|
|
|
@State private var hasLoaded = false
|
|
|
|
|
|
@State private var models: [ModelChoice] = []
|
|
|
|
|
|
@State private var modelsLoading = false
|
2026-01-10 21:45:58 +01:00
|
|
|
|
@State private var modelSearchQuery: String = ""
|
|
|
|
|
|
@State private var isModelPickerOpen = false
|
2025-12-07 00:10:35 +01:00
|
|
|
|
@State private var modelError: String?
|
2025-12-20 23:24:09 +01:00
|
|
|
|
@State private var modelsSourceLabel: String?
|
2025-12-07 00:10:35 +01:00
|
|
|
|
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
|
|
|
|
|
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
|
|
|
|
|
@State private var allowAutosave = false
|
2025-12-07 04:30:24 +00:00
|
|
|
|
@State private var heartbeatMinutes: Int?
|
|
|
|
|
|
@State private var heartbeatBody: String = "HEARTBEAT"
|
2025-12-07 00:10:35 +01:00
|
|
|
|
|
2026-01-04 14:32:47 +00:00
|
|
|
|
// clawd browser settings (stored in ~/.clawdbot/clawdbot.json under "browser")
|
2025-12-13 15:15:09 +00:00
|
|
|
|
@State private var browserEnabled: Bool = true
|
2025-12-13 15:29:39 +00:00
|
|
|
|
@State private var browserControlUrl: String = "http://127.0.0.1:18791"
|
2025-12-13 15:15:09 +00:00
|
|
|
|
@State private var browserColorHex: String = "#FF4500"
|
|
|
|
|
|
@State private var browserAttachOnly: Bool = false
|
|
|
|
|
|
|
2026-01-04 14:32:47 +00:00
|
|
|
|
// Talk mode settings (stored in ~/.clawdbot/clawdbot.json under "talk")
|
2025-12-29 23:21:05 +01:00
|
|
|
|
@State private var talkVoiceId: String = ""
|
|
|
|
|
|
@State private var talkInterruptOnSpeech: Bool = true
|
2025-12-30 00:51:17 +01:00
|
|
|
|
@State private var talkApiKey: String = ""
|
2025-12-30 01:57:45 +01:00
|
|
|
|
@State private var gatewayApiKeyFound = false
|
2026-01-10 21:45:58 +01:00
|
|
|
|
@FocusState private var modelSearchFocused: Bool
|
2025-12-29 23:21:05 +01:00
|
|
|
|
|
2026-01-02 12:25:47 -06:00
|
|
|
|
private struct ConfigDraft {
|
|
|
|
|
|
let configModel: String
|
|
|
|
|
|
let heartbeatMinutes: Int?
|
|
|
|
|
|
let heartbeatBody: String
|
|
|
|
|
|
let browserEnabled: Bool
|
|
|
|
|
|
let browserControlUrl: String
|
|
|
|
|
|
let browserColorHex: String
|
|
|
|
|
|
let browserAttachOnly: Bool
|
|
|
|
|
|
let talkVoiceId: String
|
|
|
|
|
|
let talkApiKey: String
|
|
|
|
|
|
let talkInterruptOnSpeech: Bool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-07 00:10:35 +01:00
|
|
|
|
var body: some View {
|
2025-12-17 19:14:54 +00:00
|
|
|
|
ScrollView { self.content }
|
2025-12-18 08:55:47 +01:00
|
|
|
|
.onChange(of: self.modelCatalogPath) { _, _ in
|
|
|
|
|
|
Task { await self.loadModels() }
|
|
|
|
|
|
}
|
|
|
|
|
|
.onChange(of: self.modelCatalogReloadBump) { _, _ in
|
|
|
|
|
|
Task { await self.loadModels() }
|
|
|
|
|
|
}
|
|
|
|
|
|
.task {
|
|
|
|
|
|
guard !self.hasLoaded else { return }
|
|
|
|
|
|
guard !self.isPreview else { return }
|
|
|
|
|
|
self.hasLoaded = true
|
2026-01-01 18:58:41 +01:00
|
|
|
|
await self.loadConfig()
|
2025-12-18 08:55:47 +01:00
|
|
|
|
await self.loadModels()
|
2025-12-30 01:57:45 +01:00
|
|
|
|
await self.refreshGatewayTalkApiKey()
|
2025-12-18 08:55:47 +01:00
|
|
|
|
self.allowAutosave = true
|
|
|
|
|
|
}
|
2025-12-17 19:14:54 +00:00
|
|
|
|
}
|
2025-12-13 15:55:31 +00:00
|
|
|
|
|
2025-12-17 19:14:54 +00:00
|
|
|
|
private var content: some View {
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
|
|
|
|
self.header
|
|
|
|
|
|
self.agentSection
|
2025-12-20 21:32:06 +01:00
|
|
|
|
.disabled(self.isNixMode)
|
2025-12-17 19:14:54 +00:00
|
|
|
|
self.heartbeatSection
|
2025-12-20 21:32:06 +01:00
|
|
|
|
.disabled(self.isNixMode)
|
2025-12-29 23:21:05 +01:00
|
|
|
|
self.talkSection
|
2026-01-01 09:15:28 +01:00
|
|
|
|
.disabled(self.isNixMode)
|
2025-12-17 19:14:54 +00:00
|
|
|
|
self.browserSection
|
2025-12-20 21:32:06 +01:00
|
|
|
|
.disabled(self.isNixMode)
|
2025-12-17 19:14:54 +00:00
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
|
.padding(.horizontal, 24)
|
|
|
|
|
|
.padding(.vertical, 18)
|
|
|
|
|
|
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
|
|
|
|
|
}
|
2025-12-13 15:55:31 +00:00
|
|
|
|
|
2025-12-17 19:14:54 +00:00
|
|
|
|
@ViewBuilder
|
|
|
|
|
|
private var header: some View {
|
2026-01-04 14:32:47 +00:00
|
|
|
|
Text("Clawdbot CLI config")
|
2025-12-17 19:14:54 +00:00
|
|
|
|
.font(.title3.weight(.semibold))
|
2025-12-20 21:32:06 +01:00
|
|
|
|
Text(self.isNixMode
|
|
|
|
|
|
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
|
2026-01-04 14:32:47 +00:00
|
|
|
|
: "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).")
|
2025-12-17 19:14:54 +00:00
|
|
|
|
.font(.callout)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
}
|
2025-12-13 15:55:31 +00:00
|
|
|
|
|
2025-12-17 19:14:54 +00:00
|
|
|
|
private var agentSection: some View {
|
|
|
|
|
|
GroupBox("Agent") {
|
|
|
|
|
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
|
|
|
|
|
GridRow {
|
|
|
|
|
|
self.gridLabel("Model")
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
2026-01-10 21:45:58 +01:00
|
|
|
|
self.modelPickerField
|
2025-12-17 19:14:54 +00:00
|
|
|
|
self.modelMetaLabels
|
2025-12-07 00:10:35 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-17 19:14:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 21:45:58 +01:00
|
|
|
|
private var modelPickerField: some View {
|
|
|
|
|
|
Button {
|
|
|
|
|
|
guard !self.modelsLoading else { return }
|
|
|
|
|
|
self.isModelPickerOpen = true
|
|
|
|
|
|
} label: {
|
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
|
Text(self.modelPickerLabel)
|
|
|
|
|
|
.foregroundStyle(self.modelPickerLabelIsPlaceholder ? .secondary : .primary)
|
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
|
.truncationMode(.tail)
|
|
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
|
|
Image(systemName: "chevron.up.chevron.down")
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
2025-12-17 19:14:54 +00:00
|
|
|
|
}
|
2026-01-10 21:45:58 +01:00
|
|
|
|
.padding(.vertical, 6)
|
|
|
|
|
|
.padding(.horizontal, 8)
|
|
|
|
|
|
}
|
|
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
|
.contentShape(Rectangle())
|
|
|
|
|
|
.background(
|
|
|
|
|
|
RoundedRectangle(cornerRadius: 6)
|
2026-01-10 23:10:39 +01:00
|
|
|
|
.fill(
|
|
|
|
|
|
Color(nsColor: .textBackgroundColor)))
|
2026-01-10 21:45:58 +01:00
|
|
|
|
.overlay(
|
|
|
|
|
|
RoundedRectangle(cornerRadius: 6)
|
2026-01-10 23:10:39 +01:00
|
|
|
|
.stroke(
|
|
|
|
|
|
Color.secondary.opacity(0.25),
|
|
|
|
|
|
lineWidth: 1))
|
2026-01-10 21:45:58 +01:00
|
|
|
|
.popover(isPresented: self.$isModelPickerOpen, arrowEdge: .bottom) {
|
|
|
|
|
|
self.modelPickerPopover
|
2025-12-17 19:14:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
|
2026-01-10 21:45:58 +01:00
|
|
|
|
.onChange(of: self.isModelPickerOpen) { _, isOpen in
|
|
|
|
|
|
if isOpen {
|
|
|
|
|
|
self.modelSearchQuery = ""
|
|
|
|
|
|
self.modelSearchFocused = true
|
|
|
|
|
|
}
|
2025-12-17 19:14:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 21:45:58 +01:00
|
|
|
|
private var modelPickerPopover: some View {
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
|
|
|
|
TextField("Search models", text: self.$modelSearchQuery)
|
2025-12-17 19:14:54 +00:00
|
|
|
|
.textFieldStyle(.roundedBorder)
|
2026-01-10 21:45:58 +01:00
|
|
|
|
.focused(self.$modelSearchFocused)
|
|
|
|
|
|
.controlSize(.small)
|
|
|
|
|
|
.onSubmit {
|
|
|
|
|
|
if let exact = self.exactMatchForQuery() {
|
|
|
|
|
|
self.selectModel(exact)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if let manual = self.manualEntryCandidate {
|
|
|
|
|
|
self.selectManualModel(manual)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if self.modelSearchMatches.count == 1 {
|
|
|
|
|
|
self.selectModel(self.modelSearchMatches[0])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
List {
|
|
|
|
|
|
if self.modelSearchMatches.isEmpty {
|
|
|
|
|
|
Text("No models match \"\(self.modelSearchQuery)\"")
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ForEach(self.modelSearchMatches) { choice in
|
|
|
|
|
|
Button {
|
|
|
|
|
|
self.selectModel(choice)
|
|
|
|
|
|
} label: {
|
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
|
Text(choice.name)
|
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
|
|
Text(choice.provider.uppercased())
|
|
|
|
|
|
.font(.caption2.weight(.semibold))
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
.padding(.vertical, 2)
|
|
|
|
|
|
.padding(.horizontal, 6)
|
|
|
|
|
|
.background(Color.secondary.opacity(0.15))
|
|
|
|
|
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
|
|
|
|
|
}
|
|
|
|
|
|
.padding(.vertical, 2)
|
|
|
|
|
|
}
|
|
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if let manual = self.manualEntryCandidate {
|
|
|
|
|
|
Button("Use \"\(manual)\"") {
|
|
|
|
|
|
self.selectManualModel(manual)
|
|
|
|
|
|
}
|
|
|
|
|
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
2025-12-17 19:14:54 +00:00
|
|
|
|
}
|
2026-01-10 21:45:58 +01:00
|
|
|
|
}
|
|
|
|
|
|
.listStyle(.inset)
|
2025-12-17 19:14:54 +00:00
|
|
|
|
}
|
2026-01-10 21:45:58 +01:00
|
|
|
|
.frame(width: 340, height: 260)
|
|
|
|
|
|
.padding(8)
|
2025-12-17 19:14:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@ViewBuilder
|
|
|
|
|
|
private var modelMetaLabels: some View {
|
2026-01-10 21:45:58 +01:00
|
|
|
|
if self.shouldShowProviderHintForSelection {
|
|
|
|
|
|
self.statusLine(label: "Tip: prefer provider/model (e.g. openai-codex/gpt-5.2)", color: .orange)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 19:14:54 +00:00
|
|
|
|
if let contextLabel = self.selectedContextLabel {
|
|
|
|
|
|
Text(contextLabel)
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if let authMode = self.selectedAnthropicAuthMode {
|
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
|
Circle()
|
|
|
|
|
|
.fill(authMode.isConfigured ? Color.green : Color.orange)
|
|
|
|
|
|
.frame(width: 8, height: 8)
|
|
|
|
|
|
Text("Anthropic auth: \(authMode.shortLabel)")
|
|
|
|
|
|
}
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(authMode.isConfigured ? Color.secondary : Color.orange)
|
|
|
|
|
|
.help(self.anthropicAuthHelpText)
|
|
|
|
|
|
|
|
|
|
|
|
AnthropicAuthControls(connectionMode: self.state.connectionMode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if let modelError {
|
|
|
|
|
|
Text(modelError)
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
}
|
2025-12-20 23:24:09 +01:00
|
|
|
|
|
|
|
|
|
|
if let modelsSourceLabel {
|
|
|
|
|
|
Text("Model catalog: \(modelsSourceLabel)")
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
}
|
2025-12-17 19:14:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private var anthropicAuthHelpText: String {
|
2026-01-04 14:32:47 +00:00
|
|
|
|
"Determined from Clawdbot OAuth token file (~/.clawdbot/credentials/oauth.json) " +
|
2025-12-17 19:14:54 +00:00
|
|
|
|
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private var heartbeatSection: some View {
|
|
|
|
|
|
GroupBox("Heartbeat") {
|
|
|
|
|
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
|
|
|
|
|
GridRow {
|
|
|
|
|
|
self.gridLabel("Schedule")
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
|
|
|
|
HStack(spacing: 12) {
|
|
|
|
|
|
Stepper(
|
|
|
|
|
|
value: Binding(
|
|
|
|
|
|
get: { self.heartbeatMinutes ?? 10 },
|
|
|
|
|
|
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
|
|
|
|
|
|
in: 0...720)
|
|
|
|
|
|
{
|
|
|
|
|
|
Text("Every \(self.heartbeatMinutes ?? 10) min")
|
|
|
|
|
|
.frame(width: 150, alignment: .leading)
|
2025-12-07 04:32:28 +00:00
|
|
|
|
}
|
2025-12-17 19:14:54 +00:00
|
|
|
|
.help("Set to 0 to disable automatic heartbeats")
|
|
|
|
|
|
|
|
|
|
|
|
TextField("HEARTBEAT", text: self.$heartbeatBody)
|
|
|
|
|
|
.textFieldStyle(.roundedBorder)
|
|
|
|
|
|
.frame(maxWidth: .infinity)
|
|
|
|
|
|
.onChange(of: self.heartbeatBody) { _, _ in
|
|
|
|
|
|
self.autosaveConfig()
|
|
|
|
|
|
}
|
|
|
|
|
|
.help("Message body sent on each heartbeat")
|
2025-12-13 15:55:31 +00:00
|
|
|
|
}
|
2025-12-17 19:14:54 +00:00
|
|
|
|
Text("Heartbeats keep agent sessions warm; 0 minutes disables them.")
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
2025-12-07 04:32:28 +00:00
|
|
|
|
}
|
2025-12-07 04:30:24 +00:00
|
|
|
|
}
|
2025-12-17 19:14:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private var browserSection: some View {
|
|
|
|
|
|
GroupBox("Browser (clawd)") {
|
|
|
|
|
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
|
|
|
|
|
GridRow {
|
|
|
|
|
|
self.gridLabel("Enabled")
|
|
|
|
|
|
Toggle("", isOn: self.$browserEnabled)
|
|
|
|
|
|
.labelsHidden()
|
|
|
|
|
|
.toggleStyle(.checkbox)
|
|
|
|
|
|
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
|
|
|
|
|
|
}
|
|
|
|
|
|
GridRow {
|
|
|
|
|
|
self.gridLabel("Control URL")
|
|
|
|
|
|
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
|
|
|
|
|
|
.textFieldStyle(.roundedBorder)
|
|
|
|
|
|
.frame(maxWidth: .infinity)
|
|
|
|
|
|
.disabled(!self.browserEnabled)
|
|
|
|
|
|
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
|
|
|
|
|
|
}
|
|
|
|
|
|
GridRow {
|
|
|
|
|
|
self.gridLabel("Browser path")
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
|
|
|
|
if let label = self.browserPathLabel {
|
|
|
|
|
|
Text(label)
|
|
|
|
|
|
.font(.caption.monospaced())
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
|
.truncationMode(.middle)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Text("—")
|
2025-12-13 15:55:31 +00:00
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
}
|
2025-12-08 12:50:37 +01:00
|
|
|
|
}
|
2025-12-17 19:14:54 +00:00
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
2025-12-08 12:50:37 +01:00
|
|
|
|
}
|
2025-12-17 19:14:54 +00:00
|
|
|
|
GridRow {
|
|
|
|
|
|
self.gridLabel("Accent")
|
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
|
TextField("#FF4500", text: self.$browserColorHex)
|
|
|
|
|
|
.textFieldStyle(.roundedBorder)
|
|
|
|
|
|
.frame(width: 120)
|
|
|
|
|
|
.disabled(!self.browserEnabled)
|
|
|
|
|
|
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
|
|
|
|
|
|
Circle()
|
|
|
|
|
|
.fill(self.browserColor)
|
|
|
|
|
|
.frame(width: 12, height: 12)
|
|
|
|
|
|
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
|
|
|
|
|
|
Text("lobster-orange")
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
2025-12-13 15:15:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-17 19:14:54 +00:00
|
|
|
|
GridRow {
|
|
|
|
|
|
self.gridLabel("Attach only")
|
|
|
|
|
|
Toggle("", isOn: self.$browserAttachOnly)
|
|
|
|
|
|
.labelsHidden()
|
|
|
|
|
|
.toggleStyle(.checkbox)
|
|
|
|
|
|
.disabled(!self.browserEnabled)
|
|
|
|
|
|
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
|
|
|
|
|
|
.help(Self.browserAttachOnlyHelp)
|
|
|
|
|
|
}
|
|
|
|
|
|
GridRow {
|
|
|
|
|
|
Color.clear
|
|
|
|
|
|
.frame(width: self.labelColumnWidth, height: 1)
|
|
|
|
|
|
Text(Self.browserProfileNote)
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
|
}
|
2025-12-13 15:55:31 +00:00
|
|
|
|
}
|
2025-12-07 00:10:35 +01:00
|
|
|
|
}
|
2025-12-17 19:14:54 +00:00
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
2025-12-07 00:10:35 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 23:21:05 +01:00
|
|
|
|
private var talkSection: some View {
|
|
|
|
|
|
GroupBox("Talk Mode") {
|
|
|
|
|
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
|
|
|
|
|
GridRow {
|
|
|
|
|
|
self.gridLabel("Voice ID")
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
2025-12-30 00:17:10 +01:00
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
|
TextField("ElevenLabs voice ID", text: self.$talkVoiceId)
|
|
|
|
|
|
.textFieldStyle(.roundedBorder)
|
|
|
|
|
|
.frame(maxWidth: .infinity)
|
|
|
|
|
|
.onChange(of: self.talkVoiceId) { _, _ in self.autosaveConfig() }
|
|
|
|
|
|
if !self.talkVoiceSuggestions.isEmpty {
|
|
|
|
|
|
Menu {
|
|
|
|
|
|
ForEach(self.talkVoiceSuggestions, id: \.self) { value in
|
|
|
|
|
|
Button(value) {
|
|
|
|
|
|
self.talkVoiceId = value
|
|
|
|
|
|
self.autosaveConfig()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} label: {
|
|
|
|
|
|
Label("Suggestions", systemImage: "chevron.up.chevron.down")
|
|
|
|
|
|
}
|
|
|
|
|
|
.fixedSize()
|
2025-12-29 23:21:05 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Text("Defaults to ELEVENLABS_VOICE_ID / SAG_VOICE_ID if unset.")
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-30 00:51:17 +01:00
|
|
|
|
GridRow {
|
|
|
|
|
|
self.gridLabel("API key")
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
|
SecureField("ELEVENLABS_API_KEY", text: self.$talkApiKey)
|
|
|
|
|
|
.textFieldStyle(.roundedBorder)
|
|
|
|
|
|
.frame(maxWidth: .infinity)
|
|
|
|
|
|
.disabled(self.hasEnvApiKey)
|
|
|
|
|
|
.onChange(of: self.talkApiKey) { _, _ in self.autosaveConfig() }
|
2026-01-02 12:30:59 -06:00
|
|
|
|
if !self.hasEnvApiKey, !self.talkApiKey.isEmpty {
|
2025-12-30 00:51:17 +01:00
|
|
|
|
Button("Clear") {
|
|
|
|
|
|
self.talkApiKey = ""
|
|
|
|
|
|
self.autosaveConfig()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
self.statusLine(label: self.apiKeyStatusLabel, color: self.apiKeyStatusColor)
|
|
|
|
|
|
if self.hasEnvApiKey {
|
|
|
|
|
|
Text("Using ELEVENLABS_API_KEY from the environment.")
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
2026-01-02 12:30:59 -06:00
|
|
|
|
} else if self.gatewayApiKeyFound,
|
|
|
|
|
|
self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
2026-01-02 00:17:49 +01:00
|
|
|
|
{
|
2025-12-30 01:57:45 +01:00
|
|
|
|
Text("Using API key from the gateway profile.")
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
2025-12-30 00:51:17 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-29 23:21:05 +01:00
|
|
|
|
GridRow {
|
|
|
|
|
|
self.gridLabel("Interrupt")
|
|
|
|
|
|
Toggle("Stop speaking when you start talking", isOn: self.$talkInterruptOnSpeech)
|
|
|
|
|
|
.labelsHidden()
|
|
|
|
|
|
.toggleStyle(.checkbox)
|
|
|
|
|
|
.onChange(of: self.talkInterruptOnSpeech) { _, _ in self.autosaveConfig() }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-13 15:55:31 +00:00
|
|
|
|
private func gridLabel(_ text: String) -> some View {
|
|
|
|
|
|
Text(text)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
.frame(width: self.labelColumnWidth, alignment: .leading)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-30 00:51:17 +01:00
|
|
|
|
private func statusLine(label: String, color: Color) -> some View {
|
|
|
|
|
|
HStack(spacing: 6) {
|
|
|
|
|
|
Circle()
|
|
|
|
|
|
.fill(color)
|
|
|
|
|
|
.frame(width: 6, height: 6)
|
|
|
|
|
|
Text(label)
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
}
|
|
|
|
|
|
.padding(.top, 2)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 18:58:41 +01:00
|
|
|
|
private func loadConfig() async {
|
|
|
|
|
|
let parsed = await ConfigStore.load()
|
2026-01-09 12:44:23 +00:00
|
|
|
|
let agents = parsed["agents"] as? [String: Any]
|
|
|
|
|
|
let defaults = agents?["defaults"] as? [String: Any]
|
|
|
|
|
|
let heartbeat = defaults?["heartbeat"] as? [String: Any]
|
|
|
|
|
|
let heartbeatEvery = heartbeat?["every"] as? String
|
|
|
|
|
|
let heartbeatBody = heartbeat?["prompt"] as? String
|
2025-12-13 15:15:09 +00:00
|
|
|
|
let browser = parsed["browser"] as? [String: Any]
|
2025-12-29 23:21:05 +01:00
|
|
|
|
let talk = parsed["talk"] as? [String: Any]
|
2025-12-07 00:10:35 +01:00
|
|
|
|
|
2026-01-09 12:44:23 +00:00
|
|
|
|
let loadedModel: String = {
|
|
|
|
|
|
if let raw = defaults?["model"] as? String { return raw }
|
|
|
|
|
|
if let modelDict = defaults?["model"] as? [String: Any],
|
|
|
|
|
|
let primary = modelDict["primary"] as? String { return primary }
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}()
|
2025-12-07 00:10:35 +01:00
|
|
|
|
if !loadedModel.isEmpty {
|
|
|
|
|
|
self.configModel = loadedModel
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.configModel = SessionLoader.fallbackModel
|
|
|
|
|
|
}
|
2025-12-07 04:30:24 +00:00
|
|
|
|
|
2026-01-09 12:44:23 +00:00
|
|
|
|
if let heartbeatEvery {
|
|
|
|
|
|
let digits = heartbeatEvery.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
|
.prefix { $0.isNumber }
|
|
|
|
|
|
if let minutes = Int(digits) {
|
|
|
|
|
|
self.heartbeatMinutes = minutes
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-07 04:30:24 +00:00
|
|
|
|
if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody }
|
2025-12-13 15:15:09 +00:00
|
|
|
|
|
|
|
|
|
|
if let browser {
|
|
|
|
|
|
if let enabled = browser["enabled"] as? Bool { self.browserEnabled = enabled }
|
|
|
|
|
|
if let url = browser["controlUrl"] as? String, !url.isEmpty { self.browserControlUrl = url }
|
|
|
|
|
|
if let color = browser["color"] as? String, !color.isEmpty { self.browserColorHex = color }
|
|
|
|
|
|
if let attachOnly = browser["attachOnly"] as? Bool { self.browserAttachOnly = attachOnly }
|
|
|
|
|
|
}
|
2025-12-29 23:21:05 +01:00
|
|
|
|
|
|
|
|
|
|
if let talk {
|
|
|
|
|
|
if let voice = talk["voiceId"] as? String { self.talkVoiceId = voice }
|
2025-12-30 00:51:17 +01:00
|
|
|
|
if let apiKey = talk["apiKey"] as? String { self.talkApiKey = apiKey }
|
2025-12-29 23:21:05 +01:00
|
|
|
|
if let interrupt = talk["interruptOnSpeech"] as? Bool {
|
|
|
|
|
|
self.talkInterruptOnSpeech = interrupt
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-07 00:10:35 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-30 01:57:45 +01:00
|
|
|
|
private func refreshGatewayTalkApiKey() async {
|
|
|
|
|
|
do {
|
|
|
|
|
|
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
|
|
|
|
|
method: .configGet,
|
|
|
|
|
|
params: nil,
|
|
|
|
|
|
timeoutMs: 8000)
|
|
|
|
|
|
let talk = snap.config?["talk"]?.dictionaryValue
|
|
|
|
|
|
let apiKey = talk?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
|
self.gatewayApiKeyFound = !(apiKey ?? "").isEmpty
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
self.gatewayApiKeyFound = false
|
|
|
|
|
|
}
|
2025-12-07 00:10:35 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func autosaveConfig() {
|
2025-12-20 21:32:06 +01:00
|
|
|
|
guard self.allowAutosave, !self.isNixMode else { return }
|
2025-12-07 00:10:35 +01:00
|
|
|
|
Task { await self.saveConfig() }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func saveConfig() async {
|
|
|
|
|
|
guard !self.configSaving else { return }
|
|
|
|
|
|
self.configSaving = true
|
|
|
|
|
|
defer { self.configSaving = false }
|
|
|
|
|
|
|
2026-01-01 21:34:46 -06:00
|
|
|
|
let configModel = self.configModel
|
|
|
|
|
|
let heartbeatMinutes = self.heartbeatMinutes
|
|
|
|
|
|
let heartbeatBody = self.heartbeatBody
|
|
|
|
|
|
let browserEnabled = self.browserEnabled
|
|
|
|
|
|
let browserControlUrl = self.browserControlUrl
|
|
|
|
|
|
let browserColorHex = self.browserColorHex
|
|
|
|
|
|
let browserAttachOnly = self.browserAttachOnly
|
|
|
|
|
|
let talkVoiceId = self.talkVoiceId
|
|
|
|
|
|
let talkApiKey = self.talkApiKey
|
|
|
|
|
|
let talkInterruptOnSpeech = self.talkInterruptOnSpeech
|
|
|
|
|
|
|
2026-01-02 12:25:47 -06:00
|
|
|
|
let draft = ConfigDraft(
|
2026-01-01 21:34:46 -06:00
|
|
|
|
configModel: configModel,
|
|
|
|
|
|
heartbeatMinutes: heartbeatMinutes,
|
|
|
|
|
|
heartbeatBody: heartbeatBody,
|
|
|
|
|
|
browserEnabled: browserEnabled,
|
|
|
|
|
|
browserControlUrl: browserControlUrl,
|
|
|
|
|
|
browserColorHex: browserColorHex,
|
|
|
|
|
|
browserAttachOnly: browserAttachOnly,
|
|
|
|
|
|
talkVoiceId: talkVoiceId,
|
|
|
|
|
|
talkApiKey: talkApiKey,
|
2026-01-02 12:30:59 -06:00
|
|
|
|
talkInterruptOnSpeech: talkInterruptOnSpeech)
|
2026-01-01 21:34:46 -06:00
|
|
|
|
|
2026-01-02 12:25:47 -06:00
|
|
|
|
let errorMessage = await ConfigSettings.buildAndSaveConfig(draft)
|
|
|
|
|
|
|
2026-01-01 21:34:46 -06:00
|
|
|
|
if let errorMessage {
|
|
|
|
|
|
self.modelError = errorMessage
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-02 17:14:57 +01:00
|
|
|
|
@MainActor
|
2026-01-02 12:25:47 -06:00
|
|
|
|
private static func buildAndSaveConfig(_ draft: ConfigDraft) async -> String? {
|
2026-01-01 18:58:41 +01:00
|
|
|
|
var root = await ConfigStore.load()
|
2026-01-09 12:44:23 +00:00
|
|
|
|
var agents = root["agents"] as? [String: Any] ?? [:]
|
|
|
|
|
|
var defaults = agents["defaults"] as? [String: Any] ?? [:]
|
2025-12-13 15:15:09 +00:00
|
|
|
|
var browser = root["browser"] as? [String: Any] ?? [:]
|
2025-12-29 23:21:05 +01:00
|
|
|
|
var talk = root["talk"] as? [String: Any] ?? [:]
|
2025-12-07 00:10:35 +01:00
|
|
|
|
|
2026-01-10 21:45:58 +01:00
|
|
|
|
let chosenModel = draft.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
2025-12-07 00:10:35 +01:00
|
|
|
|
let trimmedModel = chosenModel
|
2026-01-09 12:44:23 +00:00
|
|
|
|
if !trimmedModel.isEmpty {
|
|
|
|
|
|
var model = defaults["model"] as? [String: Any] ?? [:]
|
|
|
|
|
|
model["primary"] = trimmedModel
|
|
|
|
|
|
defaults["model"] = model
|
|
|
|
|
|
|
|
|
|
|
|
var models = defaults["models"] as? [String: Any] ?? [:]
|
|
|
|
|
|
if models[trimmedModel] == nil {
|
|
|
|
|
|
models[trimmedModel] = [:]
|
|
|
|
|
|
}
|
|
|
|
|
|
defaults["models"] = models
|
|
|
|
|
|
}
|
2025-12-07 00:10:35 +01:00
|
|
|
|
|
2026-01-02 12:25:47 -06:00
|
|
|
|
if let heartbeatMinutes = draft.heartbeatMinutes {
|
2026-01-09 12:44:23 +00:00
|
|
|
|
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
|
|
|
|
|
|
heartbeat["every"] = "\(heartbeatMinutes)m"
|
|
|
|
|
|
defaults["heartbeat"] = heartbeat
|
2025-12-07 04:30:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-02 12:25:47 -06:00
|
|
|
|
let trimmedBody = draft.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
2025-12-07 04:30:24 +00:00
|
|
|
|
if !trimmedBody.isEmpty {
|
2026-01-09 12:44:23 +00:00
|
|
|
|
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
|
|
|
|
|
|
heartbeat["prompt"] = trimmedBody
|
|
|
|
|
|
defaults["heartbeat"] = heartbeat
|
2025-12-07 04:30:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 12:44:23 +00:00
|
|
|
|
if defaults.isEmpty {
|
|
|
|
|
|
agents.removeValue(forKey: "defaults")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
agents["defaults"] = defaults
|
|
|
|
|
|
}
|
|
|
|
|
|
if agents.isEmpty {
|
|
|
|
|
|
root.removeValue(forKey: "agents")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
root["agents"] = agents
|
|
|
|
|
|
}
|
2025-12-07 00:10:35 +01:00
|
|
|
|
|
2026-01-02 12:25:47 -06:00
|
|
|
|
browser["enabled"] = draft.browserEnabled
|
|
|
|
|
|
let trimmedUrl = draft.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
2025-12-13 15:15:09 +00:00
|
|
|
|
if !trimmedUrl.isEmpty { browser["controlUrl"] = trimmedUrl }
|
2026-01-02 12:25:47 -06:00
|
|
|
|
let trimmedColor = draft.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
2025-12-13 15:15:09 +00:00
|
|
|
|
if !trimmedColor.isEmpty { browser["color"] = trimmedColor }
|
2026-01-02 12:25:47 -06:00
|
|
|
|
browser["attachOnly"] = draft.browserAttachOnly
|
2025-12-13 15:15:09 +00:00
|
|
|
|
root["browser"] = browser
|
|
|
|
|
|
|
2026-01-02 12:25:47 -06:00
|
|
|
|
let trimmedVoice = draft.talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
2025-12-29 23:21:05 +01:00
|
|
|
|
if trimmedVoice.isEmpty {
|
|
|
|
|
|
talk.removeValue(forKey: "voiceId")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
talk["voiceId"] = trimmedVoice
|
|
|
|
|
|
}
|
2026-01-02 12:25:47 -06:00
|
|
|
|
let trimmedApiKey = draft.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
2025-12-30 00:51:17 +01:00
|
|
|
|
if trimmedApiKey.isEmpty {
|
|
|
|
|
|
talk.removeValue(forKey: "apiKey")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
talk["apiKey"] = trimmedApiKey
|
|
|
|
|
|
}
|
2026-01-02 12:25:47 -06:00
|
|
|
|
talk["interruptOnSpeech"] = draft.talkInterruptOnSpeech
|
2025-12-29 23:21:05 +01:00
|
|
|
|
root["talk"] = talk
|
|
|
|
|
|
|
2026-01-01 18:58:41 +01:00
|
|
|
|
do {
|
|
|
|
|
|
try await ConfigStore.save(root)
|
2026-01-01 21:34:46 -06:00
|
|
|
|
return nil
|
2026-01-02 12:30:59 -06:00
|
|
|
|
} catch {
|
2026-01-01 21:34:46 -06:00
|
|
|
|
return error.localizedDescription
|
2026-01-01 18:58:41 +01:00
|
|
|
|
}
|
2025-12-07 04:38:08 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-13 15:15:09 +00:00
|
|
|
|
private var browserColor: Color {
|
|
|
|
|
|
let raw = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
|
let hex = raw.hasPrefix("#") ? String(raw.dropFirst()) : raw
|
|
|
|
|
|
guard hex.count == 6, let value = Int(hex, radix: 16) else { return .orange }
|
|
|
|
|
|
let r = Double((value >> 16) & 0xFF) / 255.0
|
|
|
|
|
|
let g = Double((value >> 8) & 0xFF) / 255.0
|
|
|
|
|
|
let b = Double(value & 0xFF) / 255.0
|
|
|
|
|
|
return Color(red: r, green: g, blue: b)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 23:21:05 +01:00
|
|
|
|
private var talkVoiceSuggestions: [String] {
|
|
|
|
|
|
let env = ProcessInfo.processInfo.environment
|
|
|
|
|
|
let candidates = [
|
|
|
|
|
|
self.talkVoiceId,
|
|
|
|
|
|
env["ELEVENLABS_VOICE_ID"] ?? "",
|
|
|
|
|
|
env["SAG_VOICE_ID"] ?? "",
|
|
|
|
|
|
]
|
|
|
|
|
|
var seen = Set<String>()
|
|
|
|
|
|
return candidates
|
|
|
|
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
|
|
|
|
.filter { !$0.isEmpty }
|
|
|
|
|
|
.filter { seen.insert($0).inserted }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-30 00:51:17 +01:00
|
|
|
|
private var hasEnvApiKey: Bool {
|
|
|
|
|
|
let raw = ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] ?? ""
|
|
|
|
|
|
return !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private var apiKeyStatusLabel: String {
|
|
|
|
|
|
if self.hasEnvApiKey { return "ElevenLabs API key: found (environment)" }
|
|
|
|
|
|
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
|
|
|
|
return "ElevenLabs API key: stored in config"
|
|
|
|
|
|
}
|
2025-12-30 01:57:45 +01:00
|
|
|
|
if self.gatewayApiKeyFound { return "ElevenLabs API key: found (gateway)" }
|
2025-12-30 00:51:17 +01:00
|
|
|
|
return "ElevenLabs API key: missing"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private var apiKeyStatusColor: Color {
|
|
|
|
|
|
if self.hasEnvApiKey { return .green }
|
|
|
|
|
|
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return .green }
|
2025-12-30 01:57:45 +01:00
|
|
|
|
if self.gatewayApiKeyFound { return .green }
|
2025-12-30 00:51:17 +01:00
|
|
|
|
return .red
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-13 17:23:41 +00:00
|
|
|
|
private var browserPathLabel: String? {
|
|
|
|
|
|
guard self.browserEnabled else { return nil }
|
|
|
|
|
|
|
|
|
|
|
|
let host = (URL(string: self.browserControlUrl)?.host ?? "").lowercased()
|
|
|
|
|
|
if !host.isEmpty, !Self.isLoopbackHost(host) {
|
|
|
|
|
|
return "remote (\(host))"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
guard let candidate = Self.detectedBrowserCandidate() else { return nil }
|
|
|
|
|
|
return candidate.executablePath ?? candidate.appPath
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private struct BrowserCandidate {
|
|
|
|
|
|
let name: String
|
|
|
|
|
|
let appPath: String
|
|
|
|
|
|
let executablePath: String?
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static func detectedBrowserCandidate() -> BrowserCandidate? {
|
|
|
|
|
|
let candidates: [(name: String, appName: String)] = [
|
|
|
|
|
|
("Google Chrome Canary", "Google Chrome Canary.app"),
|
|
|
|
|
|
("Chromium", "Chromium.app"),
|
|
|
|
|
|
("Google Chrome", "Google Chrome.app"),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
let roots = [
|
|
|
|
|
|
"/Applications",
|
|
|
|
|
|
"\(NSHomeDirectory())/Applications",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
let fm = FileManager.default
|
|
|
|
|
|
for (name, appName) in candidates {
|
|
|
|
|
|
for root in roots {
|
|
|
|
|
|
let appPath = "\(root)/\(appName)"
|
|
|
|
|
|
if fm.fileExists(atPath: appPath) {
|
|
|
|
|
|
let bundle = Bundle(url: URL(fileURLWithPath: appPath))
|
|
|
|
|
|
let exec = bundle?.executableURL?.path
|
|
|
|
|
|
return BrowserCandidate(name: name, appPath: appPath, executablePath: exec)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static func isLoopbackHost(_ host: String) -> Bool {
|
|
|
|
|
|
if host == "localhost" { return true }
|
|
|
|
|
|
if host == "127.0.0.1" { return true }
|
|
|
|
|
|
if host == "::1" { return true }
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-07 00:10:35 +01:00
|
|
|
|
private func loadModels() async {
|
|
|
|
|
|
guard !self.modelsLoading else { return }
|
|
|
|
|
|
self.modelsLoading = true
|
|
|
|
|
|
self.modelError = nil
|
2025-12-20 23:24:09 +01:00
|
|
|
|
self.modelsSourceLabel = nil
|
2025-12-07 00:10:35 +01:00
|
|
|
|
do {
|
2025-12-20 23:24:09 +01:00
|
|
|
|
let res: ModelsListResult =
|
|
|
|
|
|
try await GatewayConnection.shared
|
|
|
|
|
|
.requestDecoded(
|
|
|
|
|
|
method: .modelsList,
|
|
|
|
|
|
timeoutMs: 15000)
|
|
|
|
|
|
self.models = res.models
|
|
|
|
|
|
self.modelsSourceLabel = "gateway"
|
2025-12-07 00:10:35 +01:00
|
|
|
|
} catch {
|
2025-12-20 23:24:09 +01:00
|
|
|
|
do {
|
|
|
|
|
|
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
|
|
|
|
|
|
self.models = loaded
|
|
|
|
|
|
self.modelsSourceLabel = "local fallback"
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
self.modelError = error.localizedDescription
|
|
|
|
|
|
self.models = []
|
|
|
|
|
|
}
|
2025-12-07 00:10:35 +01:00
|
|
|
|
}
|
|
|
|
|
|
self.modelsLoading = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 23:24:09 +01:00
|
|
|
|
private struct ModelsListResult: Decodable {
|
|
|
|
|
|
let models: [ModelChoice]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 21:45:58 +01:00
|
|
|
|
private var modelSearchMatches: [ModelChoice] {
|
|
|
|
|
|
let raw = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
|
|
|
guard !raw.isEmpty else { return self.models }
|
|
|
|
|
|
let tokens = raw
|
|
|
|
|
|
.split(whereSeparator: { $0.isWhitespace })
|
|
|
|
|
|
.map { token in
|
|
|
|
|
|
token.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
|
|
|
|
|
}
|
|
|
|
|
|
.filter { !$0.isEmpty }
|
|
|
|
|
|
guard !tokens.isEmpty else { return self.models }
|
|
|
|
|
|
return self.models.filter { choice in
|
|
|
|
|
|
let haystack = [
|
|
|
|
|
|
choice.id,
|
|
|
|
|
|
choice.name,
|
|
|
|
|
|
choice.provider,
|
|
|
|
|
|
self.modelRef(for: choice),
|
|
|
|
|
|
]
|
2026-01-10 23:10:39 +01:00
|
|
|
|
.joined(separator: " ")
|
|
|
|
|
|
.lowercased()
|
2026-01-10 21:45:58 +01:00
|
|
|
|
return tokens.allSatisfy { haystack.contains($0) }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private var selectedModelChoice: ModelChoice? {
|
|
|
|
|
|
guard !self.configModel.isEmpty else { return nil }
|
|
|
|
|
|
return self.models.first(where: { self.matchesConfigModel($0) })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private var modelPickerLabel: String {
|
|
|
|
|
|
if let choice = self.selectedModelChoice {
|
|
|
|
|
|
return "\(choice.name) — \(choice.provider.uppercased())"
|
|
|
|
|
|
}
|
|
|
|
|
|
if !self.configModel.isEmpty { return self.configModel }
|
|
|
|
|
|
return "Select model"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private var modelPickerLabelIsPlaceholder: Bool {
|
|
|
|
|
|
self.configModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private var manualEntryCandidate: String? {
|
|
|
|
|
|
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
|
guard !trimmed.isEmpty else { return nil }
|
|
|
|
|
|
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
|
|
|
|
|
guard !cleaned.isEmpty else { return nil }
|
|
|
|
|
|
guard !self.isKnownModelRef(cleaned) else { return nil }
|
|
|
|
|
|
return cleaned
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func isKnownModelRef(_ value: String) -> Bool {
|
|
|
|
|
|
let needle = value.lowercased()
|
|
|
|
|
|
return self.models.contains { choice in
|
|
|
|
|
|
choice.id.lowercased() == needle
|
|
|
|
|
|
|| self.modelRef(for: choice).lowercased() == needle
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func modelRef(for choice: ModelChoice) -> String {
|
|
|
|
|
|
let id = choice.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
|
let provider = choice.provider.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
|
guard !provider.isEmpty else { return id }
|
|
|
|
|
|
let normalizedProvider = provider.lowercased()
|
|
|
|
|
|
if id.lowercased().hasPrefix("\(normalizedProvider)/") {
|
|
|
|
|
|
return id
|
|
|
|
|
|
}
|
2026-01-10 23:39:39 +01:00
|
|
|
|
return "\(normalizedProvider)/\(id)"
|
2026-01-10 21:45:58 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func matchesConfigModel(_ choice: ModelChoice) -> Bool {
|
|
|
|
|
|
let configured = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
|
guard !configured.isEmpty else { return false }
|
|
|
|
|
|
if configured.caseInsensitiveCompare(choice.id) == .orderedSame { return true }
|
|
|
|
|
|
let ref = self.modelRef(for: choice)
|
|
|
|
|
|
return configured.caseInsensitiveCompare(ref) == .orderedSame
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func exactMatchForQuery() -> ModelChoice? {
|
|
|
|
|
|
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
|
guard !trimmed.isEmpty else { return nil }
|
|
|
|
|
|
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%")).lowercased()
|
|
|
|
|
|
guard !cleaned.isEmpty else { return nil }
|
|
|
|
|
|
return self.models.first(where: { choice in
|
|
|
|
|
|
let id = choice.id.lowercased()
|
|
|
|
|
|
if id == cleaned { return true }
|
|
|
|
|
|
return self.modelRef(for: choice).lowercased() == cleaned
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private var shouldShowProviderHint: Bool {
|
|
|
|
|
|
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
|
guard !trimmed.isEmpty else { return false }
|
|
|
|
|
|
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
|
|
|
|
|
return !cleaned.contains("/")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private var shouldShowProviderHintForSelection: Bool {
|
|
|
|
|
|
let trimmed = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
|
guard !trimmed.isEmpty else { return false }
|
|
|
|
|
|
return !trimmed.contains("/")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func selectModel(_ choice: ModelChoice) {
|
|
|
|
|
|
self.configModel = self.modelRef(for: choice)
|
|
|
|
|
|
self.autosaveConfig()
|
|
|
|
|
|
self.isModelPickerOpen = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func selectManualModel(_ value: String) {
|
2026-01-10 23:39:39 +01:00
|
|
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
|
if let slash = trimmed.firstIndex(of: "/") {
|
|
|
|
|
|
let provider = trimmed[..<slash].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
|
|
|
let model = trimmed[trimmed.index(after: slash)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
|
self.configModel = provider.isEmpty ? String(model) : "\(provider)/\(model)"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.configModel = trimmed
|
|
|
|
|
|
}
|
2026-01-10 21:45:58 +01:00
|
|
|
|
self.autosaveConfig()
|
|
|
|
|
|
self.isModelPickerOpen = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-07 00:10:35 +01:00
|
|
|
|
private var selectedContextLabel: String? {
|
|
|
|
|
|
guard
|
2026-01-10 21:45:58 +01:00
|
|
|
|
let choice = self.selectedModelChoice,
|
2025-12-07 00:10:35 +01:00
|
|
|
|
let context = choice.contextWindow
|
|
|
|
|
|
else {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let human = context >= 1000 ? "\(context / 1000)k" : "\(context)"
|
|
|
|
|
|
return "Context window: \(human) tokens"
|
|
|
|
|
|
}
|
2025-12-13 18:06:32 +00:00
|
|
|
|
|
2025-12-17 19:14:54 +00:00
|
|
|
|
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
|
2026-01-10 21:45:58 +01:00
|
|
|
|
guard let choice = self.selectedModelChoice else { return nil }
|
2025-12-17 19:14:54 +00:00
|
|
|
|
guard choice.provider.lowercased() == "anthropic" else { return nil }
|
|
|
|
|
|
return AnthropicAuthResolver.resolve()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-13 18:06:32 +00:00
|
|
|
|
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
|
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
|
|
|
|
configuration.label
|
|
|
|
|
|
.font(.caption.weight(.semibold))
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
configuration.content
|
|
|
|
|
|
}
|
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-07 00:10:35 +01:00
|
|
|
|
}
|
2025-12-09 18:04:11 +01:00
|
|
|
|
|
|
|
|
|
|
#if DEBUG
|
|
|
|
|
|
struct ConfigSettings_Previews: PreviewProvider {
|
|
|
|
|
|
static var previews: some View {
|
|
|
|
|
|
ConfigSettings()
|
|
|
|
|
|
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|