agentix-cli 0.58.0 → 0.60.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 +4 -0
- package/apps/mac-voice/Sources/AgentXVoice/AnswerView.swift +228 -0
- package/apps/mac-voice/Sources/AgentXVoice/HistoryClient.swift +55 -0
- package/apps/mac-voice/Sources/AgentXVoice/HistoryModel.swift +153 -0
- package/apps/mac-voice/Sources/AgentXVoice/HistoryWindow.swift +301 -0
- package/apps/mac-voice/Sources/AgentXVoice/LocalSTT.swift +79 -0
- package/apps/mac-voice/Sources/AgentXVoice/ModelStore.swift +184 -0
- package/apps/mac-voice/Sources/AgentXVoice/Orb.swift +36 -9
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +126 -55
- package/apps/mac-voice/Sources/AgentXVoice/PanelAnswer.swift +113 -0
- package/apps/mac-voice/Sources/AgentXVoice/Parakeet.swift +264 -0
- package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +28 -17
- package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +81 -3
- package/apps/mac-voice/Sources/AgentXVoice/SettingsLook.swift +90 -0
- package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +20 -5
- package/apps/mac-voice/Sources/AgentXVoice/SileroVAD.swift +92 -0
- package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +7 -5
- package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +14 -1
- package/apps/mac-voice/Sources/AgentXVoice/Surface.swift +105 -0
- package/apps/mac-voice/Sources/AgentXVoice/TurnEnd.swift +110 -0
- package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +54 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +35 -36
- package/apps/mac-voice/build.sh +10 -1
- package/dist/{App-Z5VOAUXB.js → App-LQXWKBVY.js} +2 -2
- package/dist/{ChatApp-BE7ONQKS.js → ChatApp-75TL4LKW.js} +2 -2
- package/dist/{agent-Q74EISKP.js → agent-DH4FMMMY.js} +2 -2
- package/dist/{board-dashboard-A7PQOI6C.js → board-dashboard-BVRRDYAG.js} +223 -138
- package/dist/board-dashboard-BVRRDYAG.js.map +1 -0
- package/dist/{chunk-NKMFHEGV.js → chunk-27P5NLTC.js} +2 -2
- package/dist/{chunk-5LLGIX5M.js → chunk-2VQIPJH5.js} +2 -2
- package/dist/{chunk-V7ZK6ZIO.js → chunk-3XVTFNYM.js} +2 -2
- package/dist/{chunk-KW7TIOS3.js → chunk-B3DCYBP4.js} +2 -2
- package/dist/{chunk-547G3HYQ.js → chunk-CRSXKQLZ.js} +2 -2
- package/dist/{chunk-5W7NJHCS.js → chunk-DTEQNXK3.js} +2 -2
- package/dist/{chunk-XTD3SZIP.js → chunk-E2BJVHBR.js} +2 -2
- package/dist/{chunk-RXEAACVH.js → chunk-E2PWCU2N.js} +2 -2
- package/dist/{chunk-I2LYULR4.js → chunk-JO3PSLWL.js} +5 -5
- package/dist/{chunk-U5YBQJ5Q.js → chunk-JORXRF4E.js} +2 -2
- package/dist/{chunk-2MIOMYT2.js → chunk-LXUG2X26.js} +2 -2
- package/dist/{chunk-Q6SMLSRO.js → chunk-N5KD3TVN.js} +2 -2
- package/dist/{chunk-VI7GBDKV.js → chunk-NBGNPKAF.js} +2 -2
- package/dist/{chunk-6QNPCVRN.js → chunk-PAOV37KQ.js} +3 -3
- package/dist/chunk-PAOV37KQ.js.map +1 -0
- package/dist/{chunk-OZZPJ2YG.js → chunk-RYFUFX6F.js} +194 -181
- package/dist/chunk-RYFUFX6F.js.map +1 -0
- package/dist/chunk-S7TCVIET.js +15 -0
- package/dist/chunk-S7TCVIET.js.map +1 -0
- package/dist/{chunk-POL7RAJQ.js → chunk-TM762AXU.js} +159 -153
- package/dist/chunk-TM762AXU.js.map +1 -0
- package/dist/{chunk-6OABTCRA.js → chunk-WBGC7MWX.js} +2 -2
- package/dist/{chunk-CQUVUVXR.js → chunk-WIBMNQKJ.js} +2 -2
- package/dist/{chunk-KKGFZEXS.js → chunk-WOPFHKBF.js} +37 -36
- package/dist/chunk-WOPFHKBF.js.map +1 -0
- package/dist/{chunk-VVBGPT6W.js → chunk-YQCOX4R3.js} +2 -2
- package/dist/cli.js +1 -1
- package/dist/client-QVZIZFV4.js +2 -0
- package/dist/config-3XC7EYB7.js +2 -0
- package/dist/config-mutator-HKU63FJM.js +2 -0
- package/dist/doctor-YFA4ZSRH.js +2 -0
- package/dist/index.d.ts +66 -7
- package/dist/index.js +1 -1
- package/dist/program-BOCLU6ES.js +2 -0
- package/dist/{promote-KVPLDSKT.js → promote-UR2GSHKC.js} +2 -2
- package/dist/query-XUP3BMVS.js +2 -0
- package/dist/{registry-AUOI4TW2.js → registry-PGALUW5L.js} +2 -2
- package/dist/{request-planner-LMZ5ZAXC.js → request-planner-NHE3IRJN.js} +2 -2
- package/dist/{review-candidates-WCBXLLDS.js → review-candidates-6GKRSRBQ.js} +2 -2
- package/dist/{routing-FBASYKUL.js → routing-AQKHAU3Z.js} +2 -2
- package/dist/schedule-tool-6NMCLTIH.js +2 -0
- package/dist/scheduler-RAYGNDWT.js +2 -0
- package/dist/{seat-DBFW3Z5P.js → seat-6FMW2QI4.js} +2 -2
- package/dist/{select-6CNVVFA5.js → select-5VONJMLH.js} +2 -2
- package/dist/{set-enabled-AYZH3KSB.js → set-enabled-YHD3IJJZ.js} +2 -2
- package/dist/{tool-PKS4NBZ6.js → tool-MXJGLJZ5.js} +2 -2
- package/dist/wiki-ZQLEYY24.js +2 -0
- package/package.json +1 -1
- package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +0 -198
- package/dist/board-dashboard-A7PQOI6C.js.map +0 -1
- package/dist/chunk-6QNPCVRN.js.map +0 -1
- package/dist/chunk-ERL6AS3O.js +0 -15
- package/dist/chunk-ERL6AS3O.js.map +0 -1
- package/dist/chunk-KKGFZEXS.js.map +0 -1
- package/dist/chunk-OZZPJ2YG.js.map +0 -1
- package/dist/chunk-POL7RAJQ.js.map +0 -1
- package/dist/client-EFP2PKA3.js +0 -2
- package/dist/config-P6YXC6XZ.js +0 -2
- package/dist/config-mutator-3GKXLTHH.js +0 -2
- package/dist/doctor-JEWMHXYH.js +0 -2
- package/dist/program-7DUQJDSQ.js +0 -2
- package/dist/query-AGOMCYTT.js +0 -2
- package/dist/schedule-tool-RJRJ63SS.js +0 -2
- package/dist/scheduler-ATA7Z44S.js +0 -2
- package/dist/wiki-QW6BDNEQ.js +0 -2
- /package/dist/{App-Z5VOAUXB.js.map → App-LQXWKBVY.js.map} +0 -0
- /package/dist/{ChatApp-BE7ONQKS.js.map → ChatApp-75TL4LKW.js.map} +0 -0
- /package/dist/{agent-Q74EISKP.js.map → agent-DH4FMMMY.js.map} +0 -0
- /package/dist/{chunk-NKMFHEGV.js.map → chunk-27P5NLTC.js.map} +0 -0
- /package/dist/{chunk-5LLGIX5M.js.map → chunk-2VQIPJH5.js.map} +0 -0
- /package/dist/{chunk-V7ZK6ZIO.js.map → chunk-3XVTFNYM.js.map} +0 -0
- /package/dist/{chunk-KW7TIOS3.js.map → chunk-B3DCYBP4.js.map} +0 -0
- /package/dist/{chunk-547G3HYQ.js.map → chunk-CRSXKQLZ.js.map} +0 -0
- /package/dist/{chunk-5W7NJHCS.js.map → chunk-DTEQNXK3.js.map} +0 -0
- /package/dist/{chunk-XTD3SZIP.js.map → chunk-E2BJVHBR.js.map} +0 -0
- /package/dist/{chunk-RXEAACVH.js.map → chunk-E2PWCU2N.js.map} +0 -0
- /package/dist/{chunk-I2LYULR4.js.map → chunk-JO3PSLWL.js.map} +0 -0
- /package/dist/{chunk-U5YBQJ5Q.js.map → chunk-JORXRF4E.js.map} +0 -0
- /package/dist/{chunk-2MIOMYT2.js.map → chunk-LXUG2X26.js.map} +0 -0
- /package/dist/{chunk-Q6SMLSRO.js.map → chunk-N5KD3TVN.js.map} +0 -0
- /package/dist/{chunk-VI7GBDKV.js.map → chunk-NBGNPKAF.js.map} +0 -0
- /package/dist/{chunk-6OABTCRA.js.map → chunk-WBGC7MWX.js.map} +0 -0
- /package/dist/{chunk-CQUVUVXR.js.map → chunk-WIBMNQKJ.js.map} +0 -0
- /package/dist/{chunk-VVBGPT6W.js.map → chunk-YQCOX4R3.js.map} +0 -0
- /package/dist/{client-EFP2PKA3.js.map → client-QVZIZFV4.js.map} +0 -0
- /package/dist/{config-P6YXC6XZ.js.map → config-3XC7EYB7.js.map} +0 -0
- /package/dist/{config-mutator-3GKXLTHH.js.map → config-mutator-HKU63FJM.js.map} +0 -0
- /package/dist/{doctor-JEWMHXYH.js.map → doctor-YFA4ZSRH.js.map} +0 -0
- /package/dist/{program-7DUQJDSQ.js.map → program-BOCLU6ES.js.map} +0 -0
- /package/dist/{promote-KVPLDSKT.js.map → promote-UR2GSHKC.js.map} +0 -0
- /package/dist/{query-AGOMCYTT.js.map → query-XUP3BMVS.js.map} +0 -0
- /package/dist/{registry-AUOI4TW2.js.map → registry-PGALUW5L.js.map} +0 -0
- /package/dist/{request-planner-LMZ5ZAXC.js.map → request-planner-NHE3IRJN.js.map} +0 -0
- /package/dist/{review-candidates-WCBXLLDS.js.map → review-candidates-6GKRSRBQ.js.map} +0 -0
- /package/dist/{routing-FBASYKUL.js.map → routing-AQKHAU3Z.js.map} +0 -0
- /package/dist/{schedule-tool-RJRJ63SS.js.map → schedule-tool-6NMCLTIH.js.map} +0 -0
- /package/dist/{scheduler-ATA7Z44S.js.map → scheduler-RAYGNDWT.js.map} +0 -0
- /package/dist/{seat-DBFW3Z5P.js.map → seat-6FMW2QI4.js.map} +0 -0
- /package/dist/{select-6CNVVFA5.js.map → select-5VONJMLH.js.map} +0 -0
- /package/dist/{set-enabled-AYZH3KSB.js.map → set-enabled-YHD3IJJZ.js.map} +0 -0
- /package/dist/{tool-PKS4NBZ6.js.map → tool-MXJGLJZ5.js.map} +0 -0
- /package/dist/{wiki-QW6BDNEQ.js.map → wiki-ZQLEYY24.js.map} +0 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import SwiftUI
|
|
3
|
+
|
|
4
|
+
/// History: past voice exchanges by day, filterable by agent.
|
|
5
|
+
///
|
|
6
|
+
/// The daemon keeps the records (every /ask turn is a task trace on the
|
|
7
|
+
/// voice channel); the window reads them and keeps no copy. The list holds
|
|
8
|
+
/// summaries only, and an answer in full is fetched when it is selected.
|
|
9
|
+
/// Replay goes through the daemon's speaking queue, like any answer.
|
|
10
|
+
@MainActor
|
|
11
|
+
final class HistoryStore: ObservableObject {
|
|
12
|
+
static let pageSize = 30
|
|
13
|
+
|
|
14
|
+
@Published var exchanges: [VoiceExchange] = []
|
|
15
|
+
@Published var next: String?
|
|
16
|
+
/// nil shows every agent.
|
|
17
|
+
@Published var agent: String? { didSet { if agent != oldValue { reload() } } }
|
|
18
|
+
@Published var agents: [AgentClient.AgentInfo] = []
|
|
19
|
+
@Published var selected: String? { didSet { if selected != oldValue { loadDetail() } } }
|
|
20
|
+
@Published var detail: VoiceExchangeDetail?
|
|
21
|
+
@Published var loading = false
|
|
22
|
+
@Published var error = ""
|
|
23
|
+
@Published var notice = ""
|
|
24
|
+
|
|
25
|
+
/// Swapped for fixed data when rendering screenshots.
|
|
26
|
+
var fetchPage: (String?, Int, String?) async -> Result<VoiceHistoryPage, HistoryClient.HistoryFailure> = HistoryClient.page
|
|
27
|
+
var fetchDetail: (String) async -> VoiceExchangeDetail? = HistoryClient.detail
|
|
28
|
+
var fetchAgents: () async -> [AgentClient.AgentInfo]? = AgentClient.agents
|
|
29
|
+
|
|
30
|
+
var days: [HistoryDay] { HistoryLogic.groupByDay(exchanges) }
|
|
31
|
+
|
|
32
|
+
func name(of id: String) -> String { agents.first { $0.id == id }?.label ?? id }
|
|
33
|
+
|
|
34
|
+
func color(of id: String) -> Color {
|
|
35
|
+
Color(hex: OrbMath.colorHex(agentID: id, configured: agents.first { $0.id == id }?.color))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func reload() {
|
|
39
|
+
loading = true
|
|
40
|
+
error = ""
|
|
41
|
+
Task {
|
|
42
|
+
if agents.isEmpty, let a = await fetchAgents() { agents = a }
|
|
43
|
+
let result = await fetchPage(agent, Self.pageSize, nil)
|
|
44
|
+
loading = false
|
|
45
|
+
switch result {
|
|
46
|
+
case .success(let page):
|
|
47
|
+
exchanges = page.exchanges
|
|
48
|
+
next = page.next
|
|
49
|
+
if selected == nil || !exchanges.contains(where: { $0.id == selected }) { selected = exchanges.first?.id }
|
|
50
|
+
case .failure(let f):
|
|
51
|
+
exchanges = []
|
|
52
|
+
next = nil
|
|
53
|
+
error = f.message
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func loadOlder() {
|
|
59
|
+
guard let before = next, !loading else { return }
|
|
60
|
+
loading = true
|
|
61
|
+
Task {
|
|
62
|
+
let result = await fetchPage(agent, Self.pageSize, before)
|
|
63
|
+
loading = false
|
|
64
|
+
switch result {
|
|
65
|
+
case .success(let page):
|
|
66
|
+
exchanges = HistoryLogic.merge(exchanges, page.exchanges)
|
|
67
|
+
next = page.next
|
|
68
|
+
case .failure(let f):
|
|
69
|
+
error = f.message
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private func loadDetail() {
|
|
75
|
+
detail = nil
|
|
76
|
+
guard let id = selected else { return }
|
|
77
|
+
Task {
|
|
78
|
+
let d = await fetchDetail(id)
|
|
79
|
+
if selected == id { detail = d }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func replay(_ id: String) {
|
|
84
|
+
notice = ""
|
|
85
|
+
error = ""
|
|
86
|
+
Task {
|
|
87
|
+
if let problem = await HistoryClient.replay(id) { error = problem }
|
|
88
|
+
else { notice = "Queued. It plays after anything already speaking." }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func copy(_ d: VoiceExchangeDetail) {
|
|
93
|
+
NSPasteboard.general.clearContents()
|
|
94
|
+
NSPasteboard.general.setString(d.answer ?? "", forType: .string)
|
|
95
|
+
notice = "Answer copied."
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func openTask(_ d: VoiceExchangeDetail) {
|
|
99
|
+
guard let url = URL(string: Config.dashboardURL + d.taskPath) else { return }
|
|
100
|
+
NSWorkspace.shared.open(url)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
struct HistoryView: View {
|
|
105
|
+
@ObservedObject var store: HistoryStore
|
|
106
|
+
|
|
107
|
+
var body: some View {
|
|
108
|
+
VStack(spacing: 0) {
|
|
109
|
+
toolbar
|
|
110
|
+
Divider()
|
|
111
|
+
HStack(spacing: 0) {
|
|
112
|
+
list.frame(width: 320)
|
|
113
|
+
Divider()
|
|
114
|
+
detailPane.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
115
|
+
}
|
|
116
|
+
if !store.error.isEmpty || !store.notice.isEmpty { footer }
|
|
117
|
+
}
|
|
118
|
+
.frame(minWidth: 760, minHeight: 480)
|
|
119
|
+
.background(Color(nsColor: .windowBackgroundColor))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private var toolbar: some View {
|
|
123
|
+
HStack(spacing: 10) {
|
|
124
|
+
Picker("Agent", selection: $store.agent) {
|
|
125
|
+
Text("All agents").tag(String?.none)
|
|
126
|
+
ForEach(store.agents, id: \.id) { a in Text(a.label).tag(String?.some(a.id)) }
|
|
127
|
+
}
|
|
128
|
+
.frame(width: 220)
|
|
129
|
+
Spacer()
|
|
130
|
+
if store.loading { ProgressView().controlSize(.small) }
|
|
131
|
+
Button("Refresh") { store.reload() }
|
|
132
|
+
.keyboardShortcut("r", modifiers: .command)
|
|
133
|
+
}
|
|
134
|
+
.padding(10)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@ViewBuilder private var list: some View {
|
|
138
|
+
if store.exchanges.isEmpty {
|
|
139
|
+
Text(store.loading ? "Loading…" : store.error.isEmpty ? "No voice exchanges yet." : "Nothing to show.")
|
|
140
|
+
.foregroundStyle(.secondary)
|
|
141
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
142
|
+
} else {
|
|
143
|
+
List(selection: $store.selected) {
|
|
144
|
+
ForEach(store.days, id: \.title) { day in
|
|
145
|
+
Section(day.title) {
|
|
146
|
+
ForEach(day.exchanges) { e in row(e).tag(e.id) }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if store.next != nil {
|
|
150
|
+
Button("Load older") { store.loadOlder() }
|
|
151
|
+
.frame(maxWidth: .infinity)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
.listStyle(.inset)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private func row(_ e: VoiceExchange) -> some View {
|
|
159
|
+
VStack(alignment: .leading, spacing: 3) {
|
|
160
|
+
HStack(spacing: 6) {
|
|
161
|
+
Circle().fill(store.color(of: e.agentId)).frame(width: 8, height: 8)
|
|
162
|
+
Text(store.name(of: e.agentId)).font(.caption.weight(.semibold))
|
|
163
|
+
Spacer()
|
|
164
|
+
Text(e.date, style: .time).font(.caption).foregroundStyle(.secondary)
|
|
165
|
+
Text(HistoryLogic.duration(e.durationMs)).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
|
166
|
+
}
|
|
167
|
+
Text(e.question).fontWeight(.medium).lineLimit(2)
|
|
168
|
+
Text(e.failed ? (e.error ?? "No answer") : e.answerPreview)
|
|
169
|
+
.font(.callout)
|
|
170
|
+
.foregroundStyle(e.failed ? Color(nsColor: Brand.alert) : .secondary)
|
|
171
|
+
.lineLimit(2)
|
|
172
|
+
}
|
|
173
|
+
.padding(.vertical, 3)
|
|
174
|
+
.accessibilityElement(children: .combine)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
@ViewBuilder private var detailPane: some View {
|
|
178
|
+
if let d = store.detail {
|
|
179
|
+
ScrollView {
|
|
180
|
+
VStack(alignment: .leading, spacing: 14) {
|
|
181
|
+
HStack(spacing: 6) {
|
|
182
|
+
Circle().fill(store.color(of: d.agentId)).frame(width: 10, height: 10)
|
|
183
|
+
Text(store.name(of: d.agentId)).font(.headline)
|
|
184
|
+
Spacer()
|
|
185
|
+
Text("Answered in \(HistoryLogic.duration(d.durationMs))")
|
|
186
|
+
.font(.caption).foregroundStyle(.secondary)
|
|
187
|
+
}
|
|
188
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
189
|
+
Text("YOU SAID").font(.caption2.monospaced()).tracking(0.8).foregroundStyle(.secondary)
|
|
190
|
+
Text(d.question).textSelection(.enabled)
|
|
191
|
+
}
|
|
192
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
193
|
+
Text("ANSWER").font(.caption2.monospaced()).tracking(0.8).foregroundStyle(.secondary)
|
|
194
|
+
if let answer = d.answer, !answer.isEmpty {
|
|
195
|
+
Text(written(answer)).textSelection(.enabled)
|
|
196
|
+
if d.truncated { Text("The answer is longer; open the task for all of it.").font(.caption).foregroundStyle(.secondary) }
|
|
197
|
+
} else {
|
|
198
|
+
Text(d.error ?? "No answer was recorded.").foregroundStyle(Color(nsColor: Brand.alert))
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if !d.links.isEmpty {
|
|
202
|
+
HStack(spacing: 8) {
|
|
203
|
+
ForEach(d.links, id: \.1) { link in
|
|
204
|
+
if let url = URL(string: link.1) { Link(link.0, destination: url) }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
Divider()
|
|
209
|
+
HStack(spacing: 8) {
|
|
210
|
+
Button("Replay") { store.replay(d.id) }
|
|
211
|
+
.disabled(!(d.answer?.isEmpty == false))
|
|
212
|
+
.keyboardShortcut(.return, modifiers: .command)
|
|
213
|
+
Button("Copy") { store.copy(d) }
|
|
214
|
+
.disabled(!(d.answer?.isEmpty == false))
|
|
215
|
+
.keyboardShortcut("c", modifiers: [.command, .shift])
|
|
216
|
+
Button("Open task in dashboard") { store.openTask(d) }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
.padding(16)
|
|
220
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
Text(store.selected == nil ? "Select an exchange." : "Loading…")
|
|
224
|
+
.foregroundStyle(.secondary)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/// The answer as written: markdown links and bare URLs as links.
|
|
229
|
+
private func written(_ answer: String) -> AttributedString {
|
|
230
|
+
let md = HistoryLogic.linkify(answer)
|
|
231
|
+
return (try? AttributedString(markdown: md, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)))
|
|
232
|
+
?? AttributedString(answer)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private var footer: some View {
|
|
236
|
+
HStack {
|
|
237
|
+
Text(store.error.isEmpty ? store.notice : store.error)
|
|
238
|
+
.foregroundStyle(store.error.isEmpty ? Color.secondary : Color(nsColor: .systemRed))
|
|
239
|
+
.font(.callout)
|
|
240
|
+
Spacer()
|
|
241
|
+
}
|
|
242
|
+
.padding(10)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
@MainActor
|
|
247
|
+
final class HistoryWindow: NSObject {
|
|
248
|
+
static let shared = HistoryWindow()
|
|
249
|
+
let store = HistoryStore()
|
|
250
|
+
private var window: NSWindow?
|
|
251
|
+
|
|
252
|
+
func show() {
|
|
253
|
+
if window == nil {
|
|
254
|
+
let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 860, height: 560),
|
|
255
|
+
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
256
|
+
backing: .buffered, defer: false)
|
|
257
|
+
w.title = "AgentX Voice History"
|
|
258
|
+
w.isReleasedWhenClosed = false
|
|
259
|
+
w.contentView = NSHostingView(rootView: HistoryView(store: store))
|
|
260
|
+
w.center()
|
|
261
|
+
window = w
|
|
262
|
+
}
|
|
263
|
+
store.agents = []
|
|
264
|
+
store.reload()
|
|
265
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
266
|
+
window?.makeKeyAndOrderFront(nil)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/// The menu's last three exchanges, for a quick replay.
|
|
271
|
+
@MainActor
|
|
272
|
+
final class RecentExchanges: NSObject {
|
|
273
|
+
private(set) var items: [VoiceExchange] = []
|
|
274
|
+
|
|
275
|
+
func refresh() async {
|
|
276
|
+
if case .success(let page) = await HistoryClient.page(agent: nil, limit: 3) { items = page.exchanges }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/// A "Recent" section, or nothing when there is no history.
|
|
280
|
+
func addItems(to menu: NSMenu, name: (String) -> String) {
|
|
281
|
+
guard !items.isEmpty else { return }
|
|
282
|
+
menu.addItem(.separator())
|
|
283
|
+
menu.addItem(NSMenuItem.sectionHeader(title: "Recent · click to replay"))
|
|
284
|
+
for e in items {
|
|
285
|
+
let row = NSMenuItem(title: HistoryLogic.menuTitle(e, agentName: name(e.agentId)),
|
|
286
|
+
action: #selector(replay(_:)), keyEquivalent: "")
|
|
287
|
+
row.target = self
|
|
288
|
+
row.representedObject = e.id
|
|
289
|
+
row.isEnabled = e.canReplay
|
|
290
|
+
row.toolTip = e.answerPreview.isEmpty ? nil : e.answerPreview
|
|
291
|
+
menu.addItem(row)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
@objc private func replay(_ sender: NSMenuItem) {
|
|
296
|
+
guard let id = sender.representedObject as? String else { return }
|
|
297
|
+
Task {
|
|
298
|
+
if let problem = await HistoryClient.replay(id) { Log.warn("history replay: \(problem)") }
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Parakeet as the app uses it: never makes a turn wait for a download or
|
|
4
|
+
/// for the first model load, and answers nil whenever mlx-whisper should
|
|
5
|
+
/// take this turn instead.
|
|
6
|
+
///
|
|
7
|
+
/// The first load of Parakeet on a Mac takes about half a minute (Core ML
|
|
8
|
+
/// prepares the encoder for the Neural Engine and caches the result);
|
|
9
|
+
/// later loads take about half a second. A turn waits a few seconds for
|
|
10
|
+
/// the load at most, then falls back while the load carries on.
|
|
11
|
+
enum LocalSTT {
|
|
12
|
+
/// How long a turn waits for Parakeet to finish loading.
|
|
13
|
+
static let loadPatience: TimeInterval = 4
|
|
14
|
+
|
|
15
|
+
private static let loader = ParakeetLoader()
|
|
16
|
+
|
|
17
|
+
static func parakeet(wav: Data) async -> String? {
|
|
18
|
+
guard ModelStore.parakeet.isInstalled else {
|
|
19
|
+
Log.warn("Parakeet is not downloaded yet; using mlx-whisper for now")
|
|
20
|
+
ModelStore.installInBackground(ModelStore.parakeet) { Log.info($0) }
|
|
21
|
+
return nil
|
|
22
|
+
}
|
|
23
|
+
guard let engine = await loader.engine(within: loadPatience) else {
|
|
24
|
+
Log.warn("Parakeet is still loading (the first load takes about 30 s); using mlx-whisper for this turn")
|
|
25
|
+
return nil
|
|
26
|
+
}
|
|
27
|
+
return await Task.detached(priority: .userInitiated) { () -> String? in
|
|
28
|
+
do {
|
|
29
|
+
let t = Date()
|
|
30
|
+
let text = try engine.transcribe(wav: wav)
|
|
31
|
+
Log.info(String(format: "parakeet: %.2f s", Date().timeIntervalSince(t)))
|
|
32
|
+
return text
|
|
33
|
+
} catch {
|
|
34
|
+
Log.warn("Parakeet failed (\(error.localizedDescription)); falling back to mlx-whisper")
|
|
35
|
+
return nil
|
|
36
|
+
}
|
|
37
|
+
}.value
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Loads Parakeet once, in the background, and lets callers wait for it
|
|
42
|
+
/// with a limit.
|
|
43
|
+
private actor ParakeetLoader {
|
|
44
|
+
private var loading: Task<Parakeet?, Never>?
|
|
45
|
+
|
|
46
|
+
func engine(within seconds: TimeInterval) async -> Parakeet? {
|
|
47
|
+
let task = loading ?? start()
|
|
48
|
+
return await withTaskGroup(of: Parakeet?.self) { group in
|
|
49
|
+
group.addTask { await task.value }
|
|
50
|
+
group.addTask {
|
|
51
|
+
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
|
52
|
+
return nil
|
|
53
|
+
}
|
|
54
|
+
let first = await group.next() ?? nil
|
|
55
|
+
group.cancelAll()
|
|
56
|
+
return first
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private func start() -> Task<Parakeet?, Never> {
|
|
61
|
+
let task = Task.detached(priority: .userInitiated) { () -> Parakeet? in
|
|
62
|
+
let t = Date()
|
|
63
|
+
do {
|
|
64
|
+
let p = try Parakeet.loaded()
|
|
65
|
+
Log.info(String(format: "parakeet: loaded in %.1f s", Date().timeIntervalSince(t)))
|
|
66
|
+
return p
|
|
67
|
+
} catch {
|
|
68
|
+
Log.warn("Parakeet would not load (\(error.localizedDescription))")
|
|
69
|
+
return nil
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
loading = task
|
|
73
|
+
Task { if await task.value == nil { self.forget() } }
|
|
74
|
+
return task
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// A failed load is tried again on the next turn.
|
|
78
|
+
private func forget() { loading = nil }
|
|
79
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import CryptoKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// On-device models, fetched on first use into ~/.agentx/models and never
|
|
5
|
+
/// shipped with the app or the repository.
|
|
6
|
+
///
|
|
7
|
+
/// Every file is pinned twice: by the Hugging Face revision it comes from
|
|
8
|
+
/// and by its SHA-256. A file that does not match is deleted, so a
|
|
9
|
+
/// truncated download or a changed upstream repo can never be loaded. A
|
|
10
|
+
/// model counts as installed only when a `.verified` stamp sits next to
|
|
11
|
+
/// it, written after every file checked out, so a half-finished download
|
|
12
|
+
/// is simply "not installed" and the next attempt starts over.
|
|
13
|
+
struct LocalModel: Sendable {
|
|
14
|
+
struct File: Sendable {
|
|
15
|
+
let path: String
|
|
16
|
+
let size: Int64
|
|
17
|
+
let sha256: String
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// Short name for the command line and the logs: "vad", "parakeet".
|
|
21
|
+
let name: String
|
|
22
|
+
let repo: String
|
|
23
|
+
let revision: String
|
|
24
|
+
let files: [File]
|
|
25
|
+
|
|
26
|
+
var totalBytes: Int64 { files.reduce(0) { $0 + $1.size } }
|
|
27
|
+
|
|
28
|
+
/// ~/.agentx/models/<repo name>/
|
|
29
|
+
var directory: URL {
|
|
30
|
+
ModelStore.root.appendingPathComponent(repo.split(separator: "/").last.map(String.init) ?? repo)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func url(_ path: String) -> URL { directory.appendingPathComponent(path) }
|
|
34
|
+
|
|
35
|
+
private var stamp: URL { directory.appendingPathComponent(".verified") }
|
|
36
|
+
|
|
37
|
+
/// Every file is present and was verified for this revision.
|
|
38
|
+
var isInstalled: Bool {
|
|
39
|
+
guard let s = try? String(contentsOf: stamp, encoding: .utf8) else { return false }
|
|
40
|
+
return s.trimmingCharacters(in: .whitespacesAndNewlines) == revision
|
|
41
|
+
&& files.allSatisfy { FileManager.default.fileExists(atPath: url($0.path).path) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fileprivate func markInstalled() throws {
|
|
45
|
+
try revision.write(to: stamp, atomically: true, encoding: .utf8)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
enum ModelStore {
|
|
50
|
+
static let root: URL = {
|
|
51
|
+
if let dir = ProcessInfo.processInfo.environment["AGENTX_MODELS_DIR"], !dir.isEmpty {
|
|
52
|
+
return URL(fileURLWithPath: dir)
|
|
53
|
+
}
|
|
54
|
+
return URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".agentx/models")
|
|
55
|
+
}()
|
|
56
|
+
|
|
57
|
+
/// Silero VAD v6, converted to Core ML by FluidInference (MIT). 0.9 MB.
|
|
58
|
+
/// One call scores 32 ms of 16 kHz audio (512 samples plus 64 of context).
|
|
59
|
+
static let vad = LocalModel(
|
|
60
|
+
name: "vad",
|
|
61
|
+
repo: "FluidInference/silero-vad-coreml",
|
|
62
|
+
revision: "b419383c55c110e2c9271fa6ee0ea83d03c70d96",
|
|
63
|
+
files: [
|
|
64
|
+
.init(path: "silero-vad-unified-v6.0.0.mlmodelc/analytics/coremldata.bin", size: 243, sha256: "2141be60ea0adf7acb1232fbcfaffb2be308ae02e6672d3762aedf36611ea9fd"),
|
|
65
|
+
.init(path: "silero-vad-unified-v6.0.0.mlmodelc/coremldata.bin", size: 593, sha256: "f460dcdf796b19c04bc38ab6e69601831f634e5e74d499487f0c8fe17ca12f0f"),
|
|
66
|
+
.init(path: "silero-vad-unified-v6.0.0.mlmodelc/metadata.json", size: 3232, sha256: "282b1b788b5d4d66d6a70d468464a7ada71d2469a8705526b741fd9ab841295a"),
|
|
67
|
+
.init(path: "silero-vad-unified-v6.0.0.mlmodelc/model.mil", size: 25126, sha256: "79eea23c368f3e7edf85af798a953f5b0910e4b08ff48e55fff8545dd05fd047"),
|
|
68
|
+
.init(path: "silero-vad-unified-v6.0.0.mlmodelc/weights/weight.bin", size: 882304, sha256: "853cf34740d3f5061f977ebe2976f7c921b064261c9c4753b3a1196f2dba42b4"),
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
/// NVIDIA Parakeet TDT 0.6B v3 (CC-BY-4.0), converted to Core ML by
|
|
72
|
+
/// FluidInference. 483 MB, almost all of it the encoder. The same build
|
|
73
|
+
/// FluidAudio loads by default: 6-bit palettized encoder, 15 s window.
|
|
74
|
+
static let parakeet = LocalModel(
|
|
75
|
+
name: "parakeet",
|
|
76
|
+
repo: "FluidInference/parakeet-tdt-0.6b-v3-coreml",
|
|
77
|
+
revision: "7dd20fe6b1797d35f5e3307e8b1732d9a178edfe",
|
|
78
|
+
files: [
|
|
79
|
+
.init(path: "Preprocessor.mlmodelc/analytics/coremldata.bin", size: 243, sha256: "c9beeb989c8d66f8be11df59bc6df277ec76cee404f6865b46243835ef562f6d"),
|
|
80
|
+
.init(path: "Preprocessor.mlmodelc/coremldata.bin", size: 486, sha256: "dbde3f2300842c1fd51ef3ff948a0bcffe65ffd2dca10707f2509f32c1d65b1d"),
|
|
81
|
+
.init(path: "Preprocessor.mlmodelc/metadata.json", size: 2841, sha256: "2a98699e22d279dd37fa1d238aeb1c6db1df0d6fad687775324157689d8f3acf"),
|
|
82
|
+
.init(path: "Preprocessor.mlmodelc/model.mil", size: 28181, sha256: "4b8518a956450fec57f06c2a21bdffc26973f7f1fa6842fb38fe917f896b6b93"),
|
|
83
|
+
.init(path: "Preprocessor.mlmodelc/weights/weight.bin", size: 491072, sha256: "129b76e3aeafa8afa3ea76d995b964b145fe83700d579f6ff42c4c38fa0968ea"),
|
|
84
|
+
.init(path: "Encoder.mlmodelc/analytics/coremldata.bin", size: 243, sha256: "42e638870d73f26b332918a3496ce36793fbb413a81cbd3d16ba01328637a105"),
|
|
85
|
+
.init(path: "Encoder.mlmodelc/coremldata.bin", size: 485, sha256: "d48034a167a82e88fc3df64f60af963ab3983538271175b8319e7d5720a0fb86"),
|
|
86
|
+
.init(path: "Encoder.mlmodelc/metadata.json", size: 2921, sha256: "da24da9cca943fb29d7fa8e376d57fca7cb3aa08ca51b956b0b0e56813f087e9"),
|
|
87
|
+
.init(path: "Encoder.mlmodelc/model.mil", size: 959769, sha256: "ed7b19156ca29fa7dfd6891deb9fda4b0e8893f68597c985d135736546a43808"),
|
|
88
|
+
.init(path: "Encoder.mlmodelc/weights/weight.bin", size: 445187200, sha256: "e2020f323703477a5b21d7c2d282c403e371afb5962e79877e3033e73ba6f421"),
|
|
89
|
+
.init(path: "Decoder.mlmodelc/analytics/coremldata.bin", size: 243, sha256: "4238c4e81ecd0dc94bd7dfbb60f7e2cc824107c1ffe0387b8607b72833dba350"),
|
|
90
|
+
.init(path: "Decoder.mlmodelc/coremldata.bin", size: 554, sha256: "18647af085d87bd8f3121c8a9b4d4564c1ede038dab63d295b4e745cf2d7fb99"),
|
|
91
|
+
.init(path: "Decoder.mlmodelc/metadata.json", size: 3427, sha256: "a39e93cd8371b8ded92635c7804fcd0590f0d1dd9415c6d19a0484be073077d9"),
|
|
92
|
+
.init(path: "Decoder.mlmodelc/model.mil", size: 13110, sha256: "ef2a0a281695398a62fde86ac269c68f73d5b578d7ed3b31f2ba91a2d1ea1f35"),
|
|
93
|
+
.init(path: "Decoder.mlmodelc/weights/weight.bin", size: 23604992, sha256: "48adf0f0d47c406c8253d4f7fef967436a39da14f5a65e66d5a4b407be355d41"),
|
|
94
|
+
.init(path: "JointDecisionv3.mlmodelc/analytics/coremldata.bin", size: 243, sha256: "26def4bf73dd56d29dee21c8ef97cb8969e62f6120ed1adc91e46828e2737b6c"),
|
|
95
|
+
.init(path: "JointDecisionv3.mlmodelc/coremldata.bin", size: 521, sha256: "f5fc08b741400f0088492c9e839418b1e18522f19cba28d361dd030c5f398342"),
|
|
96
|
+
.init(path: "JointDecisionv3.mlmodelc/metadata.json", size: 3453, sha256: "d9307211b9a37e0f0ac260c7660b1571a3de25841035cfdf9b58fd40425f890f"),
|
|
97
|
+
.init(path: "JointDecisionv3.mlmodelc/model.mil", size: 11775, sha256: "be60732943389a047175111a83f8839f3eb39d4803adafa828a0871b2f39818d"),
|
|
98
|
+
.init(path: "JointDecisionv3.mlmodelc/weights/weight.bin", size: 12642764, sha256: "4e0e63d840032f7f07ddb1d64446051166281e5491bf22da8a945c41f6eedb3e"),
|
|
99
|
+
.init(path: "parakeet_vocab.json", size: 151122, sha256: "7ec60e05f1b24480736ec0eed40900f4626bce1fa9a60fd700ec7e2a59198735"),
|
|
100
|
+
])
|
|
101
|
+
|
|
102
|
+
static let all = [vad, parakeet]
|
|
103
|
+
|
|
104
|
+
enum StoreError: LocalizedError {
|
|
105
|
+
case http(String, Int)
|
|
106
|
+
case mismatch(String)
|
|
107
|
+
var errorDescription: String? {
|
|
108
|
+
switch self {
|
|
109
|
+
case .http(let path, let code): return "download of \(path) failed with HTTP \(code)"
|
|
110
|
+
case .mismatch(let path): return "\(path) did not match its checksum and was deleted"
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// Downloads whatever is missing or wrong, checks every file, then
|
|
116
|
+
/// stamps the model installed. `progress` gets bytes done and total.
|
|
117
|
+
static func install(_ model: LocalModel, progress: ((Int64, Int64) -> Void)? = nil) async throws {
|
|
118
|
+
if model.isInstalled { return }
|
|
119
|
+
let fm = FileManager.default
|
|
120
|
+
try fm.createDirectory(at: model.directory, withIntermediateDirectories: true)
|
|
121
|
+
var done: Int64 = 0
|
|
122
|
+
for file in model.files {
|
|
123
|
+
let dest = model.url(file.path)
|
|
124
|
+
if fm.fileExists(atPath: dest.path), (try? sha256(of: dest)) == file.sha256 {
|
|
125
|
+
done += file.size
|
|
126
|
+
progress?(done, model.totalBytes)
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
try? fm.removeItem(at: dest)
|
|
130
|
+
try fm.createDirectory(at: dest.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
131
|
+
let source = URL(string: "https://huggingface.co/\(model.repo)/resolve/\(model.revision)/\(file.path)")!
|
|
132
|
+
let (tmp, response) = try await URLSession.shared.download(from: source)
|
|
133
|
+
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
|
134
|
+
try? fm.removeItem(at: tmp)
|
|
135
|
+
throw StoreError.http(file.path, http.statusCode)
|
|
136
|
+
}
|
|
137
|
+
guard (try? sha256(of: tmp)) == file.sha256 else {
|
|
138
|
+
try? fm.removeItem(at: tmp)
|
|
139
|
+
throw StoreError.mismatch(file.path)
|
|
140
|
+
}
|
|
141
|
+
try fm.moveItem(at: tmp, to: dest)
|
|
142
|
+
done += file.size
|
|
143
|
+
progress?(done, model.totalBytes)
|
|
144
|
+
}
|
|
145
|
+
try model.markInstalled()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// Streams the file, so the 445 MB encoder is never held in memory.
|
|
149
|
+
static func sha256(of url: URL) throws -> String {
|
|
150
|
+
let handle = try FileHandle(forReadingFrom: url)
|
|
151
|
+
defer { try? handle.close() }
|
|
152
|
+
var hasher = SHA256()
|
|
153
|
+
while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty {
|
|
154
|
+
hasher.update(data: chunk)
|
|
155
|
+
}
|
|
156
|
+
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private static let background = BackgroundInstalls()
|
|
160
|
+
|
|
161
|
+
/// Starts one download of `model` in the background unless it is
|
|
162
|
+
/// installed or already downloading. For first use: the turn that
|
|
163
|
+
/// found it missing falls back, and a later one gets the model.
|
|
164
|
+
static func installInBackground(_ model: LocalModel, log: @escaping @Sendable (String) -> Void) {
|
|
165
|
+
guard !model.isInstalled, background.begin(model.name) else { return }
|
|
166
|
+
log("downloading the \(model.name) model (\(model.totalBytes / 1_000_000) MB) into \(model.directory.path)")
|
|
167
|
+
Task.detached {
|
|
168
|
+
do {
|
|
169
|
+
try await install(model)
|
|
170
|
+
log("the \(model.name) model is installed")
|
|
171
|
+
} catch {
|
|
172
|
+
log("the \(model.name) model could not be downloaded: \(error.localizedDescription)")
|
|
173
|
+
}
|
|
174
|
+
background.end(model.name)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private final class BackgroundInstalls: @unchecked Sendable {
|
|
180
|
+
private let lock = NSLock()
|
|
181
|
+
private var running = Set<String>()
|
|
182
|
+
func begin(_ name: String) -> Bool { lock.lock(); defer { lock.unlock() }; return running.insert(name).inserted }
|
|
183
|
+
func end(_ name: String) { lock.lock(); running.remove(name); lock.unlock() }
|
|
184
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import AppKit
|
|
2
2
|
import SwiftUI
|
|
3
3
|
|
|
4
|
-
/// The Siri-style orb at the head of the pill: the agent's
|
|
5
|
-
/// listens, thinks and speaks.
|
|
4
|
+
/// The Siri-style orb at the head of the pill: the agent's nature palette
|
|
5
|
+
/// (lagoon, forest, dusk…), and it listens, thinks and speaks.
|
|
6
6
|
///
|
|
7
7
|
/// Built from SwiftUI's own gradients, no animation library. It only moves
|
|
8
8
|
/// while it has something to show and is on screen: idle or hidden, its
|
|
@@ -18,6 +18,9 @@ final class OrbModel: ObservableObject {
|
|
|
18
18
|
/// 0…1: the microphone while listening.
|
|
19
19
|
@Published var level: Double = 0
|
|
20
20
|
@Published var tint = NSColor(srgbRed: 0.078, green: 0.722, blue: 0.651, alpha: 1)
|
|
21
|
+
/// The agent's palette, five stops deep to light, from the daemon.
|
|
22
|
+
/// Nil: shades of `tint` (an error, held notifications, an older daemon).
|
|
23
|
+
@Published var colors: [NSColor]?
|
|
21
24
|
/// The pill is on screen. Off, nothing moves.
|
|
22
25
|
@Published var onScreen = false
|
|
23
26
|
@Published var reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
|
@@ -43,8 +46,8 @@ struct PillOrbView: View {
|
|
|
43
46
|
let paused = model.paused
|
|
44
47
|
TimelineView(.animation(minimumInterval: 1.0 / 30, paused: paused)) { context in
|
|
45
48
|
OrbBody(t: paused ? 0 : context.date.timeIntervalSinceReferenceDate,
|
|
46
|
-
phase: model.phase, level: model.level, tint: model.tint,
|
|
47
|
-
ringWidth: 5)
|
|
49
|
+
phase: model.phase, level: model.level, tint: model.tint, colors: model.colors,
|
|
50
|
+
still: model.still, ringWidth: 5)
|
|
48
51
|
.frame(width: Self.designSize, height: Self.designSize)
|
|
49
52
|
.scaleEffect(diameter / Self.designSize)
|
|
50
53
|
}
|
|
@@ -89,11 +92,12 @@ final class PillOrb: NSHostingView<PillOrbView> {
|
|
|
89
92
|
|
|
90
93
|
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
|
91
94
|
|
|
92
|
-
/// Show `phase` in `
|
|
93
|
-
/// redraws what changed.
|
|
94
|
-
func show(_ phase: OrbModel.Phase, tint: NSColor) {
|
|
95
|
+
/// Show `phase` in `colors`, or in shades of `tint` without them.
|
|
96
|
+
/// Cheap to call on every render: SwiftUI only redraws what changed.
|
|
97
|
+
func show(_ phase: OrbModel.Phase, tint: NSColor, colors: [NSColor]?) {
|
|
95
98
|
let model = driver.model
|
|
96
99
|
if model.tint != tint { model.tint = tint }
|
|
100
|
+
if model.colors != colors { model.colors = colors }
|
|
97
101
|
if model.phase != phase {
|
|
98
102
|
model.phase = phase
|
|
99
103
|
if phase != .listening { model.level = 0 }
|
|
@@ -160,6 +164,8 @@ struct OrbBody: View {
|
|
|
160
164
|
let phase: OrbModel.Phase
|
|
161
165
|
let level: Double
|
|
162
166
|
let tint: NSColor
|
|
167
|
+
/// The agent's palette, deep to light; nil for shades of `tint`.
|
|
168
|
+
var colors: [NSColor]? = nil
|
|
163
169
|
/// Reduce Motion: a still orb, sized and shaded by state alone.
|
|
164
170
|
let still: Bool
|
|
165
171
|
/// The thinking ring's stroke at the 96-point design size. The pill
|
|
@@ -190,12 +196,17 @@ struct OrbBody: View {
|
|
|
190
196
|
let scale = 0.78 + 0.22 * energy
|
|
191
197
|
ZStack {
|
|
192
198
|
Circle()
|
|
193
|
-
.fill(
|
|
199
|
+
.fill(glow.opacity(0.28 + 0.32 * energy))
|
|
194
200
|
.blur(radius: 10 + 12 * energy)
|
|
195
201
|
.scaleEffect(scale * 1.08)
|
|
196
202
|
fill
|
|
197
203
|
.clipShape(Circle())
|
|
198
204
|
.scaleEffect(scale)
|
|
205
|
+
// Glass: a soft light from the top left, as on a marble.
|
|
206
|
+
Circle()
|
|
207
|
+
.fill(RadialGradient(colors: [.white.opacity(0.42), .white.opacity(0)],
|
|
208
|
+
center: UnitPoint(x: 0.34, y: 0.26), startRadius: 0, endRadius: 34))
|
|
209
|
+
.scaleEffect(scale)
|
|
199
210
|
// Thinking reads as a slow ring going round; the other states
|
|
200
211
|
// as a soft rim.
|
|
201
212
|
if phase == .thinking {
|
|
@@ -247,9 +258,25 @@ struct OrbBody: View {
|
|
|
247
258
|
]
|
|
248
259
|
}
|
|
249
260
|
|
|
261
|
+
/// The halo's colour: the palette's middle stop, or the tint.
|
|
262
|
+
private var glow: Color {
|
|
263
|
+
if let colors, colors.count == 5 { return Color(nsColor: colors[2]) }
|
|
264
|
+
return Color(nsColor: tint)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/// Five colours for the mesh: the agent's palette when it has one,
|
|
268
|
+
/// with its light stop brightening into a core as the voice grows.
|
|
269
|
+
private var palette: [Color] {
|
|
270
|
+
guard let colors, colors.count == 5 else { return shades }
|
|
271
|
+
let core = CGFloat(0.1 + 0.35 * energy)
|
|
272
|
+
let light = colors[4].blended(withFraction: core, of: .white) ?? colors[4]
|
|
273
|
+
// Spread so neighbouring mesh points differ: deep beside light.
|
|
274
|
+
return [colors[1], colors[3], colors[0], colors[2], light].map { Color(nsColor: $0) }
|
|
275
|
+
}
|
|
276
|
+
|
|
250
277
|
/// Five shades around the agent's colour: lighter, deeper and two
|
|
251
278
|
/// neighbouring hues, and a bright core that grows with the voice.
|
|
252
|
-
private var
|
|
279
|
+
private var shades: [Color] {
|
|
253
280
|
let base = tint.usingColorSpace(.sRGB) ?? tint
|
|
254
281
|
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
|
255
282
|
base.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
|