mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
Merge pull request #4 from awizemann/development
Config Editor and Voice Fixes
This commit is contained in:
@@ -14,6 +14,7 @@ struct HermesConfig: Sendable {
|
||||
var showReasoning: Bool
|
||||
var verbose: Bool
|
||||
var autoTTS: Bool
|
||||
var silenceThreshold: Int
|
||||
|
||||
static let empty = HermesConfig(
|
||||
model: "unknown",
|
||||
@@ -28,7 +29,8 @@ struct HermesConfig: Sendable {
|
||||
streaming: true,
|
||||
showReasoning: false,
|
||||
verbose: false,
|
||||
autoTTS: true
|
||||
autoTTS: true,
|
||||
silenceThreshold: 200
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ struct HermesFileService: Sendable {
|
||||
streaming: values["display.streaming"] != "false",
|
||||
showReasoning: values["display.show_reasoning"] == "true",
|
||||
verbose: values["agent.verbose"] == "true",
|
||||
autoTTS: values["voice.auto_tts"] != "false"
|
||||
autoTTS: values["voice.auto_tts"] != "false",
|
||||
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? 200
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
@Observable
|
||||
final class SettingsViewModel {
|
||||
@@ -8,11 +9,89 @@ final class SettingsViewModel {
|
||||
var gatewayState: GatewayState?
|
||||
var hermesRunning = false
|
||||
var rawConfigYAML = ""
|
||||
var personalities: [String] = []
|
||||
var providers = ["anthropic", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax"]
|
||||
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"]
|
||||
var saveMessage: String?
|
||||
|
||||
func load() {
|
||||
config = fileService.loadConfig()
|
||||
gatewayState = fileService.loadGatewayState()
|
||||
hermesRunning = fileService.isHermesRunning()
|
||||
rawConfigYAML = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
||||
personalities = parsePersonalities()
|
||||
}
|
||||
|
||||
func setSetting(_ key: String, value: String) {
|
||||
let result = runHermes(["config", "set", key, value])
|
||||
if result.exitCode == 0 {
|
||||
saveMessage = "Saved \(key)"
|
||||
config = fileService.loadConfig()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.saveMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setModel(_ value: String) { setSetting("model.default", value: value) }
|
||||
func setProvider(_ value: String) { setSetting("model.provider", value: value) }
|
||||
func setPersonality(_ value: String) { setSetting("display.personality", value: value) }
|
||||
func setTerminalBackend(_ value: String) { setSetting("terminal.backend", value: value) }
|
||||
func setMaxTurns(_ value: Int) { setSetting("agent.max_turns", value: String(value)) }
|
||||
func setMemoryEnabled(_ value: Bool) { setSetting("memory.memory_enabled", value: value ? "true" : "false") }
|
||||
func setMemoryCharLimit(_ value: Int) { setSetting("memory.memory_char_limit", value: String(value)) }
|
||||
func setUserCharLimit(_ value: Int) { setSetting("memory.user_char_limit", value: String(value)) }
|
||||
func setNudgeInterval(_ value: Int) { setSetting("memory.nudge_interval", value: String(value)) }
|
||||
func setStreaming(_ value: Bool) { setSetting("display.streaming", value: value ? "true" : "false") }
|
||||
func setShowReasoning(_ value: Bool) { setSetting("display.show_reasoning", value: value ? "true" : "false") }
|
||||
func setVerbose(_ value: Bool) { setSetting("agent.verbose", value: value ? "true" : "false") }
|
||||
func setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") }
|
||||
func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) }
|
||||
|
||||
func openConfigInEditor() {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||
}
|
||||
|
||||
private func parsePersonalities() -> [String] {
|
||||
var names: [String] = []
|
||||
var inPersonalities = false
|
||||
for line in rawConfigYAML.components(separatedBy: "\n") {
|
||||
if line.trimmingCharacters(in: .whitespaces) == "personalities:" && line.hasPrefix(" ") {
|
||||
inPersonalities = true
|
||||
continue
|
||||
}
|
||||
if inPersonalities {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty { continue }
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
if indent <= 2 && !trimmed.isEmpty {
|
||||
inPersonalities = false
|
||||
continue
|
||||
}
|
||||
if indent == 4 && trimmed.contains(":") {
|
||||
let name = String(trimmed.split(separator: ":")[0])
|
||||
names.append(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = arguments
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||
} catch {
|
||||
return ("", -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,13 @@ struct SettingsView: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
configSection
|
||||
gatewaySection
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
headerBar
|
||||
modelSection
|
||||
displaySection
|
||||
terminalSection
|
||||
voiceSection
|
||||
memorySection
|
||||
pathsSection
|
||||
rawConfigSection
|
||||
}
|
||||
@@ -19,62 +23,90 @@ struct SettingsView: View {
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var configSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Configuration")
|
||||
.font(.headline)
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
|
||||
SettingRow(label: "Model", value: viewModel.config.model)
|
||||
SettingRow(label: "Provider", value: viewModel.config.provider)
|
||||
SettingRow(label: "Personality", value: viewModel.config.personality)
|
||||
SettingRow(label: "Max Turns", value: "\(viewModel.config.maxTurns)")
|
||||
SettingRow(label: "Terminal Backend", value: viewModel.config.terminalBackend)
|
||||
SettingRow(label: "Memory Enabled", value: viewModel.config.memoryEnabled ? "Yes" : "No")
|
||||
SettingRow(label: "Memory Char Limit", value: "\(viewModel.config.memoryCharLimit)")
|
||||
SettingRow(label: "User Char Limit", value: "\(viewModel.config.userCharLimit)")
|
||||
SettingRow(label: "Nudge Interval", value: "\(viewModel.config.nudgeInterval) turns")
|
||||
SettingRow(label: "Streaming", value: viewModel.config.streaming ? "Yes" : "No")
|
||||
SettingRow(label: "Show Reasoning", value: viewModel.config.showReasoning ? "Yes" : "No")
|
||||
SettingRow(label: "Verbose", value: viewModel.config.verbose ? "Yes" : "No")
|
||||
private var headerBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.saveMessage {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Open in Editor") { viewModel.openConfigInEditor() }
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewaySection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Gateway")
|
||||
.font(.headline)
|
||||
HStack(spacing: 16) {
|
||||
Label(
|
||||
viewModel.gatewayState?.statusText ?? "unknown",
|
||||
systemImage: viewModel.gatewayState?.isRunning == true ? "circle.fill" : "circle"
|
||||
)
|
||||
.foregroundStyle(viewModel.gatewayState?.isRunning == true ? .green : .secondary)
|
||||
if let reason = viewModel.gatewayState?.exitReason {
|
||||
Text(reason)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
// MARK: - Model & Provider
|
||||
|
||||
private var modelSection: some View {
|
||||
SettingsSection(title: "Model", icon: "cpu") {
|
||||
EditableTextField(label: "Model", value: viewModel.config.model) { viewModel.setModel($0) }
|
||||
PickerRow(label: "Provider", selection: viewModel.config.provider, options: viewModel.providers) { viewModel.setProvider($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Display
|
||||
|
||||
private var displaySection: some View {
|
||||
SettingsSection(title: "Display", icon: "paintbrush") {
|
||||
if !viewModel.personalities.isEmpty {
|
||||
PickerRow(label: "Personality", selection: viewModel.config.personality, options: viewModel.personalities) { viewModel.setPersonality($0) }
|
||||
} else {
|
||||
EditableTextField(label: "Personality", value: viewModel.config.personality) { viewModel.setPersonality($0) }
|
||||
}
|
||||
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
|
||||
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
|
||||
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Terminal
|
||||
|
||||
private var terminalSection: some View {
|
||||
SettingsSection(title: "Terminal", icon: "terminal") {
|
||||
PickerRow(label: "Backend", selection: viewModel.config.terminalBackend, options: viewModel.terminalBackends) { viewModel.setTerminalBackend($0) }
|
||||
StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voice
|
||||
|
||||
private var voiceSection: some View {
|
||||
SettingsSection(title: "Voice", icon: "mic") {
|
||||
ToggleRow(label: "Auto TTS", isOn: viewModel.config.autoTTS) { viewModel.setAutoTTS($0) }
|
||||
StepperRow(label: "Silence Threshold", value: viewModel.config.silenceThreshold, range: 50...500) { viewModel.setSilenceThreshold($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Memory
|
||||
|
||||
private var memorySection: some View {
|
||||
SettingsSection(title: "Memory", icon: "brain") {
|
||||
ToggleRow(label: "Memory Enabled", isOn: viewModel.config.memoryEnabled) { viewModel.setMemoryEnabled($0) }
|
||||
StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10000) { viewModel.setMemoryCharLimit($0) }
|
||||
StepperRow(label: "User Char Limit", value: viewModel.config.userCharLimit, range: 500...10000) { viewModel.setUserCharLimit($0) }
|
||||
StepperRow(label: "Nudge Interval", value: viewModel.config.nudgeInterval, range: 1...50) { viewModel.setNudgeInterval($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paths
|
||||
|
||||
private var pathsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Paths")
|
||||
.font(.headline)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
PathRow(label: "Hermes Home", path: HermesPaths.home)
|
||||
PathRow(label: "State DB", path: HermesPaths.stateDB)
|
||||
PathRow(label: "Config", path: HermesPaths.configYAML)
|
||||
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
|
||||
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
|
||||
PathRow(label: "Skills", path: HermesPaths.skillsDir)
|
||||
PathRow(label: "Logs", path: HermesPaths.errorsLog)
|
||||
}
|
||||
SettingsSection(title: "Paths", icon: "folder") {
|
||||
PathRow(label: "Hermes Home", path: HermesPaths.home)
|
||||
PathRow(label: "State DB", path: HermesPaths.stateDB)
|
||||
PathRow(label: "Config", path: HermesPaths.configYAML)
|
||||
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
|
||||
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
|
||||
PathRow(label: "Skills", path: HermesPaths.skillsDir)
|
||||
PathRow(label: "Logs", path: HermesPaths.errorsLog)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Raw Config
|
||||
|
||||
private var rawConfigSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
@@ -98,19 +130,143 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingRow: View {
|
||||
// MARK: - Reusable Components
|
||||
|
||||
struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
let icon: String
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label(title, systemImage: icon)
|
||||
.font(.headline)
|
||||
VStack(spacing: 1) {
|
||||
content
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EditableTextField: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let onCommit: (String) -> Void
|
||||
@State private var text: String = ""
|
||||
@State private var isEditing = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 120, alignment: .trailing)
|
||||
Text(value)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
if isEditing {
|
||||
TextField(label, text: $text, onCommit: {
|
||||
if text != value { onCommit(text) }
|
||||
isEditing = false
|
||||
})
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Button("Cancel") { isEditing = false }
|
||||
.controlSize(.mini)
|
||||
} else {
|
||||
Text(value)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Spacer()
|
||||
Button("Edit") {
|
||||
text = value
|
||||
isEditing = true
|
||||
}
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct PickerRow: View {
|
||||
let label: String
|
||||
let selection: String
|
||||
let options: [String]
|
||||
let onChange: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Picker("", selection: Binding(
|
||||
get: { selection },
|
||||
set: { onChange($0) }
|
||||
)) {
|
||||
ForEach(options, id: \.self) { option in
|
||||
Text(option).tag(option)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 250)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct ToggleRow: View {
|
||||
let label: String
|
||||
let isOn: Bool
|
||||
let onChange: (Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Toggle("", isOn: Binding(
|
||||
get: { isOn },
|
||||
set: { onChange($0) }
|
||||
))
|
||||
.toggleStyle(.switch)
|
||||
.labelsHidden()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct StepperRow: View {
|
||||
let label: String
|
||||
let value: Int
|
||||
let range: ClosedRange<Int>
|
||||
let onChange: (Int) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Text("\(value)")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(width: 50)
|
||||
Stepper("", value: Binding(
|
||||
get: { value },
|
||||
set: { onChange($0) }
|
||||
), in: range)
|
||||
.labelsHidden()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,10 +279,11 @@ struct PathRow: View {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 100, alignment: .trailing)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Text(path)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
Spacer()
|
||||
Button {
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
|
||||
} label: {
|
||||
@@ -135,5 +292,8 @@ struct PathRow: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user