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
|
@@ -213,7 +213,11 @@ enum AgentClient {
|
|
|
213
213
|
/// "#RRGGBB": presence.color, else derived from the id. Older
|
|
214
214
|
/// daemons send none, and the app derives it the same way.
|
|
215
215
|
let color: String?
|
|
216
|
+
/// The orb's nature palette. Older daemons send none, and the orb
|
|
217
|
+
/// uses shades of `color`.
|
|
218
|
+
let palette: Palette?
|
|
216
219
|
var label: String { name ?? id }
|
|
220
|
+
struct Palette: Decodable { let id: String; let colors: [String] }
|
|
217
221
|
}
|
|
218
222
|
|
|
219
223
|
/// The daemon's agents, or nil when the daemon cannot be reached.
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import WebKit
|
|
3
|
+
|
|
4
|
+
/// What the agent said, in a form you can read, select and copy, inside
|
|
5
|
+
/// the pill.
|
|
6
|
+
///
|
|
7
|
+
/// Speech is lossy in one specific way that matters: a URL cannot be
|
|
8
|
+
/// spoken usefully, an image cannot be spoken at all, and a name you have
|
|
9
|
+
/// not seen written is a name you cannot act on. The spoken answer is
|
|
10
|
+
/// deliberately short and stripped of exactly those things, so without a
|
|
11
|
+
/// visual surface everything worth keeping from a turn is the part that
|
|
12
|
+
/// gets thrown away.
|
|
13
|
+
///
|
|
14
|
+
/// Rich content arrives through the same `agentx:ui` directive Telegram
|
|
15
|
+
/// and WhatsApp already use, parsed server-side, so an agent has one way to
|
|
16
|
+
/// attach a link or a picture regardless of where it is speaking.
|
|
17
|
+
final class AnswerView: NSView {
|
|
18
|
+
// A web view, because agents write markdown: headings, bullets, fenced
|
|
19
|
+
// code, tables. An NSTextView shows that as a mess of asterisks and
|
|
20
|
+
// hashes, which defeats the answer's only purpose: being MORE readable
|
|
21
|
+
// than the spoken answer, not less. Text in it is selectable, and it
|
|
22
|
+
// scrolls once the answer is taller than the pill lets it grow.
|
|
23
|
+
private let web = WKWebView()
|
|
24
|
+
private let links = NSStackView()
|
|
25
|
+
private let thumb = NSImageView()
|
|
26
|
+
private let actions = NSStackView()
|
|
27
|
+
private var source = ""
|
|
28
|
+
|
|
29
|
+
/// The answer's natural height, measured after it renders. The pill
|
|
30
|
+
/// grows to it, up to its maximum.
|
|
31
|
+
var onHeight: ((CGFloat) -> Void)?
|
|
32
|
+
/// "Open in chat". Set by the app.
|
|
33
|
+
var onOpenChat: (() -> Void)?
|
|
34
|
+
|
|
35
|
+
private static let linkRow: CGFloat = 34
|
|
36
|
+
private static let imageHeight: CGFloat = 120
|
|
37
|
+
|
|
38
|
+
override init(frame: NSRect) {
|
|
39
|
+
super.init(frame: frame)
|
|
40
|
+
web.setValue(false, forKey: "drawsBackground")
|
|
41
|
+
web.navigationDelegate = self
|
|
42
|
+
addSubview(web)
|
|
43
|
+
|
|
44
|
+
thumb.imageScaling = .scaleProportionallyUpOrDown
|
|
45
|
+
thumb.isHidden = true
|
|
46
|
+
addSubview(thumb)
|
|
47
|
+
|
|
48
|
+
links.orientation = .horizontal
|
|
49
|
+
links.spacing = 8
|
|
50
|
+
links.edgeInsets = NSEdgeInsets(top: 4, left: 12, bottom: 8, right: 12)
|
|
51
|
+
addSubview(links)
|
|
52
|
+
|
|
53
|
+
// Copy and Open in chat, shown while the pointer is over the answer.
|
|
54
|
+
actions.orientation = .horizontal
|
|
55
|
+
actions.spacing = 2
|
|
56
|
+
actions.addArrangedSubview(actionButton("doc.on.doc", "Copy the answer", #selector(copyAnswer)))
|
|
57
|
+
actions.addArrangedSubview(actionButton("bubble.left.and.bubble.right", "Open in chat", #selector(openChat)))
|
|
58
|
+
actions.isHidden = true
|
|
59
|
+
actions.edgeInsets = NSEdgeInsets(top: 1, left: 3, bottom: 1, right: 3)
|
|
60
|
+
// A backing of its own, so the buttons read over the text below.
|
|
61
|
+
actions.wantsLayer = true
|
|
62
|
+
actions.layer?.cornerRadius = 7
|
|
63
|
+
actions.layer?.cornerCurve = .continuous
|
|
64
|
+
addSubview(actions)
|
|
65
|
+
|
|
66
|
+
addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
|
|
67
|
+
owner: self, userInfo: nil))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@available(*, unavailable)
|
|
71
|
+
required init?(coder: NSCoder) { fatalError("not from a nib") }
|
|
72
|
+
|
|
73
|
+
private func actionButton(_ symbol: String, _ label: String, _ action: Selector) -> NSButton {
|
|
74
|
+
let b = NSButton()
|
|
75
|
+
b.isBordered = false
|
|
76
|
+
b.bezelStyle = .regularSquare
|
|
77
|
+
b.imagePosition = .imageOnly
|
|
78
|
+
b.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label)
|
|
79
|
+
b.contentTintColor = .secondaryLabelColor
|
|
80
|
+
b.toolTip = label
|
|
81
|
+
b.setAccessibilityLabel(label)
|
|
82
|
+
b.target = self
|
|
83
|
+
b.action = action
|
|
84
|
+
b.widthAnchor.constraint(equalToConstant: 24).isActive = true
|
|
85
|
+
b.heightAnchor.constraint(equalToConstant: 22).isActive = true
|
|
86
|
+
return b
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
override func resizeSubviews(withOldSize oldSize: NSSize) {
|
|
90
|
+
let w = bounds.width, h = bounds.height
|
|
91
|
+
let linkH: CGFloat = links.arrangedSubviews.isEmpty ? 0 : Self.linkRow
|
|
92
|
+
let imgH: CGFloat = thumb.isHidden ? 0 : Self.imageHeight
|
|
93
|
+
thumb.frame = NSRect(x: 12, y: h - imgH - 10, width: w - 24, height: imgH)
|
|
94
|
+
web.frame = NSRect(x: 0, y: linkH, width: w, height: max(0, h - linkH - imgH - (imgH > 0 ? 16 : 0)))
|
|
95
|
+
links.frame = NSRect(x: 0, y: 0, width: w, height: linkH)
|
|
96
|
+
actions.frame = NSRect(x: w - 64, y: h - 32, width: 56, height: 26)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Does this turn have anything the spoken answer did not already
|
|
100
|
+
/// deliver?
|
|
101
|
+
///
|
|
102
|
+
/// Opening on every turn, including "Ok.", shows one word you have
|
|
103
|
+
/// already heard. A surface that adds nothing trains you to ignore it,
|
|
104
|
+
/// so the turn where it DOES carry a link gets ignored too.
|
|
105
|
+
///
|
|
106
|
+
/// Worth showing when there is rich content, a URL you could click, or
|
|
107
|
+
/// materially more written than was spoken. Otherwise the speech was
|
|
108
|
+
/// the whole answer.
|
|
109
|
+
static func isWorthShowing(spoken: String, written: String?, buttons: [(String, String)], imageURL: String?) -> Bool {
|
|
110
|
+
if !buttons.isEmpty || imageURL != nil { return true }
|
|
111
|
+
let text = (written?.isEmpty == false ? written! : spoken)
|
|
112
|
+
if text.range(of: #"https?://"#, options: .regularExpression) != nil { return true }
|
|
113
|
+
// Speech drops formatting, so written is often a little longer for
|
|
114
|
+
// the same content. Only a real difference counts.
|
|
115
|
+
let spokenLen = spoken.trimmingCharacters(in: .whitespacesAndNewlines).count
|
|
116
|
+
let writtenLen = text.trimmingCharacters(in: .whitespacesAndNewlines).count
|
|
117
|
+
if writtenLen > spokenLen + 80 { return true }
|
|
118
|
+
// Long enough that re-reading it beats re-hearing it.
|
|
119
|
+
return writtenLen > 280
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// `spoken` is what was said; `written` is the fuller answer with the
|
|
123
|
+
/// URLs and formatting the spoken form had to drop. `onHeight` follows
|
|
124
|
+
/// once it has rendered.
|
|
125
|
+
@MainActor
|
|
126
|
+
func show(spoken: String, written: String?, buttons: [(String, String)], imageURL: String?) {
|
|
127
|
+
source = (written?.isEmpty == false ? written! : spoken)
|
|
128
|
+
web.loadHTMLString(Markdown.page(Markdown.toHTML(source), css: Brand.cardCSS), baseURL: nil)
|
|
129
|
+
|
|
130
|
+
links.arrangedSubviews.forEach { links.removeArrangedSubview($0); $0.removeFromSuperview() }
|
|
131
|
+
for (label, url) in buttons.prefix(4) {
|
|
132
|
+
let b = NSButton(title: label, target: self, action: #selector(openLink(_:)))
|
|
133
|
+
b.bezelStyle = .push
|
|
134
|
+
b.controlSize = .small
|
|
135
|
+
b.toolTip = url
|
|
136
|
+
b.identifier = NSUserInterfaceItemIdentifier(url)
|
|
137
|
+
links.addArrangedSubview(b)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
thumb.isHidden = true
|
|
141
|
+
thumb.image = nil
|
|
142
|
+
if let s = imageURL, let u = URL(string: s) {
|
|
143
|
+
// Off the main thread: a slow image must never stall the UI,
|
|
144
|
+
// and the answer is useful without it.
|
|
145
|
+
URLSession.shared.dataTask(with: u) { [weak self] data, _, _ in
|
|
146
|
+
guard let data, let img = NSImage(data: data) else { return }
|
|
147
|
+
Task { @MainActor in
|
|
148
|
+
guard let self, self.source.isEmpty == false else { return }
|
|
149
|
+
self.thumb.image = img
|
|
150
|
+
self.thumb.isHidden = false
|
|
151
|
+
self.measure()
|
|
152
|
+
}
|
|
153
|
+
}.resume()
|
|
154
|
+
}
|
|
155
|
+
resizeSubviews(withOldSize: bounds.size)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// Empty again, once the pill has collapsed.
|
|
159
|
+
@MainActor
|
|
160
|
+
func clear() {
|
|
161
|
+
source = ""
|
|
162
|
+
web.loadHTMLString("", baseURL: nil)
|
|
163
|
+
thumb.isHidden = true
|
|
164
|
+
links.arrangedSubviews.forEach { links.removeArrangedSubview($0); $0.removeFromSuperview() }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// The rendered text's height plus the image and links around it.
|
|
168
|
+
@MainActor
|
|
169
|
+
private func measure() {
|
|
170
|
+
let js = "(() => { const b = document.body, s = getComputedStyle(b);"
|
|
171
|
+
+ " return b.scrollHeight + parseFloat(s.marginTop) + parseFloat(s.marginBottom) })()"
|
|
172
|
+
web.evaluateJavaScript(js) { [weak self] value, _ in
|
|
173
|
+
MainActor.assumeIsolated {
|
|
174
|
+
guard let self, !self.source.isEmpty else { return }
|
|
175
|
+
let text = CGFloat((value as? NSNumber)?.doubleValue ?? 120)
|
|
176
|
+
let extra = (self.links.arrangedSubviews.isEmpty ? 0 : Self.linkRow)
|
|
177
|
+
+ (self.thumb.isHidden ? 0 : Self.imageHeight + 16)
|
|
178
|
+
self.resizeSubviews(withOldSize: self.bounds.size)
|
|
179
|
+
self.onHeight?(ceil(text + extra))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
override func mouseEntered(with event: NSEvent) {
|
|
185
|
+
effectiveAppearance.performAsCurrentDrawingAppearance {
|
|
186
|
+
actions.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
|
187
|
+
actions.layer?.borderColor = NSColor.separatorColor.cgColor
|
|
188
|
+
actions.layer?.borderWidth = 1 / (window?.backingScaleFactor ?? 2)
|
|
189
|
+
}
|
|
190
|
+
actions.isHidden = source.isEmpty
|
|
191
|
+
}
|
|
192
|
+
override func mouseExited(with event: NSEvent) { actions.isHidden = true }
|
|
193
|
+
|
|
194
|
+
@objc private func copyAnswer() {
|
|
195
|
+
NSPasteboard.general.clearContents()
|
|
196
|
+
NSPasteboard.general.setString(source, forType: .string)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
@objc private func openChat() { onOpenChat?() }
|
|
200
|
+
|
|
201
|
+
/// Select-and-⌘C: the pill has no Edit menu to route the shortcut.
|
|
202
|
+
func copySelection() { NSApp.sendAction(#selector(NSText.copy(_:)), to: web, from: nil) }
|
|
203
|
+
|
|
204
|
+
@objc private func openLink(_ sender: NSButton) {
|
|
205
|
+
guard let s = sender.identifier?.rawValue, let u = URL(string: s) else { return }
|
|
206
|
+
NSWorkspace.shared.open(u)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
extension AnswerView: WKNavigationDelegate {
|
|
211
|
+
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
212
|
+
MainActor.assumeIsolated { if !source.isEmpty { measure() } }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/// Links open in the real browser. A pill is not a place to read a web
|
|
216
|
+
/// page, and navigating away would replace the answer it exists to hold.
|
|
217
|
+
func webView(_ webView: WKWebView,
|
|
218
|
+
decidePolicyFor navigationAction: WKNavigationAction,
|
|
219
|
+
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
220
|
+
if navigationAction.navigationType == .linkActivated,
|
|
221
|
+
let url = navigationAction.request.url {
|
|
222
|
+
NSWorkspace.shared.open(url)
|
|
223
|
+
decisionHandler(.cancel)
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
decisionHandler(.allow)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -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
|
+
}
|