448 lines
20 KiB
Swift
448 lines
20 KiB
Swift
import SwiftUI
|
||
|
||
@MainActor
|
||
struct ConfigSettings: View {
|
||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||
private let labelColumnWidth: CGFloat = 120
|
||
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."
|
||
@State private var configModel: String = ""
|
||
@State private var customModel: String = ""
|
||
@State private var configSaving = false
|
||
@State private var hasLoaded = false
|
||
@State private var models: [ModelChoice] = []
|
||
@State private var modelsLoading = false
|
||
@State private var modelError: String?
|
||
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
||
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
||
@State private var allowAutosave = false
|
||
@State private var heartbeatMinutes: Int?
|
||
@State private var heartbeatBody: String = "HEARTBEAT"
|
||
@AppStorage(webChatEnabledKey) private var webChatEnabled: Bool = true
|
||
@AppStorage(webChatPortKey) private var webChatPort: Int = 18788
|
||
|
||
// clawd browser settings (stored in ~/.clawdis/clawdis.json under "browser")
|
||
@State private var browserEnabled: Bool = true
|
||
@State private var browserControlUrl: String = "http://127.0.0.1:18791"
|
||
@State private var browserColorHex: String = "#FF4500"
|
||
@State private var browserAttachOnly: Bool = false
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
Text("Clawdis CLI config")
|
||
.font(.title3.weight(.semibold))
|
||
Text("Edit ~/.clawdis/clawdis.json (inbound.reply.agent/session).")
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
|
||
GroupBox("Agent") {
|
||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||
GridRow {
|
||
self.gridLabel("Model")
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Picker("Model", selection: self.$configModel) {
|
||
ForEach(self.models) { choice in
|
||
Text("\(choice.name) — \(choice.provider.uppercased())")
|
||
.tag(choice.id)
|
||
}
|
||
Text("Manual entry…").tag("__custom__")
|
||
}
|
||
.labelsHidden()
|
||
.frame(maxWidth: .infinity)
|
||
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
|
||
.onChange(of: self.configModel) { _, _ in
|
||
self.autosaveConfig()
|
||
}
|
||
|
||
if self.configModel == "__custom__" {
|
||
TextField("Enter model ID", text: self.$customModel)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(maxWidth: .infinity)
|
||
.onChange(of: self.customModel) { _, newValue in
|
||
self.configModel = newValue
|
||
self.autosaveConfig()
|
||
}
|
||
}
|
||
|
||
if let contextLabel = self.selectedContextLabel {
|
||
Text(contextLabel)
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
if let modelError {
|
||
Text(modelError)
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
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)
|
||
}
|
||
.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")
|
||
}
|
||
Text("Heartbeats keep Pi sessions warm; 0 minutes disables them.")
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
GroupBox("Web Chat") {
|
||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||
GridRow {
|
||
self.gridLabel("Enabled")
|
||
Toggle("", isOn: self.$webChatEnabled)
|
||
.labelsHidden()
|
||
.toggleStyle(.checkbox)
|
||
}
|
||
GridRow {
|
||
self.gridLabel("Port")
|
||
TextField("18788", value: self.$webChatPort, formatter: NumberFormatter())
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(width: 100)
|
||
.disabled(!self.webChatEnabled)
|
||
}
|
||
GridRow {
|
||
Color.clear
|
||
.frame(width: self.labelColumnWidth, height: 1)
|
||
Text(
|
||
"""
|
||
Mac app connects to the gateway’s loopback web chat on this port.
|
||
Remote mode uses SSH -L to forward it.
|
||
""")
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
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("—")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
Spacer(minLength: 0)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, 24)
|
||
.padding(.vertical, 18)
|
||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||
}
|
||
.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
|
||
self.loadConfig()
|
||
await self.loadModels()
|
||
self.allowAutosave = true
|
||
}
|
||
}
|
||
|
||
private func gridLabel(_ text: String) -> some View {
|
||
Text(text)
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: self.labelColumnWidth, alignment: .leading)
|
||
}
|
||
|
||
private func loadConfig() {
|
||
let parsed = self.loadConfigDict()
|
||
let inbound = parsed["inbound"] as? [String: Any]
|
||
let reply = inbound?["reply"] as? [String: Any]
|
||
let agent = reply?["agent"] as? [String: Any]
|
||
let heartbeatMinutes = reply?["heartbeatMinutes"] as? Int
|
||
let heartbeatBody = reply?["heartbeatBody"] as? String
|
||
let browser = parsed["browser"] as? [String: Any]
|
||
|
||
let loadedModel = (agent?["model"] as? String) ?? ""
|
||
if !loadedModel.isEmpty {
|
||
self.configModel = loadedModel
|
||
self.customModel = loadedModel
|
||
} else {
|
||
self.configModel = SessionLoader.fallbackModel
|
||
self.customModel = SessionLoader.fallbackModel
|
||
}
|
||
|
||
if let heartbeatMinutes { self.heartbeatMinutes = heartbeatMinutes }
|
||
if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody }
|
||
|
||
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 }
|
||
}
|
||
}
|
||
|
||
private func autosaveConfig() {
|
||
guard self.allowAutosave else { return }
|
||
Task { await self.saveConfig() }
|
||
}
|
||
|
||
private func saveConfig() async {
|
||
guard !self.configSaving else { return }
|
||
self.configSaving = true
|
||
defer { self.configSaving = false }
|
||
|
||
var root = self.loadConfigDict()
|
||
var inbound = root["inbound"] as? [String: Any] ?? [:]
|
||
var reply = inbound["reply"] as? [String: Any] ?? [:]
|
||
var agent = reply["agent"] as? [String: Any] ?? [:]
|
||
var browser = root["browser"] as? [String: Any] ?? [:]
|
||
|
||
let chosenModel = (self.configModel == "__custom__" ? self.customModel : self.configModel)
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let trimmedModel = chosenModel
|
||
if !trimmedModel.isEmpty { agent["model"] = trimmedModel }
|
||
|
||
reply["agent"] = agent
|
||
|
||
if let heartbeatMinutes {
|
||
reply["heartbeatMinutes"] = heartbeatMinutes
|
||
}
|
||
|
||
let trimmedBody = self.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if !trimmedBody.isEmpty {
|
||
reply["heartbeatBody"] = trimmedBody
|
||
}
|
||
|
||
inbound["reply"] = reply
|
||
root["inbound"] = inbound
|
||
|
||
browser["enabled"] = self.browserEnabled
|
||
let trimmedUrl = self.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if !trimmedUrl.isEmpty { browser["controlUrl"] = trimmedUrl }
|
||
let trimmedColor = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if !trimmedColor.isEmpty { browser["color"] = trimmedColor }
|
||
browser["attachOnly"] = self.browserAttachOnly
|
||
root["browser"] = browser
|
||
|
||
ClawdisConfigFile.saveDict(root)
|
||
}
|
||
|
||
private func loadConfigDict() -> [String: Any] {
|
||
ClawdisConfigFile.loadDict()
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
private func loadModels() async {
|
||
guard !self.modelsLoading else { return }
|
||
self.modelsLoading = true
|
||
self.modelError = nil
|
||
do {
|
||
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
|
||
self.models = loaded
|
||
if !self.configModel.isEmpty, !loaded.contains(where: { $0.id == self.configModel }) {
|
||
self.customModel = self.configModel
|
||
self.configModel = "__custom__"
|
||
}
|
||
} catch {
|
||
self.modelError = error.localizedDescription
|
||
self.models = []
|
||
}
|
||
self.modelsLoading = false
|
||
}
|
||
|
||
private var selectedContextLabel: String? {
|
||
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
|
||
guard
|
||
!chosenId.isEmpty,
|
||
let choice = self.models.first(where: { $0.id == chosenId }),
|
||
let context = choice.contextWindow
|
||
else {
|
||
return nil
|
||
}
|
||
|
||
let human = context >= 1000 ? "\(context / 1000)k" : "\(context)"
|
||
return "Context window: \(human) tokens"
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
#if DEBUG
|
||
struct ConfigSettings_Previews: PreviewProvider {
|
||
static var previews: some View {
|
||
ConfigSettings()
|
||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||
}
|
||
}
|
||
#endif
|