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
|
@@ -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,90 +1,61 @@
|
|
|
1
1
|
import AppKit
|
|
2
2
|
import SwiftUI
|
|
3
3
|
|
|
4
|
-
/// The Siri-style
|
|
5
|
-
/// thinks and speaks
|
|
4
|
+
/// The Siri-style orb at the head of the pill: the agent's colour, and it
|
|
5
|
+
/// listens, thinks and speaks.
|
|
6
6
|
///
|
|
7
|
-
/// Built from SwiftUI's own gradients, no animation library.
|
|
8
|
-
///
|
|
9
|
-
/// microphone level is no longer read, so an
|
|
10
|
-
/// With Reduce Motion on
|
|
11
|
-
///
|
|
7
|
+
/// Built from SwiftUI's own gradients, no animation library. It only moves
|
|
8
|
+
/// while it has something to show and is on screen: idle or hidden, its
|
|
9
|
+
/// timeline is paused and the microphone level is no longer read, so an
|
|
10
|
+
/// idle assistant costs nothing. With Reduce Motion on, or "Animated orb"
|
|
11
|
+
/// off in the menu, it is a still picture that changes between states,
|
|
12
|
+
/// never a loop.
|
|
12
13
|
@MainActor
|
|
13
14
|
final class OrbModel: ObservableObject {
|
|
14
|
-
enum Phase: Equatable { case
|
|
15
|
+
enum Phase: Equatable { case idle, listening, thinking, speaking }
|
|
15
16
|
|
|
16
|
-
@Published var phase: Phase = .
|
|
17
|
+
@Published var phase: Phase = .idle
|
|
17
18
|
/// 0…1: the microphone while listening.
|
|
18
19
|
@Published var level: Double = 0
|
|
19
20
|
@Published var tint = NSColor(srgbRed: 0.078, green: 0.722, blue: 0.651, alpha: 1)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@Published var line = ""
|
|
23
|
-
/// The step the agent is on, and for how long.
|
|
24
|
-
@Published var status = ""
|
|
21
|
+
/// The pill is on screen. Off, nothing moves.
|
|
22
|
+
@Published var onScreen = false
|
|
25
23
|
@Published var reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
|
24
|
+
/// "Animated orb" in the menu. Off: a still orb, as with Reduce Motion.
|
|
25
|
+
@Published var animated = true
|
|
26
|
+
|
|
27
|
+
var still: Bool { reduceMotion || !animated }
|
|
28
|
+
/// The timeline runs only while there is motion to draw.
|
|
29
|
+
var paused: Bool { still || phase == .idle || !onScreen }
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
|
|
32
|
+
/// The orb as the pill draws it: `OrbBody` at its design size, scaled down
|
|
33
|
+
/// to `diameter`, in a frame with room around it for the glow.
|
|
34
|
+
struct PillOrbView: View {
|
|
29
35
|
@ObservedObject var model: OrbModel
|
|
30
|
-
|
|
36
|
+
let diameter: CGFloat
|
|
37
|
+
let frameSize: CGFloat
|
|
31
38
|
|
|
32
|
-
/// The
|
|
33
|
-
private
|
|
34
|
-
guard scheme == .dark, let c = model.tint.usingColorSpace(.sRGB) else { return Color(nsColor: model.tint) }
|
|
35
|
-
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
|
36
|
-
c.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
|
|
37
|
-
return Color(nsColor: NSColor(hue: h, saturation: s * 0.55, brightness: min(1, b + 0.35), alpha: 1))
|
|
38
|
-
}
|
|
39
|
+
/// The size OrbBody's blur, ring and gradients were drawn for.
|
|
40
|
+
private static let designSize: CGFloat = 96
|
|
39
41
|
|
|
40
42
|
var body: some View {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if !model.name.isEmpty {
|
|
44
|
-
Text(model.name.uppercased())
|
|
45
|
-
.font(.system(size: 10.5, weight: .semibold, design: .monospaced))
|
|
46
|
-
.tracking(0.8)
|
|
47
|
-
.foregroundStyle(nameColor)
|
|
48
|
-
}
|
|
49
|
-
if !model.line.isEmpty {
|
|
50
|
-
Text(model.line)
|
|
51
|
-
.font(.system(size: 13, weight: .medium))
|
|
52
|
-
.foregroundStyle(.primary)
|
|
53
|
-
.multilineTextAlignment(.center)
|
|
54
|
-
.lineLimit(3)
|
|
55
|
-
.truncationMode(.head)
|
|
56
|
-
.fixedSize(horizontal: false, vertical: true)
|
|
57
|
-
}
|
|
58
|
-
if !model.status.isEmpty {
|
|
59
|
-
Text(model.status)
|
|
60
|
-
.font(.system(size: 11))
|
|
61
|
-
.foregroundStyle(.secondary)
|
|
62
|
-
.lineLimit(1)
|
|
63
|
-
.truncationMode(.middle)
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
.padding(.horizontal, 18)
|
|
67
|
-
.padding(.vertical, 14)
|
|
68
|
-
.frame(width: OrbOverlay.width)
|
|
69
|
-
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
70
|
-
.overlay(RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
71
|
-
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1))
|
|
72
|
-
.frame(maxHeight: .infinity, alignment: .top)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
private var orb: some View {
|
|
76
|
-
let still = model.reduceMotion
|
|
77
|
-
let paused = still || model.phase == .hidden
|
|
78
|
-
return TimelineView(.animation(minimumInterval: 1.0 / 30, paused: paused)) { context in
|
|
43
|
+
let paused = model.paused
|
|
44
|
+
TimelineView(.animation(minimumInterval: 1.0 / 30, paused: paused)) { context in
|
|
79
45
|
OrbBody(t: paused ? 0 : context.date.timeIntervalSinceReferenceDate,
|
|
80
|
-
phase: model.phase, level: model.level, tint: model.tint, still: still
|
|
46
|
+
phase: model.phase, level: model.level, tint: model.tint, still: model.still,
|
|
47
|
+
ringWidth: 5)
|
|
48
|
+
.frame(width: Self.designSize, height: Self.designSize)
|
|
49
|
+
.scaleEffect(diameter / Self.designSize)
|
|
81
50
|
}
|
|
51
|
+
.frame(width: frameSize, height: frameSize)
|
|
52
|
+
.accessibilityElement()
|
|
82
53
|
.accessibilityLabel(Text(accessibilityText))
|
|
83
54
|
}
|
|
84
55
|
|
|
85
56
|
private var accessibilityText: String {
|
|
86
57
|
switch model.phase {
|
|
87
|
-
case .
|
|
58
|
+
case .idle: return "Idle"
|
|
88
59
|
case .listening: return "Listening"
|
|
89
60
|
case .thinking: return "Thinking"
|
|
90
61
|
case .speaking: return "Speaking"
|
|
@@ -92,6 +63,97 @@ struct OrbView: View {
|
|
|
92
63
|
}
|
|
93
64
|
}
|
|
94
65
|
|
|
66
|
+
/// The orb's view in the pill. Clicks go through it to the pill, so
|
|
67
|
+
/// pressing on the orb drags the pill like anywhere else on it.
|
|
68
|
+
@MainActor
|
|
69
|
+
final class PillOrb: NSHostingView<PillOrbView> {
|
|
70
|
+
private let driver: OrbDriver
|
|
71
|
+
var model: OrbModel { driver.model }
|
|
72
|
+
|
|
73
|
+
/// The microphone's RMS level, read while listening. Set by the app.
|
|
74
|
+
var levelSource: (() -> Float)? {
|
|
75
|
+
get { driver.levelSource }
|
|
76
|
+
set { driver.levelSource = newValue; driver.watchLevel() }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
init(diameter: CGFloat, frameSize: CGFloat) {
|
|
80
|
+
driver = OrbDriver()
|
|
81
|
+
super.init(rootView: PillOrbView(model: driver.model, diameter: diameter, frameSize: frameSize))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@available(*, unavailable)
|
|
85
|
+
required init(rootView: PillOrbView) { fatalError("use init(diameter:frameSize:)") }
|
|
86
|
+
|
|
87
|
+
@available(*, unavailable)
|
|
88
|
+
required init?(coder: NSCoder) { fatalError("not from a nib") }
|
|
89
|
+
|
|
90
|
+
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
|
91
|
+
|
|
92
|
+
/// Show `phase` in `tint`. Cheap to call on every render: SwiftUI only
|
|
93
|
+
/// redraws what changed.
|
|
94
|
+
func show(_ phase: OrbModel.Phase, tint: NSColor) {
|
|
95
|
+
let model = driver.model
|
|
96
|
+
if model.tint != tint { model.tint = tint }
|
|
97
|
+
if model.phase != phase {
|
|
98
|
+
model.phase = phase
|
|
99
|
+
if phase != .listening { model.level = 0 }
|
|
100
|
+
}
|
|
101
|
+
driver.watchLevel()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// The pill came on screen or went off it.
|
|
105
|
+
func setOnScreen(_ on: Bool) {
|
|
106
|
+
let model = driver.model
|
|
107
|
+
guard model.onScreen != on else { return }
|
|
108
|
+
model.onScreen = on
|
|
109
|
+
if !on { model.level = 0 }
|
|
110
|
+
driver.watchLevel()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// "Animated orb" in the menu.
|
|
114
|
+
func setAnimated(_ on: Bool) {
|
|
115
|
+
guard driver.model.animated != on else { return }
|
|
116
|
+
driver.model.animated = on
|
|
117
|
+
driver.watchLevel()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Reads the microphone into the model while listening, and follows the
|
|
122
|
+
/// Reduce Motion setting.
|
|
123
|
+
@MainActor
|
|
124
|
+
private final class OrbDriver {
|
|
125
|
+
let model = OrbModel()
|
|
126
|
+
var levelSource: (() -> Float)?
|
|
127
|
+
private var levelTimer: Timer?
|
|
128
|
+
private var motionObserver: NSObjectProtocol?
|
|
129
|
+
|
|
130
|
+
init() {
|
|
131
|
+
motionObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
|
132
|
+
forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification,
|
|
133
|
+
object: nil, queue: .main) { [weak self] _ in
|
|
134
|
+
MainActor.assumeIsolated {
|
|
135
|
+
guard let self else { return }
|
|
136
|
+
self.model.reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
|
137
|
+
self.watchLevel()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// The microphone is read only while listening, on screen, with
|
|
143
|
+
/// motion allowed.
|
|
144
|
+
func watchLevel() {
|
|
145
|
+
let wanted = model.phase == .listening && model.onScreen && !model.still && levelSource != nil
|
|
146
|
+
if !wanted { levelTimer?.invalidate(); levelTimer = nil; return }
|
|
147
|
+
guard levelTimer == nil else { return }
|
|
148
|
+
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 20, repeats: true) { [weak self] _ in
|
|
149
|
+
MainActor.assumeIsolated {
|
|
150
|
+
guard let self, let read = self.levelSource else { return }
|
|
151
|
+
self.model.level = OrbMath.smooth(self.model.level, toward: OrbMath.level(fromRMS: read()))
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
95
157
|
/// One frame of the orb at time `t`.
|
|
96
158
|
struct OrbBody: View {
|
|
97
159
|
let t: Double
|
|
@@ -100,11 +162,14 @@ struct OrbBody: View {
|
|
|
100
162
|
let tint: NSColor
|
|
101
163
|
/// Reduce Motion: a still orb, sized and shaded by state alone.
|
|
102
164
|
let still: Bool
|
|
165
|
+
/// The thinking ring's stroke at the 96-point design size. The pill
|
|
166
|
+
/// draws the orb small, so it passes a thicker one that still reads.
|
|
167
|
+
var ringWidth: CGFloat = 2.2
|
|
103
168
|
|
|
104
169
|
/// How much the orb swells and glows, 0…1.
|
|
105
170
|
private var energy: Double {
|
|
106
171
|
switch phase {
|
|
107
|
-
case .
|
|
172
|
+
case .idle: return 0
|
|
108
173
|
case .listening: return still ? 0.45 : 0.12 + 0.88 * level
|
|
109
174
|
case .thinking: return still ? 0.2 : 0.22 + 0.08 * sin(t * 2.2)
|
|
110
175
|
case .speaking: return still ? 0.7 : OrbMath.speakingEnvelope(at: t)
|
|
@@ -114,7 +179,7 @@ struct OrbBody: View {
|
|
|
114
179
|
/// How fast the colours drift.
|
|
115
180
|
private var speed: Double {
|
|
116
181
|
switch phase {
|
|
117
|
-
case .
|
|
182
|
+
case .idle: return 0
|
|
118
183
|
case .listening: return 0.5 + level * 1.5
|
|
119
184
|
case .thinking: return 1.3
|
|
120
185
|
case .speaking: return 0.9
|
|
@@ -136,7 +201,7 @@ struct OrbBody: View {
|
|
|
136
201
|
if phase == .thinking {
|
|
137
202
|
Circle()
|
|
138
203
|
.trim(from: 0, to: 0.28)
|
|
139
|
-
.stroke(Color.white.opacity(0.75), style: StrokeStyle(lineWidth:
|
|
204
|
+
.stroke(Color.white.opacity(0.75), style: StrokeStyle(lineWidth: ringWidth, lineCap: .round))
|
|
140
205
|
.rotationEffect(.radians(still ? -.pi / 2 : t * 3.1))
|
|
141
206
|
.scaleEffect(scale * 0.86)
|
|
142
207
|
} else {
|
|
@@ -206,106 +271,3 @@ struct OrbBody: View {
|
|
|
206
271
|
]
|
|
207
272
|
}
|
|
208
273
|
}
|
|
209
|
-
|
|
210
|
-
/// The window the orb lives in: floating, on every Space, click-through
|
|
211
|
-
/// and never key, so the app in front keeps the keyboard.
|
|
212
|
-
private final class OrbPanel: NSPanel {
|
|
213
|
-
override var canBecomeKey: Bool { false }
|
|
214
|
-
override var canBecomeMain: Bool { false }
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
@MainActor
|
|
218
|
-
final class OrbOverlay {
|
|
219
|
-
static let width: CGFloat = 300
|
|
220
|
-
private static let height: CGFloat = 250
|
|
221
|
-
|
|
222
|
-
let model = OrbModel()
|
|
223
|
-
private let panel: NSPanel
|
|
224
|
-
private let host: NSHostingView<OrbView>
|
|
225
|
-
private var levelTimer: Timer?
|
|
226
|
-
private var motionObserver: NSObjectProtocol?
|
|
227
|
-
|
|
228
|
-
/// The microphone's RMS level, read while listening. Set by the app.
|
|
229
|
-
var levelSource: (() -> Float)?
|
|
230
|
-
|
|
231
|
-
init() {
|
|
232
|
-
panel = OrbPanel(contentRect: NSRect(x: 0, y: 0, width: Self.width, height: Self.height),
|
|
233
|
-
styleMask: [.borderless, .nonactivatingPanel],
|
|
234
|
-
backing: .buffered, defer: true)
|
|
235
|
-
panel.isFloatingPanel = true
|
|
236
|
-
panel.level = .floating
|
|
237
|
-
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
|
|
238
|
-
panel.isOpaque = false
|
|
239
|
-
panel.backgroundColor = .clear
|
|
240
|
-
panel.hasShadow = false
|
|
241
|
-
panel.ignoresMouseEvents = true
|
|
242
|
-
panel.hidesOnDeactivate = false
|
|
243
|
-
host = NSHostingView(rootView: OrbView(model: model))
|
|
244
|
-
host.frame = NSRect(x: 0, y: 0, width: Self.width, height: Self.height)
|
|
245
|
-
panel.contentView = host
|
|
246
|
-
|
|
247
|
-
let model = self.model
|
|
248
|
-
motionObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
|
249
|
-
forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification,
|
|
250
|
-
object: nil, queue: .main) { _ in
|
|
251
|
-
MainActor.assumeIsolated {
|
|
252
|
-
model.reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
var isVisible: Bool { panel.isVisible }
|
|
258
|
-
|
|
259
|
-
/// Where the orb's card ends on screen, for the answer card to sit
|
|
260
|
-
/// under. Nil while hidden.
|
|
261
|
-
var contentFrame: NSRect? {
|
|
262
|
-
guard panel.isVisible else { return nil }
|
|
263
|
-
let h = min(Self.height, host.fittingSize.height)
|
|
264
|
-
return NSRect(x: panel.frame.minX, y: panel.frame.maxY - h, width: Self.width, height: h)
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
func show(_ phase: OrbModel.Phase, name: String, tint: NSColor, line: String, status: String) {
|
|
268
|
-
guard phase != .hidden else { hide(); return }
|
|
269
|
-
model.name = name
|
|
270
|
-
model.tint = tint
|
|
271
|
-
model.line = line
|
|
272
|
-
model.status = status
|
|
273
|
-
if model.phase != phase {
|
|
274
|
-
model.phase = phase
|
|
275
|
-
if phase != .listening { model.level = 0 }
|
|
276
|
-
}
|
|
277
|
-
watchLevel()
|
|
278
|
-
if !panel.isVisible {
|
|
279
|
-
position()
|
|
280
|
-
panel.orderFrontRegardless()
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
func hide() {
|
|
285
|
-
model.phase = .hidden
|
|
286
|
-
model.level = 0
|
|
287
|
-
watchLevel()
|
|
288
|
-
panel.orderOut(nil)
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/// Top right, under the menu bar, where macOS puts Siri.
|
|
292
|
-
private func position() {
|
|
293
|
-
guard let screen = NSScreen.main else { return }
|
|
294
|
-
let v = screen.visibleFrame
|
|
295
|
-
panel.setFrameOrigin(NSPoint(x: v.maxX - Self.width - 16, y: v.maxY - Self.height - 10))
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/// The microphone is read only while listening, on screen, with
|
|
299
|
-
/// motion allowed.
|
|
300
|
-
private func watchLevel() {
|
|
301
|
-
let wanted = model.phase == .listening && !model.reduceMotion && levelSource != nil
|
|
302
|
-
if !wanted { levelTimer?.invalidate(); levelTimer = nil; return }
|
|
303
|
-
guard levelTimer == nil else { return }
|
|
304
|
-
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 20, repeats: true) { [weak self] _ in
|
|
305
|
-
MainActor.assumeIsolated {
|
|
306
|
-
guard let self, let read = self.levelSource else { return }
|
|
307
|
-
self.model.level = OrbMath.smooth(self.model.level, toward: OrbMath.level(fromRMS: read()))
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
}
|