agentix-cli 0.57.0 → 0.59.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/Config.swift +19 -5
- 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 +131 -169
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +174 -35
- package/apps/mac-voice/Sources/AgentXVoice/Parakeet.swift +264 -0
- package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +70 -0
- package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +81 -3
- package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +26 -22
- package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +10 -2
- 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 +23 -6
- package/apps/mac-voice/Sources/AgentXVoice/TurnEnd.swift +110 -0
- package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +23 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +47 -70
- package/apps/mac-voice/build.sh +10 -1
- package/dist/{App-Z5VOAUXB.js → App-QEJGXZTY.js} +2 -2
- package/dist/{ChatApp-BE7ONQKS.js → ChatApp-VKWQN5GM.js} +2 -2
- package/dist/{agent-Q74EISKP.js → agent-MJKLI2L5.js} +2 -2
- package/dist/{board-dashboard-A7PQOI6C.js → board-dashboard-2KVT72J7.js} +221 -138
- package/dist/board-dashboard-2KVT72J7.js.map +1 -0
- package/dist/{chunk-I2LYULR4.js → chunk-2XZGHIVP.js} +5 -5
- package/dist/{chunk-5LLGIX5M.js → chunk-3EVCHRJF.js} +2 -2
- package/dist/{chunk-6QNPCVRN.js → chunk-4ZTV6JV7.js} +3 -3
- package/dist/chunk-4ZTV6JV7.js.map +1 -0
- package/dist/{chunk-VI7GBDKV.js → chunk-5OF3EGCZ.js} +2 -2
- package/dist/{chunk-RXEAACVH.js → chunk-6DUTIPTY.js} +2 -2
- package/dist/{chunk-CQUVUVXR.js → chunk-6ZV2QFXE.js} +2 -2
- package/dist/{chunk-2MIOMYT2.js → chunk-DAAWUFO4.js} +2 -2
- package/dist/{chunk-547G3HYQ.js → chunk-EJQOKSKP.js} +2 -2
- package/dist/{chunk-POL7RAJQ.js → chunk-ILXW3ERG.js} +117 -117
- package/dist/chunk-ILXW3ERG.js.map +1 -0
- package/dist/{chunk-ERL6AS3O.js → chunk-IW4XIZAZ.js} +8 -8
- package/dist/{chunk-ERL6AS3O.js.map → chunk-IW4XIZAZ.js.map} +1 -1
- package/dist/{chunk-Q6SMLSRO.js → chunk-JUY77L7C.js} +2 -2
- package/dist/{chunk-KW7TIOS3.js → chunk-K62U5EJJ.js} +2 -2
- package/dist/{chunk-KKGFZEXS.js → chunk-L5OCF33B.js} +37 -36
- package/dist/chunk-L5OCF33B.js.map +1 -0
- package/dist/{chunk-XTD3SZIP.js → chunk-N5GOBLUI.js} +2 -2
- package/dist/{chunk-VVBGPT6W.js → chunk-OIDMPPV5.js} +2 -2
- package/dist/{chunk-OZZPJ2YG.js → chunk-SDBU6747.js} +190 -177
- package/dist/chunk-SDBU6747.js.map +1 -0
- package/dist/{chunk-NKMFHEGV.js → chunk-WFPRVZBA.js} +2 -2
- package/dist/{chunk-6OABTCRA.js → chunk-WI5R64WO.js} +2 -2
- package/dist/{chunk-5W7NJHCS.js → chunk-YCIZ2WHX.js} +2 -2
- package/dist/{chunk-U5YBQJ5Q.js → chunk-YQU74AOL.js} +2 -2
- package/dist/{chunk-V7ZK6ZIO.js → chunk-YRB75GMN.js} +2 -2
- package/dist/cli.js +1 -1
- package/dist/client-X2PTYKVX.js +2 -0
- package/dist/{config-P6YXC6XZ.js → config-XB2ORKWS.js} +2 -2
- package/dist/config-mutator-KMXR3XDG.js +2 -0
- package/dist/doctor-6BIMNEGU.js +2 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +1 -1
- package/dist/program-DHDC6XYB.js +2 -0
- package/dist/{promote-KVPLDSKT.js → promote-GBR6YSQW.js} +2 -2
- package/dist/query-672RGMXO.js +2 -0
- package/dist/{registry-AUOI4TW2.js → registry-P535FNLB.js} +2 -2
- package/dist/{request-planner-LMZ5ZAXC.js → request-planner-KHTCHF7N.js} +2 -2
- package/dist/{review-candidates-WCBXLLDS.js → review-candidates-Y5KCRUNU.js} +2 -2
- package/dist/{routing-FBASYKUL.js → routing-RWCCDJ4D.js} +2 -2
- package/dist/schedule-tool-H46AK53H.js +2 -0
- package/dist/scheduler-3XLWBCX7.js +2 -0
- package/dist/{seat-DBFW3Z5P.js → seat-PJPKLH6L.js} +2 -2
- package/dist/{select-6CNVVFA5.js → select-VMTJAX5X.js} +2 -2
- package/dist/{set-enabled-AYZH3KSB.js → set-enabled-LPXH3ZQP.js} +2 -2
- package/dist/{tool-PKS4NBZ6.js → tool-MWXG44OT.js} +2 -2
- package/dist/wiki-QHMHVFWU.js +2 -0
- package/package.json +1 -1
- package/dist/board-dashboard-A7PQOI6C.js.map +0 -1
- package/dist/chunk-6QNPCVRN.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-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-QEJGXZTY.js.map} +0 -0
- /package/dist/{ChatApp-BE7ONQKS.js.map → ChatApp-VKWQN5GM.js.map} +0 -0
- /package/dist/{agent-Q74EISKP.js.map → agent-MJKLI2L5.js.map} +0 -0
- /package/dist/{chunk-I2LYULR4.js.map → chunk-2XZGHIVP.js.map} +0 -0
- /package/dist/{chunk-5LLGIX5M.js.map → chunk-3EVCHRJF.js.map} +0 -0
- /package/dist/{chunk-VI7GBDKV.js.map → chunk-5OF3EGCZ.js.map} +0 -0
- /package/dist/{chunk-RXEAACVH.js.map → chunk-6DUTIPTY.js.map} +0 -0
- /package/dist/{chunk-CQUVUVXR.js.map → chunk-6ZV2QFXE.js.map} +0 -0
- /package/dist/{chunk-2MIOMYT2.js.map → chunk-DAAWUFO4.js.map} +0 -0
- /package/dist/{chunk-547G3HYQ.js.map → chunk-EJQOKSKP.js.map} +0 -0
- /package/dist/{chunk-Q6SMLSRO.js.map → chunk-JUY77L7C.js.map} +0 -0
- /package/dist/{chunk-KW7TIOS3.js.map → chunk-K62U5EJJ.js.map} +0 -0
- /package/dist/{chunk-XTD3SZIP.js.map → chunk-N5GOBLUI.js.map} +0 -0
- /package/dist/{chunk-VVBGPT6W.js.map → chunk-OIDMPPV5.js.map} +0 -0
- /package/dist/{chunk-NKMFHEGV.js.map → chunk-WFPRVZBA.js.map} +0 -0
- /package/dist/{chunk-6OABTCRA.js.map → chunk-WI5R64WO.js.map} +0 -0
- /package/dist/{chunk-5W7NJHCS.js.map → chunk-YCIZ2WHX.js.map} +0 -0
- /package/dist/{chunk-U5YBQJ5Q.js.map → chunk-YQU74AOL.js.map} +0 -0
- /package/dist/{chunk-V7ZK6ZIO.js.map → chunk-YRB75GMN.js.map} +0 -0
- /package/dist/{client-EFP2PKA3.js.map → client-X2PTYKVX.js.map} +0 -0
- /package/dist/{config-P6YXC6XZ.js.map → config-XB2ORKWS.js.map} +0 -0
- /package/dist/{config-mutator-3GKXLTHH.js.map → config-mutator-KMXR3XDG.js.map} +0 -0
- /package/dist/{doctor-JEWMHXYH.js.map → doctor-6BIMNEGU.js.map} +0 -0
- /package/dist/{program-7DUQJDSQ.js.map → program-DHDC6XYB.js.map} +0 -0
- /package/dist/{promote-KVPLDSKT.js.map → promote-GBR6YSQW.js.map} +0 -0
- /package/dist/{query-AGOMCYTT.js.map → query-672RGMXO.js.map} +0 -0
- /package/dist/{registry-AUOI4TW2.js.map → registry-P535FNLB.js.map} +0 -0
- /package/dist/{request-planner-LMZ5ZAXC.js.map → request-planner-KHTCHF7N.js.map} +0 -0
- /package/dist/{review-candidates-WCBXLLDS.js.map → review-candidates-Y5KCRUNU.js.map} +0 -0
- /package/dist/{routing-FBASYKUL.js.map → routing-RWCCDJ4D.js.map} +0 -0
- /package/dist/{schedule-tool-RJRJ63SS.js.map → schedule-tool-H46AK53H.js.map} +0 -0
- /package/dist/{scheduler-ATA7Z44S.js.map → scheduler-3XLWBCX7.js.map} +0 -0
- /package/dist/{seat-DBFW3Z5P.js.map → seat-PJPKLH6L.js.map} +0 -0
- /package/dist/{select-6CNVVFA5.js.map → select-VMTJAX5X.js.map} +0 -0
- /package/dist/{set-enabled-AYZH3KSB.js.map → set-enabled-LPXH3ZQP.js.map} +0 -0
- /package/dist/{tool-PKS4NBZ6.js.map → tool-MWXG44OT.js.map} +0 -0
- /package/dist/{wiki-QW6BDNEQ.js.map → wiki-QHMHVFWU.js.map} +0 -0
|
@@ -28,11 +28,25 @@ enum Config {
|
|
|
28
28
|
set { UserDefaults.standard.set(newValue, forKey: "showPill") }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/// The orb
|
|
32
|
-
///
|
|
33
|
-
static var
|
|
34
|
-
get { UserDefaults.standard.object(forKey: "
|
|
35
|
-
set { UserDefaults.standard.set(newValue, forKey: "
|
|
31
|
+
/// The pill's orb moves with the voice. On by default; off keeps it
|
|
32
|
+
/// still in every state, as Reduce Motion does.
|
|
33
|
+
static var animatedOrb: Bool {
|
|
34
|
+
get { UserDefaults.standard.object(forKey: "animatedOrb") as? Bool ?? true }
|
|
35
|
+
set { UserDefaults.standard.set(newValue, forKey: "animatedOrb") }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Where the pill was last dragged to (its bottom-left corner, in
|
|
39
|
+
/// screen points). Nil: the default corner.
|
|
40
|
+
static var pillOrigin: CGPoint? {
|
|
41
|
+
get {
|
|
42
|
+
guard let xy = UserDefaults.standard.array(forKey: "pillOrigin") as? [Double], xy.count == 2
|
|
43
|
+
else { return nil }
|
|
44
|
+
return CGPoint(x: xy[0], y: xy[1])
|
|
45
|
+
}
|
|
46
|
+
set {
|
|
47
|
+
if let p = newValue { UserDefaults.standard.set([Double(p.x), Double(p.y)], forKey: "pillOrigin") }
|
|
48
|
+
else { UserDefaults.standard.removeObject(forKey: "pillOrigin") }
|
|
49
|
+
}
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
/// The agent actually answering: `agentID`, else `chosenAgentID`, else
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The daemon's voice history (src/daemon/voice-history-api.ts).
|
|
4
|
+
///
|
|
5
|
+
/// The list is summaries only; an answer in full is fetched when one is
|
|
6
|
+
/// opened. A replay is queued by the daemon in the agent's voice, at the
|
|
7
|
+
/// back of the speaking queue, so it never talks over what is playing.
|
|
8
|
+
/// Loopback needs no token, as for the rest of AgentClient.
|
|
9
|
+
enum HistoryClient {
|
|
10
|
+
/// One page, newest first; `before` is the previous page's `next`.
|
|
11
|
+
static func page(agent: String?, limit: Int, before: String? = nil) async -> Result<VoiceHistoryPage, HistoryFailure> {
|
|
12
|
+
let q = HistoryLogic.query(agent: agent, limit: limit, before: before)
|
|
13
|
+
guard let url = URL(string: "\(Config.daemonURL)/voice/history?\(q)") else { return .failure(.init(message: "Bad daemon address.")) }
|
|
14
|
+
var req = URLRequest(url: url)
|
|
15
|
+
req.timeoutInterval = 5
|
|
16
|
+
do {
|
|
17
|
+
let (data, response) = try await URLSession.shared.data(for: req)
|
|
18
|
+
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
19
|
+
if status == 200, let page = try? JSONDecoder().decode(VoiceHistoryPage.self, from: data) { return .success(page) }
|
|
20
|
+
return .failure(.init(message: errorText(data) ?? "The daemon answered \(status)."))
|
|
21
|
+
} catch {
|
|
22
|
+
return .failure(.init(message: "The AgentX daemon isn't reachable."))
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// One exchange in full, or nil when it cannot be loaded.
|
|
27
|
+
static func detail(_ id: String) async -> VoiceExchangeDetail? {
|
|
28
|
+
guard let url = URL(string: "\(Config.daemonURL)/voice/history/\(id)") else { return nil }
|
|
29
|
+
var req = URLRequest(url: url)
|
|
30
|
+
req.timeoutInterval = 5
|
|
31
|
+
guard let (data, response) = try? await URLSession.shared.data(for: req),
|
|
32
|
+
(response as? HTTPURLResponse)?.statusCode == 200 else { return nil }
|
|
33
|
+
return try? JSONDecoder().decode(VoiceExchangeDetail.self, from: data)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Say the answer again. Nil when queued, else why not.
|
|
37
|
+
static func replay(_ id: String) async -> String? {
|
|
38
|
+
guard let url = URL(string: "\(Config.daemonURL)/voice/history/\(id)/replay") else { return "Bad daemon address." }
|
|
39
|
+
var req = URLRequest(url: url)
|
|
40
|
+
req.httpMethod = "POST"
|
|
41
|
+
req.timeoutInterval = 5
|
|
42
|
+
guard let (data, response) = try? await URLSession.shared.data(for: req) else {
|
|
43
|
+
return "The AgentX daemon isn't reachable."
|
|
44
|
+
}
|
|
45
|
+
if (response as? HTTPURLResponse)?.statusCode == 202 { return nil }
|
|
46
|
+
return errorText(data) ?? "The answer wasn't queued."
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
struct HistoryFailure: Error { let message: String }
|
|
50
|
+
|
|
51
|
+
private static func errorText(_ data: Data) -> String? {
|
|
52
|
+
struct Reply: Decodable { let error: String? }
|
|
53
|
+
return (try? JSONDecoder().decode(Reply.self, from: data))?.error
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Past voice exchanges as the daemon serves them (GET /voice/history),
|
|
4
|
+
/// and the small decisions the History window and the menu make about
|
|
5
|
+
/// them. Foundation only, so Tests/History can run it without a window.
|
|
6
|
+
|
|
7
|
+
/// One row of the list: a bounded summary, never the whole answer.
|
|
8
|
+
struct VoiceExchange: Decodable, Identifiable, Equatable {
|
|
9
|
+
let id: String
|
|
10
|
+
let agentId: String
|
|
11
|
+
/// When it was asked, ms since the epoch.
|
|
12
|
+
let at: Double
|
|
13
|
+
let durationMs: Int?
|
|
14
|
+
/// "ok", "error", "in-flight", "canceled" or "timeout".
|
|
15
|
+
let status: String
|
|
16
|
+
let question: String
|
|
17
|
+
let answerPreview: String
|
|
18
|
+
let answerChars: Int
|
|
19
|
+
let error: String?
|
|
20
|
+
|
|
21
|
+
var date: Date { Date(timeIntervalSince1970: at / 1000) }
|
|
22
|
+
var failed: Bool { status == "error" || status == "timeout" || status == "canceled" }
|
|
23
|
+
/// Something to say again.
|
|
24
|
+
var canReplay: Bool { answerChars > 0 && !failed }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
struct VoiceHistoryPage: Decodable {
|
|
28
|
+
let exchanges: [VoiceExchange]
|
|
29
|
+
let next: String?
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// One exchange in full (GET /voice/history/:id), loaded when opened.
|
|
33
|
+
struct VoiceExchangeDetail: Decodable {
|
|
34
|
+
struct Button: Decodable, Equatable { let label: String; let url: String }
|
|
35
|
+
struct Media: Decodable, Equatable { let type: String; let url: String; let caption: String? }
|
|
36
|
+
struct UI: Decodable, Equatable { let buttons: [Button]?; let media: Media? }
|
|
37
|
+
|
|
38
|
+
let id: String
|
|
39
|
+
let agentId: String
|
|
40
|
+
let at: Double
|
|
41
|
+
let durationMs: Int?
|
|
42
|
+
let status: String
|
|
43
|
+
let question: String
|
|
44
|
+
let answer: String?
|
|
45
|
+
let truncated: Bool
|
|
46
|
+
let ui: UI?
|
|
47
|
+
let error: String?
|
|
48
|
+
/// The task page, relative to the dashboard's address.
|
|
49
|
+
let taskPath: String
|
|
50
|
+
|
|
51
|
+
/// Buttons and a non-image media link, as (label, url).
|
|
52
|
+
var links: [(String, String)] {
|
|
53
|
+
var out = (ui?.buttons ?? []).map { ($0.label, $0.url) }
|
|
54
|
+
if let m = ui?.media { out.append((m.caption ?? m.type.capitalized, m.url)) }
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// A day's exchanges under one heading.
|
|
60
|
+
struct HistoryDay: Equatable {
|
|
61
|
+
let title: String
|
|
62
|
+
let exchanges: [VoiceExchange]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
enum HistoryLogic {
|
|
66
|
+
/// Newest day first, each day newest first; "Today", "Yesterday", then
|
|
67
|
+
/// the date. Input order does not matter.
|
|
68
|
+
static func groupByDay(_ items: [VoiceExchange], now: Date = Date(),
|
|
69
|
+
calendar: Calendar = .current, locale: Locale = .current) -> [HistoryDay] {
|
|
70
|
+
let sorted = items.sorted { $0.at != $1.at ? $0.at > $1.at : $0.id > $1.id }
|
|
71
|
+
var days: [HistoryDay] = []
|
|
72
|
+
var current: (Date, [VoiceExchange])?
|
|
73
|
+
for e in sorted {
|
|
74
|
+
let day = calendar.startOfDay(for: e.date)
|
|
75
|
+
if let c = current, c.0 == day {
|
|
76
|
+
current = (day, c.1 + [e])
|
|
77
|
+
} else {
|
|
78
|
+
if let c = current { days.append(HistoryDay(title: dayTitle(c.0, now: now, calendar: calendar, locale: locale), exchanges: c.1)) }
|
|
79
|
+
current = (day, [e])
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if let c = current { days.append(HistoryDay(title: dayTitle(c.0, now: now, calendar: calendar, locale: locale), exchanges: c.1)) }
|
|
83
|
+
return days
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static func dayTitle(_ day: Date, now: Date, calendar: Calendar = .current, locale: Locale = .current) -> String {
|
|
87
|
+
if calendar.isDate(day, inSameDayAs: now) { return "Today" }
|
|
88
|
+
if let y = calendar.date(byAdding: .day, value: -1, to: now), calendar.isDate(day, inSameDayAs: y) { return "Yesterday" }
|
|
89
|
+
let f = DateFormatter()
|
|
90
|
+
f.locale = locale
|
|
91
|
+
f.calendar = calendar
|
|
92
|
+
f.timeZone = calendar.timeZone
|
|
93
|
+
let sameYear = calendar.component(.year, from: day) == calendar.component(.year, from: now)
|
|
94
|
+
f.setLocalizedDateFormatFromTemplate(sameYear ? "EEEEdMMMM" : "EEEEdMMMMyyyy")
|
|
95
|
+
return f.string(from: day)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// How long the answer took: "0.8 s", "12 s", "2 min 5 s"; "—" unknown.
|
|
99
|
+
static func duration(_ ms: Int?) -> String {
|
|
100
|
+
guard let ms, ms >= 0 else { return "—" }
|
|
101
|
+
if ms < 10_000 { return String(format: "%.1f s", Double(ms) / 1000) }
|
|
102
|
+
let s = Int((Double(ms) / 1000).rounded())
|
|
103
|
+
if s < 60 { return "\(s) s" }
|
|
104
|
+
let m = s / 60, r = s % 60
|
|
105
|
+
return r == 0 ? "\(m) min" : "\(m) min \(r) s"
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// A menu row: the question, clipped, and who answered.
|
|
109
|
+
static func menuTitle(_ e: VoiceExchange, agentName: String, max: Int = 44) -> String {
|
|
110
|
+
let q = e.question.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
|
111
|
+
let clipped = q.count > max ? String(q.prefix(max - 1)).trimmingCharacters(in: .whitespaces) + "…" : q
|
|
112
|
+
return "\u{201C}\(clipped)\u{201D} · \(agentName)"
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// Add a page to what is shown: no duplicates, newest first.
|
|
116
|
+
static func merge(_ shown: [VoiceExchange], _ page: [VoiceExchange]) -> [VoiceExchange] {
|
|
117
|
+
var seen = Set(shown.map(\.id))
|
|
118
|
+
var out = shown
|
|
119
|
+
for e in page where seen.insert(e.id).inserted { out.append(e) }
|
|
120
|
+
return out.sorted { $0.at != $1.at ? $0.at > $1.at : $0.id > $1.id }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Bare http(s) URLs as markdown links, so the written answer shows
|
|
124
|
+
/// them as links. URLs already inside a markdown link or <…> are left.
|
|
125
|
+
static func linkify(_ text: String) -> String {
|
|
126
|
+
guard let re = try? NSRegularExpression(pattern: #"(?<![\(\[<])\bhttps?://[^\s<>()\[\]]+"#) else { return text }
|
|
127
|
+
let ns = text as NSString
|
|
128
|
+
var out = ""
|
|
129
|
+
var last = 0
|
|
130
|
+
for m in re.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
|
|
131
|
+
var url = ns.substring(with: m.range)
|
|
132
|
+
// A sentence's full stop or comma is not part of the link.
|
|
133
|
+
var tail = ""
|
|
134
|
+
while let c = url.last, ".,;:!?\"'".contains(c) { tail = String(c) + tail; url.removeLast() }
|
|
135
|
+
out += ns.substring(with: NSRange(location: last, length: m.range.location - last))
|
|
136
|
+
out += "[\(url)](\(url))"
|
|
137
|
+
out += tail
|
|
138
|
+
last = m.range.location + m.range.length
|
|
139
|
+
}
|
|
140
|
+
out += ns.substring(from: last)
|
|
141
|
+
return out
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/// The query string for a page of history.
|
|
145
|
+
static func query(agent: String?, limit: Int, before: String?) -> String {
|
|
146
|
+
var items = [URLQueryItem(name: "limit", value: String(limit))]
|
|
147
|
+
if let agent, !agent.isEmpty { items.append(URLQueryItem(name: "agent", value: agent)) }
|
|
148
|
+
if let before, !before.isEmpty { items.append(URLQueryItem(name: "before", value: before)) }
|
|
149
|
+
var c = URLComponents()
|
|
150
|
+
c.queryItems = items
|
|
151
|
+
return c.percentEncodedQuery ?? ""
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -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
|
+
}
|