Merge pull request #4 from awizemann/development

Config Editor and Voice Fixes
This commit is contained in:
Alan Wizemann
2026-03-31 14:35:33 -04:00
committed by GitHub
4 changed files with 296 additions and 54 deletions
+3 -1
View File
@@ -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))
}
}