agentix-cli 0.56.0 → 0.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +43 -0
- package/apps/mac-voice/Sources/AgentXVoice/Config.swift +19 -5
- package/apps/mac-voice/Sources/AgentXVoice/Hotkey.swift +8 -2
- package/apps/mac-voice/Sources/AgentXVoice/HotkeySpec.swift +80 -0
- package/apps/mac-voice/Sources/AgentXVoice/Orb.swift +131 -169
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +174 -35
- package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +70 -0
- package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +26 -22
- package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +518 -0
- package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +8 -2
- package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +22 -6
- package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +101 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +132 -79
- package/dist/{App-4EM7IPB3.js → App-Z5VOAUXB.js} +2 -2
- package/dist/{ChatApp-FYTEEGSU.js → ChatApp-BE7ONQKS.js} +2 -2
- package/dist/{agent-RZ46ZB4W.js → agent-Q74EISKP.js} +2 -2
- package/dist/{board-dashboard-NRRYCDZW.js → board-dashboard-A7PQOI6C.js} +4 -4
- package/dist/{chunk-W4YMMWJL.js → chunk-2MIOMYT2.js} +2 -2
- package/dist/{chunk-EZN2YXRY.js → chunk-547G3HYQ.js} +2 -2
- package/dist/{chunk-LKJSPO3S.js → chunk-5LLGIX5M.js} +2 -2
- package/dist/{chunk-WOMQ2VGA.js → chunk-5W7NJHCS.js} +2 -2
- package/dist/{chunk-4HEVSWO3.js → chunk-6OABTCRA.js} +2 -2
- package/dist/{chunk-UIVHSYCN.js → chunk-6QNPCVRN.js} +3 -3
- package/dist/chunk-6QNPCVRN.js.map +1 -0
- package/dist/{chunk-CELYD5QW.js → chunk-CQUVUVXR.js} +2 -2
- package/dist/chunk-ERL6AS3O.js +15 -0
- package/dist/chunk-ERL6AS3O.js.map +1 -0
- package/dist/{chunk-EJ5ISSWW.js → chunk-I2LYULR4.js} +5 -5
- package/dist/{chunk-76AHNHWC.js → chunk-KKGFZEXS.js} +2 -2
- package/dist/{chunk-6AK6WKE5.js → chunk-KW7TIOS3.js} +2 -2
- package/dist/{chunk-SA23V7WX.js → chunk-NKMFHEGV.js} +2 -2
- package/dist/{chunk-WKMTT2UY.js → chunk-OZZPJ2YG.js} +197 -188
- package/dist/chunk-OZZPJ2YG.js.map +1 -0
- package/dist/{chunk-IL2XLW4H.js → chunk-POL7RAJQ.js} +13 -13
- package/dist/{chunk-D3LSPFHK.js → chunk-Q6SMLSRO.js} +2 -2
- package/dist/{chunk-723K4SAW.js → chunk-RXEAACVH.js} +2 -2
- package/dist/{chunk-O3XG2NVR.js → chunk-U5YBQJ5Q.js} +2 -2
- package/dist/{chunk-XEOAKD5C.js → chunk-V7ZK6ZIO.js} +2 -2
- package/dist/{chunk-M4AG2YNX.js → chunk-VI7GBDKV.js} +2 -2
- package/dist/{chunk-GSOL2LW6.js → chunk-VVBGPT6W.js} +2 -2
- package/dist/{chunk-C3VRBEJ7.js → chunk-XTD3SZIP.js} +2 -2
- package/dist/cli.js +1 -1
- package/dist/client-EFP2PKA3.js +2 -0
- package/dist/config-P6YXC6XZ.js +2 -0
- package/dist/config-mutator-3GKXLTHH.js +2 -0
- package/dist/doctor-JEWMHXYH.js +2 -0
- package/dist/index.d.ts +109 -3
- package/dist/index.js +1 -1
- package/dist/program-7DUQJDSQ.js +2 -0
- package/dist/{promote-T4UJROEH.js → promote-KVPLDSKT.js} +2 -2
- package/dist/query-AGOMCYTT.js +2 -0
- package/dist/{registry-DX2RI7I7.js → registry-AUOI4TW2.js} +2 -2
- package/dist/{request-planner-B7DE7VJR.js → request-planner-LMZ5ZAXC.js} +2 -2
- package/dist/{review-candidates-4YJHIBAF.js → review-candidates-WCBXLLDS.js} +2 -2
- package/dist/{routing-MZG3UODH.js → routing-FBASYKUL.js} +2 -2
- package/dist/schedule-tool-RJRJ63SS.js +2 -0
- package/dist/scheduler-ATA7Z44S.js +2 -0
- package/dist/{seat-2PEI7JRX.js → seat-DBFW3Z5P.js} +2 -2
- package/dist/{select-4KIUR27O.js → select-6CNVVFA5.js} +2 -2
- package/dist/{set-enabled-SE7HQKPF.js → set-enabled-AYZH3KSB.js} +2 -2
- package/dist/{tool-5VMD7IE4.js → tool-PKS4NBZ6.js} +2 -2
- package/dist/wiki-QW6BDNEQ.js +2 -0
- package/package.json +1 -1
- package/dist/chunk-U4H35SJS.js +0 -15
- package/dist/chunk-U4H35SJS.js.map +0 -1
- package/dist/chunk-UIVHSYCN.js.map +0 -1
- package/dist/chunk-WKMTT2UY.js.map +0 -1
- package/dist/client-IA7ZPE56.js +0 -2
- package/dist/config-HPN2F7LH.js +0 -2
- package/dist/config-mutator-7GPTMTVB.js +0 -2
- package/dist/doctor-IBWQVQV4.js +0 -2
- package/dist/program-G6DIFJUF.js +0 -2
- package/dist/query-VUJC4Y5O.js +0 -2
- package/dist/schedule-tool-A4OFQ4YZ.js +0 -2
- package/dist/scheduler-RHJW2V4M.js +0 -2
- package/dist/wiki-2QFLAH4U.js +0 -2
- /package/dist/{App-4EM7IPB3.js.map → App-Z5VOAUXB.js.map} +0 -0
- /package/dist/{ChatApp-FYTEEGSU.js.map → ChatApp-BE7ONQKS.js.map} +0 -0
- /package/dist/{agent-RZ46ZB4W.js.map → agent-Q74EISKP.js.map} +0 -0
- /package/dist/{board-dashboard-NRRYCDZW.js.map → board-dashboard-A7PQOI6C.js.map} +0 -0
- /package/dist/{chunk-W4YMMWJL.js.map → chunk-2MIOMYT2.js.map} +0 -0
- /package/dist/{chunk-EZN2YXRY.js.map → chunk-547G3HYQ.js.map} +0 -0
- /package/dist/{chunk-LKJSPO3S.js.map → chunk-5LLGIX5M.js.map} +0 -0
- /package/dist/{chunk-WOMQ2VGA.js.map → chunk-5W7NJHCS.js.map} +0 -0
- /package/dist/{chunk-4HEVSWO3.js.map → chunk-6OABTCRA.js.map} +0 -0
- /package/dist/{chunk-CELYD5QW.js.map → chunk-CQUVUVXR.js.map} +0 -0
- /package/dist/{chunk-EJ5ISSWW.js.map → chunk-I2LYULR4.js.map} +0 -0
- /package/dist/{chunk-76AHNHWC.js.map → chunk-KKGFZEXS.js.map} +0 -0
- /package/dist/{chunk-6AK6WKE5.js.map → chunk-KW7TIOS3.js.map} +0 -0
- /package/dist/{chunk-SA23V7WX.js.map → chunk-NKMFHEGV.js.map} +0 -0
- /package/dist/{chunk-IL2XLW4H.js.map → chunk-POL7RAJQ.js.map} +0 -0
- /package/dist/{chunk-D3LSPFHK.js.map → chunk-Q6SMLSRO.js.map} +0 -0
- /package/dist/{chunk-723K4SAW.js.map → chunk-RXEAACVH.js.map} +0 -0
- /package/dist/{chunk-O3XG2NVR.js.map → chunk-U5YBQJ5Q.js.map} +0 -0
- /package/dist/{chunk-XEOAKD5C.js.map → chunk-V7ZK6ZIO.js.map} +0 -0
- /package/dist/{chunk-M4AG2YNX.js.map → chunk-VI7GBDKV.js.map} +0 -0
- /package/dist/{chunk-GSOL2LW6.js.map → chunk-VVBGPT6W.js.map} +0 -0
- /package/dist/{chunk-C3VRBEJ7.js.map → chunk-XTD3SZIP.js.map} +0 -0
- /package/dist/{client-IA7ZPE56.js.map → client-EFP2PKA3.js.map} +0 -0
- /package/dist/{config-HPN2F7LH.js.map → config-P6YXC6XZ.js.map} +0 -0
- /package/dist/{config-mutator-7GPTMTVB.js.map → config-mutator-3GKXLTHH.js.map} +0 -0
- /package/dist/{doctor-IBWQVQV4.js.map → doctor-JEWMHXYH.js.map} +0 -0
- /package/dist/{program-G6DIFJUF.js.map → program-7DUQJDSQ.js.map} +0 -0
- /package/dist/{promote-T4UJROEH.js.map → promote-KVPLDSKT.js.map} +0 -0
- /package/dist/{query-VUJC4Y5O.js.map → query-AGOMCYTT.js.map} +0 -0
- /package/dist/{registry-DX2RI7I7.js.map → registry-AUOI4TW2.js.map} +0 -0
- /package/dist/{request-planner-B7DE7VJR.js.map → request-planner-LMZ5ZAXC.js.map} +0 -0
- /package/dist/{review-candidates-4YJHIBAF.js.map → review-candidates-WCBXLLDS.js.map} +0 -0
- /package/dist/{routing-MZG3UODH.js.map → routing-FBASYKUL.js.map} +0 -0
- /package/dist/{schedule-tool-A4OFQ4YZ.js.map → schedule-tool-RJRJ63SS.js.map} +0 -0
- /package/dist/{scheduler-RHJW2V4M.js.map → scheduler-ATA7Z44S.js.map} +0 -0
- /package/dist/{seat-2PEI7JRX.js.map → seat-DBFW3Z5P.js.map} +0 -0
- /package/dist/{select-4KIUR27O.js.map → select-6CNVVFA5.js.map} +0 -0
- /package/dist/{set-enabled-SE7HQKPF.js.map → set-enabled-AYZH3KSB.js.map} +0 -0
- /package/dist/{tool-5VMD7IE4.js.map → tool-PKS4NBZ6.js.map} +0 -0
- /package/dist/{wiki-2QFLAH4U.js.map → wiki-QW6BDNEQ.js.map} +0 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import Carbon.HIToolbox
|
|
3
|
+
import ServiceManagement
|
|
4
|
+
import SwiftUI
|
|
5
|
+
|
|
6
|
+
/// Settings: per-agent voices and the app's own shortcuts and engines.
|
|
7
|
+
///
|
|
8
|
+
/// Everything but launch at login lives in agentx.json and goes through
|
|
9
|
+
/// the daemon, which checks each value against the config schema before
|
|
10
|
+
/// writing. The window shows what the daemon has and keeps no copy: it
|
|
11
|
+
/// loads fresh every time it opens and after every save.
|
|
12
|
+
@MainActor
|
|
13
|
+
final class SettingsModel: ObservableObject {
|
|
14
|
+
@Published var saved: VoiceSettings?
|
|
15
|
+
@Published var draft: VoiceSettings?
|
|
16
|
+
@Published var selected: String?
|
|
17
|
+
@Published var loading = false
|
|
18
|
+
@Published var saving = false
|
|
19
|
+
@Published var error = ""
|
|
20
|
+
@Published var notice = ""
|
|
21
|
+
@Published var launchAtLogin = LoginItem.isOn
|
|
22
|
+
/// "agents" or "general".
|
|
23
|
+
@Published var tab = "agents"
|
|
24
|
+
/// A shortcut field is waiting for keys: the app's own shortcuts are
|
|
25
|
+
/// off meanwhile, or they would fire instead of being recorded.
|
|
26
|
+
@Published var recording: String? {
|
|
27
|
+
didSet { if (recording == nil) != (oldValue == nil) { onRecording?(recording != nil) } }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
var onSaved: ((VoiceSettings) -> Void)?
|
|
31
|
+
var onRecording: ((Bool) -> Void)?
|
|
32
|
+
|
|
33
|
+
var hasChanges: Bool {
|
|
34
|
+
guard let saved, let draft else { return false }
|
|
35
|
+
return !draft.patch(from: saved).isEmpty
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func load() {
|
|
39
|
+
loading = true
|
|
40
|
+
error = ""
|
|
41
|
+
Task {
|
|
42
|
+
let s = await AgentClient.settings()
|
|
43
|
+
loading = false
|
|
44
|
+
guard let s else {
|
|
45
|
+
error = "The AgentX daemon isn't reachable. Start it, then open Settings again."
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
saved = s
|
|
49
|
+
draft = s
|
|
50
|
+
if selected == nil || !s.agents.contains(where: { $0.id == selected }) { selected = s.agents.first?.id }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func revert() {
|
|
55
|
+
draft = saved
|
|
56
|
+
error = ""
|
|
57
|
+
notice = ""
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func save() {
|
|
61
|
+
guard let saved, let draft, hasChanges else { return }
|
|
62
|
+
saving = true
|
|
63
|
+
error = ""
|
|
64
|
+
notice = ""
|
|
65
|
+
Task {
|
|
66
|
+
let result = await AgentClient.saveSettings(draft.patch(from: saved))
|
|
67
|
+
saving = false
|
|
68
|
+
switch result {
|
|
69
|
+
case .success(let s):
|
|
70
|
+
self.saved = s
|
|
71
|
+
self.draft = s
|
|
72
|
+
notice = "Saved. The next line spoken uses it; no restart needed."
|
|
73
|
+
onSaved?(s)
|
|
74
|
+
case .failure(let f):
|
|
75
|
+
error = f.message
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func preview(_ id: String) {
|
|
81
|
+
guard let draft else { return }
|
|
82
|
+
error = ""
|
|
83
|
+
notice = "Playing a sample…"
|
|
84
|
+
Task {
|
|
85
|
+
if let problem = await AgentClient.preview(agentID: id, voice: draft.previewVoice(for: id)) {
|
|
86
|
+
notice = ""
|
|
87
|
+
error = problem
|
|
88
|
+
} else {
|
|
89
|
+
notice = "Playing a sample. It is not saved until you press Save."
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func setLaunchAtLogin(_ on: Bool) {
|
|
95
|
+
do {
|
|
96
|
+
try LoginItem.set(on)
|
|
97
|
+
error = ""
|
|
98
|
+
} catch {
|
|
99
|
+
self.error = "macOS didn't change the login item: \(error.localizedDescription)"
|
|
100
|
+
}
|
|
101
|
+
launchAtLogin = LoginItem.isOn
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// A binding into the draft for the agent `id`.
|
|
105
|
+
func agent(_ id: String) -> Binding<VoiceSettings.Agent>? {
|
|
106
|
+
guard let i = draft?.agents.firstIndex(where: { $0.id == id }) else { return nil }
|
|
107
|
+
return Binding(get: { self.draft!.agents[i] }, set: { self.draft!.agents[i] = $0 })
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Launch at login is macOS's own setting, never agentx.json.
|
|
112
|
+
enum LoginItem {
|
|
113
|
+
/// The launchd label `agentx desktop install` gives the app.
|
|
114
|
+
static let installerLabel = "tn.acme.agentx.voice"
|
|
115
|
+
|
|
116
|
+
/// Installed with `agentx desktop install`, which starts the app at
|
|
117
|
+
/// login with launchd. A second login item would start a second copy.
|
|
118
|
+
static var managedByInstaller: Bool {
|
|
119
|
+
if ProcessInfo.processInfo.environment["XPC_SERVICE_NAME"] == installerLabel { return true }
|
|
120
|
+
let plist = "\(NSHomeDirectory())/Library/LaunchAgents/\(installerLabel).plist"
|
|
121
|
+
return FileManager.default.fileExists(atPath: plist)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
static var isOn: Bool { managedByInstaller || SMAppService.mainApp.status == .enabled }
|
|
125
|
+
|
|
126
|
+
static func set(_ on: Bool) throws {
|
|
127
|
+
if on { try SMAppService.mainApp.register() } else { try SMAppService.mainApp.unregister() }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
struct SettingsView: View {
|
|
132
|
+
@ObservedObject var model: SettingsModel
|
|
133
|
+
|
|
134
|
+
var body: some View {
|
|
135
|
+
VStack(spacing: 0) {
|
|
136
|
+
if let draft = model.draft {
|
|
137
|
+
TabView(selection: $model.tab) {
|
|
138
|
+
AgentsTab(model: model, settings: draft)
|
|
139
|
+
.tabItem { Text("Agents") }
|
|
140
|
+
.tag("agents")
|
|
141
|
+
GeneralTab(model: model)
|
|
142
|
+
.tabItem { Text("General") }
|
|
143
|
+
.tag("general")
|
|
144
|
+
}
|
|
145
|
+
.padding([.horizontal, .top], 12)
|
|
146
|
+
} else {
|
|
147
|
+
VStack(spacing: 12) {
|
|
148
|
+
if model.loading { ProgressView() }
|
|
149
|
+
Text(model.loading ? "Loading settings…" : model.error)
|
|
150
|
+
.foregroundStyle(.secondary)
|
|
151
|
+
if !model.loading { Button("Try again") { model.load() } }
|
|
152
|
+
}
|
|
153
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
154
|
+
}
|
|
155
|
+
if model.draft != nil { footer }
|
|
156
|
+
}
|
|
157
|
+
.frame(width: 660, height: 640)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private var footer: some View {
|
|
161
|
+
HStack(alignment: .top, spacing: 12) {
|
|
162
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
163
|
+
if !model.error.isEmpty {
|
|
164
|
+
Text(model.error)
|
|
165
|
+
.foregroundStyle(Color(nsColor: .systemRed))
|
|
166
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
167
|
+
.textSelection(.enabled)
|
|
168
|
+
} else if !model.notice.isEmpty {
|
|
169
|
+
Text(model.notice).foregroundStyle(.secondary)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
.font(.callout)
|
|
173
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
174
|
+
Button("Revert") { model.revert() }
|
|
175
|
+
.disabled(!model.hasChanges || model.saving)
|
|
176
|
+
Button(model.saving ? "Saving…" : "Save") { model.save() }
|
|
177
|
+
.keyboardShortcut("s", modifiers: .command)
|
|
178
|
+
.disabled(!model.hasChanges || model.saving)
|
|
179
|
+
}
|
|
180
|
+
.padding(12)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// MARK: Agents
|
|
185
|
+
|
|
186
|
+
private struct AgentsTab: View {
|
|
187
|
+
@ObservedObject var model: SettingsModel
|
|
188
|
+
let settings: VoiceSettings
|
|
189
|
+
|
|
190
|
+
var body: some View {
|
|
191
|
+
HStack(spacing: 0) {
|
|
192
|
+
List(settings.agents, selection: $model.selected) { agent in
|
|
193
|
+
HStack(spacing: 8) {
|
|
194
|
+
Circle().fill(Color(hex: agent.color)).frame(width: 10, height: 10)
|
|
195
|
+
Text(agent.name)
|
|
196
|
+
}
|
|
197
|
+
.tag(agent.id)
|
|
198
|
+
}
|
|
199
|
+
.frame(width: 170)
|
|
200
|
+
Divider()
|
|
201
|
+
if let id = model.selected, let agent = model.agent(id) {
|
|
202
|
+
AgentForm(model: model, agent: agent, settings: settings)
|
|
203
|
+
} else {
|
|
204
|
+
Text("No agents in agentx.json").foregroundStyle(.secondary)
|
|
205
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private struct AgentForm: View {
|
|
212
|
+
@ObservedObject var model: SettingsModel
|
|
213
|
+
@Binding var agent: VoiceSettings.Agent
|
|
214
|
+
let settings: VoiceSettings
|
|
215
|
+
|
|
216
|
+
private var provider: String { agent.voice.provider ?? settings.general.provider }
|
|
217
|
+
|
|
218
|
+
var body: some View {
|
|
219
|
+
Form {
|
|
220
|
+
voiceSection
|
|
221
|
+
workSection
|
|
222
|
+
shortcutSection
|
|
223
|
+
}
|
|
224
|
+
.formStyle(.grouped)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private var voiceSection: some View {
|
|
228
|
+
Section {
|
|
229
|
+
providerPicker
|
|
230
|
+
systemVoicePicker
|
|
231
|
+
elevenLabsField
|
|
232
|
+
previewRow
|
|
233
|
+
rateRow
|
|
234
|
+
} header: {
|
|
235
|
+
Text("Voice")
|
|
236
|
+
} footer: {
|
|
237
|
+
Text("Voice changes apply to the next line this agent speaks. ElevenLabs speaks at most 1.2× and at least 0.7×.")
|
|
238
|
+
.font(.caption).foregroundStyle(.secondary)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private var providerPicker: some View {
|
|
243
|
+
let fallback = settings.general.provider == "elevenlabs" ? "ElevenLabs" : "Mac voices"
|
|
244
|
+
return Picker("Voice provider", selection: $agent.voice.provider) {
|
|
245
|
+
Text("Default (\(fallback))").tag(String?.none)
|
|
246
|
+
Text("Mac voices (free)").tag(String?.some("system"))
|
|
247
|
+
Text("ElevenLabs").tag(String?.some("elevenlabs"))
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/// A configured voice the Mac doesn't list (a plain name such as
|
|
252
|
+
/// "Daniel"), kept selectable so the picker can show it.
|
|
253
|
+
private var unlistedVoice: String? {
|
|
254
|
+
guard let s = agent.voice.system, s != "system",
|
|
255
|
+
!settings.systemVoices.contains(where: { $0.id == s }) else { return nil }
|
|
256
|
+
return s
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
@ViewBuilder private var systemVoicePicker: some View {
|
|
260
|
+
if agent.voice.systemPerLanguage {
|
|
261
|
+
LabeledContent("Mac voice", value: "One voice per language, set in agentx.json")
|
|
262
|
+
} else {
|
|
263
|
+
Picker("Mac voice", selection: $agent.voice.system) {
|
|
264
|
+
Text("Assigned automatically").tag(String?.none)
|
|
265
|
+
Text("The Mac's default voice").tag(String?.some("system"))
|
|
266
|
+
if let s = unlistedVoice { Text(s).tag(String?.some(s)) }
|
|
267
|
+
ForEach(settings.systemVoices) { v in
|
|
268
|
+
Text(v.label).tag(String?.some(v.id))
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private var elevenLabsField: some View {
|
|
275
|
+
let text = Binding<String>(
|
|
276
|
+
get: { agent.voice.elevenlabsVoiceId ?? "" },
|
|
277
|
+
set: { agent.voice.elevenlabsVoiceId = $0.isEmpty ? nil : $0 })
|
|
278
|
+
return TextField("ElevenLabs voice ID", text: text, prompt: Text("Default voice"))
|
|
279
|
+
.disabled(provider != "elevenlabs")
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private var previewRow: some View {
|
|
283
|
+
let now: String = agent.speaks.provider == "elevenlabs" ? "ElevenLabs" : (agent.speaks.systemVoice ?? "the Mac's default voice")
|
|
284
|
+
return HStack {
|
|
285
|
+
Text("Saved voice: \(now)").font(.caption).foregroundStyle(.secondary)
|
|
286
|
+
Spacer()
|
|
287
|
+
Button("Preview") { model.preview(agent.id) }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private var rateRow: some View {
|
|
292
|
+
let rate = Binding<Double>(
|
|
293
|
+
get: { agent.voice.rate ?? 1 },
|
|
294
|
+
set: { v in
|
|
295
|
+
let snapped = (v * 20).rounded() / 20
|
|
296
|
+
agent.voice.rate = abs(snapped - 1) < 0.001 ? nil : snapped
|
|
297
|
+
})
|
|
298
|
+
return HStack {
|
|
299
|
+
Slider(value: rate, in: 0.75...1.5, step: 0.05) { Text("Speaking speed") }
|
|
300
|
+
Text(String(format: "%.2f×", agent.voice.rate ?? 1)).monospacedDigit().frame(width: 48)
|
|
301
|
+
Button("Normal") { agent.voice.rate = nil }.disabled(agent.voice.rate == nil)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private var workSection: some View {
|
|
306
|
+
// "off" written out and unset mean the same: both show as Off.
|
|
307
|
+
let narrate = Binding<String?>(
|
|
308
|
+
get: { agent.voice.narrate == "off" ? nil : agent.voice.narrate },
|
|
309
|
+
set: { agent.voice.narrate = $0 })
|
|
310
|
+
return Section("While it works") {
|
|
311
|
+
Picker("Narration", selection: narrate) {
|
|
312
|
+
Text("Off").tag(String?.none)
|
|
313
|
+
Text("On, except scheduled jobs").tag(String?.some("on"))
|
|
314
|
+
Text("On, scheduled jobs too").tag(String?.some("all"))
|
|
315
|
+
}
|
|
316
|
+
Picker("Queue priority", selection: $agent.voice.priority) {
|
|
317
|
+
Text("Normal").tag(String?.none)
|
|
318
|
+
Text("High: ahead of waiting lines").tag(String?.some("high"))
|
|
319
|
+
Text("Low: after waiting lines").tag(String?.some("low"))
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private var shortcutSection: some View {
|
|
325
|
+
let color = Binding<Color>(
|
|
326
|
+
get: { Color(hex: agent.color) },
|
|
327
|
+
set: { agent.color = $0.hex; agent.colorSet = true })
|
|
328
|
+
let footer = agent.colorSet
|
|
329
|
+
? "Hold the shortcut and speak to ask this agent without changing who is ticked in the menu. The colour also paints this agent's pointer."
|
|
330
|
+
: "Hold the shortcut and speak to ask this agent without changing who is ticked in the menu. The colour comes from the agent's id until you pick one."
|
|
331
|
+
return Section {
|
|
332
|
+
HotkeyField(title: "Ask with shortcut", value: $agent.voice.hotkey, optional: true, model: model, id: "agent.\(agent.id)")
|
|
333
|
+
HStack {
|
|
334
|
+
ColorPicker("Orb colour", selection: color, supportsOpacity: false)
|
|
335
|
+
Button("Use default") { agent.colorSet = false }.disabled(!agent.colorSet)
|
|
336
|
+
}
|
|
337
|
+
} header: {
|
|
338
|
+
Text("Shortcut and colour")
|
|
339
|
+
} footer: {
|
|
340
|
+
Text(footer).font(.caption).foregroundStyle(.secondary)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// MARK: General
|
|
346
|
+
|
|
347
|
+
private struct GeneralTab: View {
|
|
348
|
+
@ObservedObject var model: SettingsModel
|
|
349
|
+
|
|
350
|
+
var body: some View {
|
|
351
|
+
Form {
|
|
352
|
+
if let draft = model.draft {
|
|
353
|
+
Section {
|
|
354
|
+
HotkeyField(title: "Talk (hold)", value: nonOptional(\.talk, draft.general.hotkeys.talk), optional: false, model: model, id: "talk")
|
|
355
|
+
HotkeyField(title: "Stop every voice", value: nonOptional(\.stop, draft.general.hotkeys.stop), optional: false, model: model, id: "stop")
|
|
356
|
+
HotkeyField(title: "Smart paste", value: nonOptional(\.paste, draft.general.hotkeys.paste), optional: false, model: model, id: "paste")
|
|
357
|
+
LabeledContent("Open the menu", value: HotkeySpec.display(draft.menuHotkey))
|
|
358
|
+
} header: {
|
|
359
|
+
Text("Shortcuts")
|
|
360
|
+
} footer: {
|
|
361
|
+
Text("Shortcuts change as soon as you save. If one does nothing, another app already uses it.")
|
|
362
|
+
.font(.caption).foregroundStyle(.secondary)
|
|
363
|
+
}
|
|
364
|
+
Section {
|
|
365
|
+
Picker("Speech to text", selection: binding(\.stt)) {
|
|
366
|
+
Text("Automatic").tag("auto")
|
|
367
|
+
Text("ElevenLabs").tag("elevenlabs")
|
|
368
|
+
Text("On this Mac (Whisper)").tag("local")
|
|
369
|
+
}
|
|
370
|
+
Picker("Default voice provider", selection: binding(\.provider)) {
|
|
371
|
+
Text("Mac voices (free)").tag("system")
|
|
372
|
+
Text("ElevenLabs").tag("elevenlabs")
|
|
373
|
+
}
|
|
374
|
+
} header: {
|
|
375
|
+
Text("Speech")
|
|
376
|
+
} footer: {
|
|
377
|
+
Text("Automatic uses ElevenLabs when a key is set, and Whisper on this Mac otherwise. The default provider is for agents that don't choose their own. Both apply to the next question.")
|
|
378
|
+
.font(.caption).foregroundStyle(.secondary)
|
|
379
|
+
}
|
|
380
|
+
Section {
|
|
381
|
+
Toggle("Launch at login", isOn: Binding(get: { model.launchAtLogin }, set: { model.setLaunchAtLogin($0) }))
|
|
382
|
+
.disabled(LoginItem.managedByInstaller)
|
|
383
|
+
} footer: {
|
|
384
|
+
Text(LoginItem.managedByInstaller
|
|
385
|
+
? "Started at login by `agentx desktop install`. To stop that, run `agentx desktop stop` in Terminal."
|
|
386
|
+
: "Saved by macOS as a login item, not in agentx.json. Changes right away.")
|
|
387
|
+
.font(.caption).foregroundStyle(.secondary)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
.formStyle(.grouped)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private func binding(_ path: WritableKeyPath<VoiceSettings.General, String>) -> Binding<String> {
|
|
395
|
+
Binding(get: { model.draft?.general[keyPath: path] ?? "" },
|
|
396
|
+
set: { model.draft?.general[keyPath: path] = $0 })
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private func nonOptional(_ path: WritableKeyPath<VoiceSettings.Hotkeys, String>, _ now: String) -> Binding<String?> {
|
|
400
|
+
Binding(get: { now }, set: { if let v = $0 { model.draft?.general.hotkeys[keyPath: path] = v } })
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// MARK: Shortcut field
|
|
405
|
+
|
|
406
|
+
/// Click, then press the keys. Escape cancels; Delete clears an optional one.
|
|
407
|
+
private struct HotkeyField: View {
|
|
408
|
+
let title: String
|
|
409
|
+
@Binding var value: String?
|
|
410
|
+
let optional: Bool
|
|
411
|
+
@ObservedObject var model: SettingsModel
|
|
412
|
+
let id: String
|
|
413
|
+
@State private var monitor: Any?
|
|
414
|
+
@State private var hint = ""
|
|
415
|
+
|
|
416
|
+
private var recording: Bool { model.recording == id }
|
|
417
|
+
|
|
418
|
+
var body: some View {
|
|
419
|
+
LabeledContent(title) {
|
|
420
|
+
HStack(spacing: 6) {
|
|
421
|
+
if !hint.isEmpty { Text(hint).font(.caption).foregroundStyle(.secondary) }
|
|
422
|
+
Button(recording ? "Type the shortcut…" : value.map(HotkeySpec.display) ?? "None") {
|
|
423
|
+
recording ? stop() : start()
|
|
424
|
+
}
|
|
425
|
+
.frame(minWidth: 110)
|
|
426
|
+
if optional && value != nil && !recording {
|
|
427
|
+
Button { value = nil } label: { Image(systemName: "xmark.circle.fill") }
|
|
428
|
+
.buttonStyle(.borderless)
|
|
429
|
+
.accessibilityLabel("Clear \(title)")
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
.onDisappear { stop() }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private func start() {
|
|
437
|
+
model.recording = id
|
|
438
|
+
hint = "Esc to cancel"
|
|
439
|
+
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
|
|
440
|
+
let flags = event.modifierFlags.intersection([.control, .option, .shift, .command])
|
|
441
|
+
if event.keyCode == 53 && flags.isEmpty { stop(); return nil } // Escape
|
|
442
|
+
if (event.keyCode == 51 || event.keyCode == 117) && flags.isEmpty && optional { // Delete
|
|
443
|
+
value = nil; stop(); return nil
|
|
444
|
+
}
|
|
445
|
+
var mods: UInt32 = 0
|
|
446
|
+
if flags.contains(.control) { mods |= UInt32(controlKey) }
|
|
447
|
+
if flags.contains(.option) { mods |= UInt32(optionKey) }
|
|
448
|
+
if flags.contains(.shift) { mods |= UInt32(shiftKey) }
|
|
449
|
+
if flags.contains(.command) { mods |= UInt32(cmdKey) }
|
|
450
|
+
let spec = HotkeySpec(keyCode: UInt32(event.keyCode), modifiers: mods)
|
|
451
|
+
guard let text = spec.text, HotkeySpec(text) != nil else {
|
|
452
|
+
hint = "Add ⌃, ⌥ or ⌘"
|
|
453
|
+
return nil
|
|
454
|
+
}
|
|
455
|
+
value = text
|
|
456
|
+
stop()
|
|
457
|
+
return nil
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private func stop() {
|
|
462
|
+
if let monitor { NSEvent.removeMonitor(monitor) }
|
|
463
|
+
monitor = nil
|
|
464
|
+
hint = ""
|
|
465
|
+
if model.recording == id { model.recording = nil }
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// MARK: Window
|
|
470
|
+
|
|
471
|
+
@MainActor
|
|
472
|
+
final class SettingsWindow: NSObject, NSWindowDelegate {
|
|
473
|
+
let model = SettingsModel()
|
|
474
|
+
private var window: NSWindow?
|
|
475
|
+
|
|
476
|
+
func show() {
|
|
477
|
+
if window == nil {
|
|
478
|
+
let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 660, height: 640),
|
|
479
|
+
styleMask: [.titled, .closable, .miniaturizable],
|
|
480
|
+
backing: .buffered, defer: false)
|
|
481
|
+
w.title = "AgentX Voice Settings"
|
|
482
|
+
w.isReleasedWhenClosed = false
|
|
483
|
+
w.contentView = NSHostingView(rootView: SettingsView(model: model))
|
|
484
|
+
w.delegate = self
|
|
485
|
+
w.center()
|
|
486
|
+
window = w
|
|
487
|
+
}
|
|
488
|
+
model.launchAtLogin = LoginItem.isOn
|
|
489
|
+
model.load()
|
|
490
|
+
// An accessory app has to ask to come forward before its window can
|
|
491
|
+
// take typing; the window was opened on purpose, so that is fine.
|
|
492
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
493
|
+
window?.makeKeyAndOrderFront(nil)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
func windowWillClose(_ notification: Notification) {
|
|
497
|
+
model.recording = nil
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
extension Color {
|
|
502
|
+
/// "#RRGGBB"; the brand teal when it isn't one.
|
|
503
|
+
init(hex: String) {
|
|
504
|
+
if let c = OrbMath.parseHex(hex) { self.init(.sRGB, red: c.r, green: c.g, blue: c.b) }
|
|
505
|
+
else { self.init(nsColor: Brand.accent) }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/// "#RRGGBB" in sRGB.
|
|
509
|
+
var hex: String {
|
|
510
|
+
let c = NSColor(self).usingColorSpace(.sRGB) ?? .black
|
|
511
|
+
let v = [c.redComponent, c.greenComponent, c.blueComponent].map { Int(($0 * 255).rounded()).clamped(0, 255) }
|
|
512
|
+
return String(format: "#%02X%02X%02X", v[0], v[1], v[2])
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private extension Int {
|
|
517
|
+
func clamped(_ lo: Int, _ hi: Int) -> Int { Swift.min(hi, Swift.max(lo, self)) }
|
|
518
|
+
}
|
|
@@ -37,8 +37,14 @@ struct VoiceChoice: Decodable {
|
|
|
37
37
|
enum Speech {
|
|
38
38
|
// MARK: Speech to text
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
/// `engine` is agentx.json's `voice.stt`: "auto" and "elevenlabs" use
|
|
41
|
+
/// ElevenLabs when there is a key, falling back to mlx-whisper; "local"
|
|
42
|
+
/// never sends audio off the Mac.
|
|
43
|
+
static func transcribe(wav: Data, engine: String = "auto") async throws -> String {
|
|
44
|
+
if engine == "elevenlabs" && Config.elevenLabsKey == nil {
|
|
45
|
+
Log.warn("speech to text is set to ElevenLabs but no key is set; using mlx-whisper")
|
|
46
|
+
}
|
|
47
|
+
if engine != "local", let key = Config.elevenLabsKey {
|
|
42
48
|
do { return try await elevenLabsSTT(wav: wav, key: key) }
|
|
43
49
|
catch { Log.warn("ElevenLabs STT failed (\(error.localizedDescription)); falling back to mlx-whisper") }
|
|
44
50
|
}
|
|
@@ -34,7 +34,14 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
34
34
|
var onHoldChanged: ((Bool) -> Void)?
|
|
35
35
|
var onTargetChanged: ((String) -> Void)?
|
|
36
36
|
var onPillChanged: ((Bool) -> Void)?
|
|
37
|
-
var
|
|
37
|
+
var onAnimatedOrbChanged: ((Bool) -> Void)?
|
|
38
|
+
/// Hide the pill and stop speech, as its close button does.
|
|
39
|
+
var onHidePill: (() -> Void)?
|
|
40
|
+
/// Put the pill back in the bottom-right corner.
|
|
41
|
+
var onResetPosition: (() -> Void)?
|
|
42
|
+
/// Whether the pill is on screen, for "Hide pill".
|
|
43
|
+
var pillVisible: () -> Bool = { false }
|
|
44
|
+
var onSettings: (() -> Void)?
|
|
38
45
|
|
|
39
46
|
override init() {
|
|
40
47
|
super.init()
|
|
@@ -177,12 +184,17 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
177
184
|
let pill = action("Show floating pill", #selector(togglePill), key: "")
|
|
178
185
|
pill.state = Config.showPill ? .on : .off
|
|
179
186
|
menu.addItem(pill)
|
|
180
|
-
let orb = action("
|
|
181
|
-
orb.state = Config.
|
|
187
|
+
let orb = action("Animated orb", #selector(toggleOrb), key: "")
|
|
188
|
+
orb.state = Config.animatedOrb ? .on : .off
|
|
182
189
|
menu.addItem(orb)
|
|
190
|
+
let hide = action("Hide pill", #selector(hidePill), key: "")
|
|
191
|
+
hide.isEnabled = pillVisible()
|
|
192
|
+
menu.addItem(hide)
|
|
193
|
+
menu.addItem(action("Reset position", #selector(resetPosition), key: ""))
|
|
183
194
|
|
|
184
195
|
menu.addItem(.separator())
|
|
185
196
|
menu.addItem(action("Settings…", #selector(openSettings), key: ","))
|
|
197
|
+
menu.addItem(action("Dashboard…", #selector(openDashboardHome), key: "d"))
|
|
186
198
|
menu.addItem(action("History…", #selector(openHistory), key: "y"))
|
|
187
199
|
menu.addItem(.separator())
|
|
188
200
|
menu.addItem(NSMenuItem(title: "Quit AgentX Voice",
|
|
@@ -230,11 +242,15 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
230
242
|
}
|
|
231
243
|
|
|
232
244
|
@objc private func toggleOrb() {
|
|
233
|
-
Config.
|
|
234
|
-
|
|
245
|
+
Config.animatedOrb.toggle()
|
|
246
|
+
onAnimatedOrbChanged?(Config.animatedOrb)
|
|
235
247
|
}
|
|
236
248
|
|
|
237
|
-
@objc private func
|
|
249
|
+
@objc private func hidePill() { onHidePill?() }
|
|
250
|
+
@objc private func resetPosition() { onResetPosition?() }
|
|
251
|
+
|
|
252
|
+
@objc private func openSettings() { onSettings?() }
|
|
253
|
+
@objc private func openDashboardHome() { openDashboard("/admin") }
|
|
238
254
|
@objc private func openHistory() { openDashboard("/activity") }
|
|
239
255
|
|
|
240
256
|
private func openDashboard(_ path: String) {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// What GET /voice/settings returns, edited in the settings window and
|
|
4
|
+
/// sent back as the difference. Foundation only, so the tests can build it.
|
|
5
|
+
struct VoiceSettings: Codable, Equatable {
|
|
6
|
+
struct Hotkeys: Codable, Equatable {
|
|
7
|
+
var talk: String
|
|
8
|
+
var stop: String
|
|
9
|
+
var paste: String
|
|
10
|
+
}
|
|
11
|
+
struct General: Codable, Equatable {
|
|
12
|
+
var provider: String
|
|
13
|
+
var fallback: String
|
|
14
|
+
var stt: String
|
|
15
|
+
var hotkeys: Hotkeys
|
|
16
|
+
}
|
|
17
|
+
struct Voice: Codable, Equatable {
|
|
18
|
+
var provider: String?
|
|
19
|
+
var system: String?
|
|
20
|
+
var systemPerLanguage: Bool
|
|
21
|
+
var elevenlabsVoiceId: String?
|
|
22
|
+
var rate: Double?
|
|
23
|
+
var narrate: String?
|
|
24
|
+
var priority: String?
|
|
25
|
+
var hotkey: String?
|
|
26
|
+
}
|
|
27
|
+
struct Speaks: Codable, Equatable {
|
|
28
|
+
let provider: String
|
|
29
|
+
let systemVoice: String?
|
|
30
|
+
}
|
|
31
|
+
struct Agent: Codable, Equatable, Identifiable {
|
|
32
|
+
let id: String
|
|
33
|
+
let name: String
|
|
34
|
+
var color: String
|
|
35
|
+
var colorSet: Bool
|
|
36
|
+
var voice: Voice
|
|
37
|
+
let speaks: Speaks
|
|
38
|
+
}
|
|
39
|
+
struct SystemVoice: Codable, Equatable, Identifiable {
|
|
40
|
+
let id: String
|
|
41
|
+
let label: String
|
|
42
|
+
let locale: String
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
var general: General
|
|
46
|
+
var agents: [Agent]
|
|
47
|
+
let systemVoices: [SystemVoice]
|
|
48
|
+
let menuHotkey: String
|
|
49
|
+
|
|
50
|
+
/// The change from `old` to this, as POST /voice/settings takes it:
|
|
51
|
+
/// only what differs, and NSNull for a field put back to its default.
|
|
52
|
+
func patch(from old: VoiceSettings) -> [String: Any] {
|
|
53
|
+
var out: [String: Any] = [:]
|
|
54
|
+
|
|
55
|
+
var general: [String: Any] = [:]
|
|
56
|
+
if self.general.provider != old.general.provider { general["provider"] = self.general.provider }
|
|
57
|
+
if self.general.stt != old.general.stt { general["stt"] = self.general.stt }
|
|
58
|
+
var keys: [String: Any] = [:]
|
|
59
|
+
if self.general.hotkeys.talk != old.general.hotkeys.talk { keys["talk"] = self.general.hotkeys.talk }
|
|
60
|
+
if self.general.hotkeys.stop != old.general.hotkeys.stop { keys["stop"] = self.general.hotkeys.stop }
|
|
61
|
+
if self.general.hotkeys.paste != old.general.hotkeys.paste { keys["paste"] = self.general.hotkeys.paste }
|
|
62
|
+
if !keys.isEmpty { general["hotkeys"] = keys }
|
|
63
|
+
if !general.isEmpty { out["general"] = general }
|
|
64
|
+
|
|
65
|
+
var agents: [String: Any] = [:]
|
|
66
|
+
for agent in self.agents {
|
|
67
|
+
guard let before = old.agents.first(where: { $0.id == agent.id }) else { continue }
|
|
68
|
+
var a: [String: Any] = [:]
|
|
69
|
+
func field<T: Equatable>(_ name: String, _ now: T?, _ was: T?) {
|
|
70
|
+
if now != was { a[name] = now.map { $0 as Any } ?? NSNull() }
|
|
71
|
+
}
|
|
72
|
+
field("provider", agent.voice.provider, before.voice.provider)
|
|
73
|
+
field("system", agent.voice.system, before.voice.system)
|
|
74
|
+
field("elevenlabsVoiceId", agent.voice.elevenlabsVoiceId.flatMap { $0.isEmpty ? nil : $0 },
|
|
75
|
+
before.voice.elevenlabsVoiceId)
|
|
76
|
+
field("rate", agent.voice.rate, before.voice.rate)
|
|
77
|
+
field("narrate", agent.voice.narrate, before.voice.narrate)
|
|
78
|
+
field("priority", agent.voice.priority, before.voice.priority)
|
|
79
|
+
field("hotkey", agent.voice.hotkey, before.voice.hotkey)
|
|
80
|
+
if agent.colorSet != before.colorSet || (agent.colorSet && agent.color != before.color) {
|
|
81
|
+
a["color"] = agent.colorSet ? agent.color as Any : NSNull()
|
|
82
|
+
}
|
|
83
|
+
if !a.isEmpty { agents[agent.id] = a }
|
|
84
|
+
}
|
|
85
|
+
if !agents.isEmpty { out["agents"] = agents }
|
|
86
|
+
return out
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// The fields a preview speaks with: the draft's voice for one agent.
|
|
90
|
+
func previewVoice(for id: String) -> [String: Any] {
|
|
91
|
+
guard let v = agents.first(where: { $0.id == id })?.voice else { return [:] }
|
|
92
|
+
var out: [String: Any] = [
|
|
93
|
+
"provider": v.provider.map { $0 as Any } ?? NSNull(),
|
|
94
|
+
"elevenlabsVoiceId": v.elevenlabsVoiceId.flatMap { $0.isEmpty ? nil : $0 }.map { $0 as Any } ?? NSNull(),
|
|
95
|
+
"rate": v.rate.map { $0 as Any } ?? NSNull(),
|
|
96
|
+
]
|
|
97
|
+
// A per-language list is not shown in the window; leave it be.
|
|
98
|
+
if !v.systemPerLanguage { out["system"] = v.system.map { $0 as Any } ?? NSNull() }
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
}
|