@amd-gaia/agent-ui 0.25.0 → 0.25.1-rc.10
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/assets/tray-icon.png +0 -0
- package/assets/tray-icon@2x.png +0 -0
- package/dist/assets/AdvancedSettings-CtYKNNWc.js +1 -0
- package/dist/assets/AdvancedSettings-D5VKpkPY.css +1 -0
- package/dist/assets/ConnectorsPane-Ctfp9d5s.js +1 -0
- package/dist/assets/ConnectorsPane-Dzu2DRTc.css +1 -0
- package/dist/assets/DocumentLibrary-CIKp6qld.css +1 -0
- package/dist/assets/DocumentLibrary-Fp70O1-8.js +4 -0
- package/dist/assets/FileBrowser-3H3DoJIm.js +4 -0
- package/dist/assets/FileBrowser-D04I8Pvv.css +1 -0
- package/dist/assets/MemoryDashboard-D5HzmtDi.js +1 -0
- package/dist/assets/MemoryDashboard-Mo721-BI.css +1 -0
- package/dist/assets/MobileAccessModal-B9peVPY4.js +2 -0
- package/dist/assets/MobileAccessModal-DuVy9F1u.css +1 -0
- package/dist/assets/ScheduleManager-DotJl5JW.css +1 -0
- package/dist/assets/ScheduleManager-HcGB9P6m.js +2 -0
- package/dist/assets/{jsx-runtime-DVeaULmT.js → apiBase-y00JgRBP.js} +1 -1
- package/dist/assets/eye-B6FejHNG.js +1 -0
- package/dist/assets/fraunces-latin-BwwriW9Y.woff2 +0 -0
- package/dist/assets/index-D2UMLtwY.js +74 -0
- package/dist/assets/index-DF5qku9L.css +1 -0
- package/dist/assets/inter-latin-C2S99t-D.woff2 +0 -0
- package/dist/assets/inter-latin-ext-CFHvXkgd.woff2 +0 -0
- package/dist/assets/message-square-Dwm7y3C0.js +1 -0
- package/dist/fonts-OFL.txt +101 -0
- package/dist/index.html +6 -6
- package/main-safety-net.cjs +54 -7
- package/main.cjs +276 -120
- package/package.json +3 -1
- package/preload.cjs +24 -0
- package/services/agent-process-manager.cjs +116 -13
- package/services/app-icon.cjs +29 -0
- package/services/auto-updater.cjs +26 -2
- package/services/backend-crash-dialog.cjs +102 -0
- package/services/backend-installer-progress-dialog.cjs +71 -43
- package/services/backend-installer.cjs +233 -25
- package/services/chromium-switches.cjs +29 -0
- package/services/keep-awake.cjs +52 -0
- package/services/notification-service.cjs +62 -0
- package/services/port-manager.cjs +19 -70
- package/services/process-tree.cjs +328 -0
- package/services/quit-cleanup.cjs +86 -0
- package/services/quit-guard.cjs +242 -0
- package/services/tray-icons.cjs +174 -0
- package/services/tray-manager.cjs +582 -83
- package/services/tray-state.cjs +231 -0
- package/services/window-keys.cjs +43 -0
- package/assets/tray-icon.ico +0 -0
- package/dist/assets/AdvancedSettings-5cX0pGZc.js +0 -1
- package/dist/assets/AdvancedSettings-Ce6EvcAe.css +0 -1
- package/dist/assets/ConnectorsPane-CG7atLb2.css +0 -1
- package/dist/assets/ConnectorsPane-CuWxzcBY.js +0 -1
- package/dist/assets/DocumentLibrary-BdAKDe8H.js +0 -1
- package/dist/assets/DocumentLibrary-DrIP_032.css +0 -1
- package/dist/assets/FileBrowser-B8s6hKC5.js +0 -4
- package/dist/assets/FileBrowser-Bj6aB6Ro.css +0 -1
- package/dist/assets/MemoryDashboard-DQxtAW90.css +0 -1
- package/dist/assets/MemoryDashboard-DtolA-HZ.js +0 -1
- package/dist/assets/MobileAccessModal-C3nWkpWA.js +0 -2
- package/dist/assets/MobileAccessModal-C_t8DG0n.css +0 -1
- package/dist/assets/ScheduleManager-CIescbX6.css +0 -1
- package/dist/assets/ScheduleManager-HHdD6sko.js +0 -2
- package/dist/assets/dm-sans-latin-Xz1IZZA0.woff2 +0 -0
- package/dist/assets/index-CFVpPQal.css +0 -1
- package/dist/assets/index-DEPbenxC.js +0 -60
- package/dist/assets/message-square-Ccmbk96j.js +0 -1
- package/dist/assets/space-mono-400-Co7bH5Hm.woff2 +0 -0
- package/dist/assets/useEscapeToClose-B8fw5mn-.js +0 -1
|
@@ -4,26 +4,48 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* GAIA Agent UI — Tray Manager (T1)
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* The system tray icon: what GAIA is doing right now, what needs the user,
|
|
8
|
+
* and the menu to act on it. Co-located alongside main.cjs per the T0
|
|
9
|
+
* co-location decision.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
15
|
-
* -
|
|
11
|
+
* - The icon and tooltip follow the state the window reports over
|
|
12
|
+
* `tray:set-activity` (idle, working, waiting for the model, needs you,
|
|
13
|
+
* error); see tray-state.cjs and tray-icons.cjs.
|
|
14
|
+
* - The menu: status line, Needs you, New chat, recent chats, Free memory,
|
|
15
|
+
* Settings, Quit. Menu actions reach the window over `tray:command`.
|
|
16
|
+
* - Windows gets a taskbar overlay for needs-you and unread.
|
|
17
|
+
* - Tray settings (close to tray, start at login, start minimized) are read
|
|
18
|
+
* and saved over `tray:get-config` / `tray:set-config`.
|
|
16
19
|
*/
|
|
17
20
|
|
|
18
|
-
const { Tray, Menu, nativeImage, ipcMain, app,
|
|
21
|
+
const { Tray, Menu, nativeImage, ipcMain, app, dialog } = require("electron");
|
|
19
22
|
const path = require("path");
|
|
20
23
|
const fs = require("fs");
|
|
21
24
|
const os = require("os");
|
|
25
|
+
const { drawStateIcon, drawOverlayIcon, STATES } = require("./tray-icons.cjs");
|
|
26
|
+
const { validateActivity, deriveTrayView, buildMenuTemplate } = require("./tray-state.cjs");
|
|
27
|
+
const { backendJson, fetchWorkInProgress } = require("./quit-guard.cjs");
|
|
28
|
+
|
|
29
|
+
/** Why the model can't be freed right now, from the backend's work list; null when it can. */
|
|
30
|
+
function describeBusy(work) {
|
|
31
|
+
const answers = work.answers.length;
|
|
32
|
+
const docs = work.indexing.length;
|
|
33
|
+
if (answers === 0 && docs === 0) return null;
|
|
34
|
+
const parts = [];
|
|
35
|
+
if (answers > 0) parts.push(answers === 1 ? "An answer is being written." : `${answers} answers are being written.`);
|
|
36
|
+
if (docs > 0) parts.push(docs === 1 ? "A document is being indexed." : `${docs} documents are being indexed.`);
|
|
37
|
+
return parts.join(" ");
|
|
38
|
+
}
|
|
22
39
|
|
|
23
40
|
// ── Constants ────────────────────────────────────────────────────────────
|
|
24
41
|
|
|
25
42
|
const GAIA_DIR = path.join(os.homedir(), ".gaia");
|
|
26
43
|
const CONFIG_PATH = path.join(GAIA_DIR, "tray-config.json");
|
|
44
|
+
const LINUX_AUTOSTART_FILE = "gaia-agent-ui.desktop";
|
|
45
|
+
// The Run value the Windows installer writes (installer/nsis/installer.nsh,
|
|
46
|
+
// ${PRODUCT_NAME}); the switch owns that same entry, command line included.
|
|
47
|
+
const WIN_LOGIN_ITEM_NAME = "GAIA";
|
|
48
|
+
const WIN_LOGIN_ITEM_ARGS = ["--minimized"];
|
|
27
49
|
|
|
28
50
|
// ── Default config ───────────────────────────────────────────────────────
|
|
29
51
|
|
|
@@ -34,8 +56,52 @@ const DEFAULT_CONFIG = {
|
|
|
34
56
|
startOnLogin: false,
|
|
35
57
|
},
|
|
36
58
|
agents: {},
|
|
59
|
+
notices: {
|
|
60
|
+
firstClose: false,
|
|
61
|
+
},
|
|
62
|
+
// Whether the user has set the sign-in switch; until then the OS decides it.
|
|
63
|
+
choices: {
|
|
64
|
+
startOnLogin: false,
|
|
65
|
+
},
|
|
37
66
|
};
|
|
38
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Tray icon bitmaps per platform: the size the tray draws at (DIP) and each
|
|
70
|
+
* scale's source asset. Windows trays are 16 DIP; Linux StatusNotifier hosts
|
|
71
|
+
* scale a 32px icon down, which stays sharp where upscaling blurs; macOS
|
|
72
|
+
* menu-bar extras are template images the OS tints for light and dark.
|
|
73
|
+
*/
|
|
74
|
+
const ICON_SPECS = {
|
|
75
|
+
win32: {
|
|
76
|
+
template: false,
|
|
77
|
+
reps: [
|
|
78
|
+
{ scale: 1, px: 16, file: "tray-icon.png" },
|
|
79
|
+
{ scale: 1.25, px: 20, file: "tray-icon.png" },
|
|
80
|
+
{ scale: 1.5, px: 24, file: "tray-icon.png" },
|
|
81
|
+
{ scale: 2, px: 32, file: "tray-icon.png" },
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
darwin: {
|
|
85
|
+
template: true,
|
|
86
|
+
reps: [
|
|
87
|
+
{ scale: 1, px: 16, file: "tray-iconTemplate.png" },
|
|
88
|
+
{ scale: 2, px: 32, file: "tray-iconTemplate@2x.png" },
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
linux: {
|
|
92
|
+
template: false,
|
|
93
|
+
reps: [
|
|
94
|
+
{ scale: 1, px: 32, file: "tray-icon.png" },
|
|
95
|
+
{ scale: 2, px: 64, file: "tray-icon@2x.png" },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const OVERLAY_REPS = [
|
|
101
|
+
{ scale: 1, px: 16 },
|
|
102
|
+
{ scale: 2, px: 32 },
|
|
103
|
+
];
|
|
104
|
+
|
|
39
105
|
const AGENT_LOG_LEVELS = ["debug", "info", "warn", "error"];
|
|
40
106
|
|
|
41
107
|
function isPlainObject(value) {
|
|
@@ -74,13 +140,24 @@ function validateAgents(agents, where) {
|
|
|
74
140
|
return out;
|
|
75
141
|
}
|
|
76
142
|
|
|
143
|
+
/**
|
|
144
|
+
* A path as one quoted Exec= argument: the Desktop Entry spec escapes " ` $ \
|
|
145
|
+
* inside the quotes, then escapes every backslash again as a string value.
|
|
146
|
+
*/
|
|
147
|
+
function desktopExecArg(file) {
|
|
148
|
+
// "%" starts a field code (%f, %u…) anywhere in Exec=, so it is doubled.
|
|
149
|
+
const quoted = `"${file.replace(/%/g, "%%").replace(/["`$\\]/g, (c) => `\\${c}`)}"`;
|
|
150
|
+
return quoted.replace(/\\/g, "\\\\");
|
|
151
|
+
}
|
|
152
|
+
|
|
77
153
|
// ── TrayManager ──────────────────────────────────────────────────────────
|
|
78
154
|
|
|
79
155
|
class TrayManager {
|
|
80
156
|
/**
|
|
81
157
|
* @param {Electron.BrowserWindow} mainWindow
|
|
82
158
|
* @param {object} [options]
|
|
83
|
-
* @param {number} [options.backendPort=4200] -
|
|
159
|
+
* @param {number} [options.backendPort=4200] - The GAIA service's port, for Free memory
|
|
160
|
+
* @param {() => Electron.BrowserWindow} options.reopenWindow - Recreate a closed window (macOS keeps the app running without one)
|
|
84
161
|
*/
|
|
85
162
|
constructor(mainWindow, options = {}) {
|
|
86
163
|
/** @type {Electron.BrowserWindow} */
|
|
@@ -89,17 +166,45 @@ class TrayManager {
|
|
|
89
166
|
/** @type {number} */
|
|
90
167
|
this._backendPort = options.backendPort || 4200;
|
|
91
168
|
|
|
169
|
+
if (typeof options.reopenWindow !== "function") {
|
|
170
|
+
throw new TypeError("TrayManager needs options.reopenWindow to bring back a closed window");
|
|
171
|
+
}
|
|
172
|
+
this._reopenWindow = options.reopenWindow;
|
|
173
|
+
|
|
174
|
+
/** Menu commands waiting for a reopened window to start listening. */
|
|
175
|
+
this._pendingCommands = [];
|
|
176
|
+
|
|
92
177
|
/** @type {Electron.Tray | null} */
|
|
93
178
|
this.tray = null;
|
|
94
179
|
|
|
95
180
|
/** @type {number} Unread notifications reflected in the tooltip/badge. */
|
|
96
181
|
this._notificationCount = 0;
|
|
182
|
+
/** Chats waiting on the user or finished unread, per the renderer's alert settings. */
|
|
183
|
+
this._attentionCount = 0;
|
|
184
|
+
|
|
185
|
+
/** Last snapshot from the window; null until it first reports. */
|
|
186
|
+
this._activity = null;
|
|
187
|
+
|
|
188
|
+
/** False once the GAIA service process has exited. */
|
|
189
|
+
this._backendAlive = true;
|
|
190
|
+
|
|
191
|
+
this._freeingMemory = false;
|
|
192
|
+
|
|
193
|
+
/** @type {ReturnType<typeof deriveTrayView> | null} */
|
|
194
|
+
this._view = null;
|
|
195
|
+
this._menuKey = null;
|
|
196
|
+
this._overlayKey = null;
|
|
97
197
|
|
|
98
198
|
/** @type {object} */
|
|
99
199
|
this.config = this._loadConfig();
|
|
100
200
|
|
|
201
|
+
this._iconSpec = ICON_SPECS[process.platform] || ICON_SPECS.linux;
|
|
202
|
+
|
|
203
|
+
/** @type {Map<string, Electron.NativeImage>} One image per tray state, built on first use. */
|
|
204
|
+
this._icons = new Map();
|
|
205
|
+
|
|
101
206
|
/** @type {Electron.NativeImage} */
|
|
102
|
-
this._icon = this.
|
|
207
|
+
this._icon = this._iconFor("idle");
|
|
103
208
|
|
|
104
209
|
this._registerIpcHandlers();
|
|
105
210
|
}
|
|
@@ -111,16 +216,13 @@ class TrayManager {
|
|
|
111
216
|
if (this.tray) return;
|
|
112
217
|
|
|
113
218
|
this.tray = new Tray(this._icon);
|
|
114
|
-
// Re-apply any count that arrived before the tray existed.
|
|
115
|
-
this.setNotificationCount(this._notificationCount);
|
|
116
219
|
|
|
117
|
-
//
|
|
220
|
+
// Windows: a click shows the window. macOS and most Linux trays open the
|
|
221
|
+
// menu on click instead, where "Show GAIA" does the same.
|
|
118
222
|
this.tray.on("click", () => this._showWindow());
|
|
119
|
-
|
|
120
|
-
// Double-click (Windows): show/focus window
|
|
121
223
|
this.tray.on("double-click", () => this._showWindow());
|
|
122
224
|
|
|
123
|
-
this.
|
|
225
|
+
this._render();
|
|
124
226
|
console.log("[tray] System tray icon created");
|
|
125
227
|
}
|
|
126
228
|
|
|
@@ -133,14 +235,16 @@ class TrayManager {
|
|
|
133
235
|
console.log("[tray] System tray icon destroyed");
|
|
134
236
|
}
|
|
135
237
|
|
|
136
|
-
/**
|
|
238
|
+
/** Rebuild the icon, tooltip and menu from the current state. */
|
|
137
239
|
refresh() {
|
|
138
|
-
this.
|
|
240
|
+
this._menuKey = null;
|
|
241
|
+
this._overlayKey = null;
|
|
242
|
+
this._render();
|
|
139
243
|
}
|
|
140
244
|
|
|
141
245
|
/**
|
|
142
|
-
* Reflect the unread-notification count in the tray tooltip
|
|
143
|
-
* launcher badge
|
|
246
|
+
* Reflect the unread-notification count in the tray tooltip, the dock /
|
|
247
|
+
* launcher badge, and the Windows taskbar overlay.
|
|
144
248
|
*
|
|
145
249
|
* @param {number} count Unread notifications; negative/NaN is treated as 0.
|
|
146
250
|
*/
|
|
@@ -153,23 +257,72 @@ class TrayManager {
|
|
|
153
257
|
console.log(`[tray] Unread notifications: ${unread}`);
|
|
154
258
|
}
|
|
155
259
|
this._notificationCount = unread;
|
|
260
|
+
this._renderBadge();
|
|
261
|
+
}
|
|
156
262
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
);
|
|
263
|
+
/**
|
|
264
|
+
* Reflect how many chats need the user (#5226): waiting on a permission or
|
|
265
|
+
* a question, or finished and unread. Shares the badge with notifications.
|
|
266
|
+
*
|
|
267
|
+
* @param {number} count Chats needing attention; must be a non-negative integer.
|
|
268
|
+
*/
|
|
269
|
+
setAttentionCount(count) {
|
|
270
|
+
if (!Number.isInteger(count) || count < 0) {
|
|
271
|
+
throw new TypeError(`setAttentionCount expects a non-negative integer, got ${count}`);
|
|
166
272
|
}
|
|
273
|
+
this._attentionCount = count;
|
|
274
|
+
this._renderBadge();
|
|
275
|
+
}
|
|
167
276
|
|
|
277
|
+
_renderBadge() {
|
|
278
|
+
const unread = this._notificationCount;
|
|
279
|
+
const chats = this._attentionCount;
|
|
168
280
|
// Dock (macOS) / Unity launcher (Linux) badge. Windows has no
|
|
169
|
-
// app.setBadgeCount equivalent
|
|
281
|
+
// app.setBadgeCount equivalent; the taskbar overlay stands in for it.
|
|
170
282
|
if (process.platform !== "win32" && typeof app.setBadgeCount === "function") {
|
|
171
|
-
app.setBadgeCount(unread);
|
|
283
|
+
app.setBadgeCount(unread + chats);
|
|
172
284
|
}
|
|
285
|
+
this._render();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Take a snapshot of what GAIA is doing from the window.
|
|
290
|
+
* @throws {TypeError} When the snapshot is malformed; nothing changes then.
|
|
291
|
+
*/
|
|
292
|
+
setActivity(raw) {
|
|
293
|
+
this._activity = validateActivity(raw);
|
|
294
|
+
this._render();
|
|
295
|
+
// The window reports once its tray bridge listens, so held commands can go.
|
|
296
|
+
const win = this.mainWindow;
|
|
297
|
+
if (this._pendingCommands.length && win && !win.isDestroyed()) {
|
|
298
|
+
for (const command of this._pendingCommands.splice(0)) {
|
|
299
|
+
win.webContents.send("tray:command", command);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The window was closed (not hidden): what it last reported no longer holds. */
|
|
305
|
+
windowClosed() {
|
|
306
|
+
this._activity = null;
|
|
307
|
+
// A new window has no overlay yet, whatever the last one showed.
|
|
308
|
+
this._overlayKey = null;
|
|
309
|
+
this._render();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Mark the GAIA service process as running or exited. */
|
|
313
|
+
setBackendAlive(alive) {
|
|
314
|
+
this._backendAlive = Boolean(alive);
|
|
315
|
+
this._render();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** @returns {string} The tray state now showing: one of tray-icons.STATES. */
|
|
319
|
+
get state() {
|
|
320
|
+
return this._view ? this._view.state : "idle";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** @returns {number} The chat count last passed to setAttentionCount. */
|
|
324
|
+
get attentionCount() {
|
|
325
|
+
return this._attentionCount;
|
|
173
326
|
}
|
|
174
327
|
|
|
175
328
|
/** @returns {number} The unread count last passed to setNotificationCount. */
|
|
@@ -192,68 +345,276 @@ class TrayManager {
|
|
|
192
345
|
return this.config.tray.startOnLogin;
|
|
193
346
|
}
|
|
194
347
|
|
|
195
|
-
|
|
348
|
+
/** @returns {boolean} Whether the one-time "still running in the tray" notice is still due. */
|
|
349
|
+
get closeNoticeDue() {
|
|
350
|
+
return !this.config.notices.firstClose;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Tell the user, once, that closing the window left GAIA running.
|
|
355
|
+
* Saved as shown before it opens, so a crash mid-dialog never repeats it.
|
|
356
|
+
* @returns {Promise<boolean>} True when the user chose to quit from the notice.
|
|
357
|
+
*/
|
|
358
|
+
async showCloseNotice() {
|
|
359
|
+
if (!this.closeNoticeDue) return false;
|
|
360
|
+
const next = { ...this.config, notices: { ...this.config.notices, firstClose: true } };
|
|
361
|
+
this._saveConfig(next);
|
|
362
|
+
this.config = next;
|
|
363
|
+
const where = process.platform === "darwin" ? "menu bar" : "tray";
|
|
364
|
+
const { response } = await dialog.showMessageBox({
|
|
365
|
+
type: "info",
|
|
366
|
+
title: "GAIA is still running",
|
|
367
|
+
message: `GAIA is still running in the ${where}.`,
|
|
368
|
+
detail: `Quit from the ${where} menu to stop it. Closing the window keeps your model loaded, so the next chat starts quickly.`,
|
|
369
|
+
buttons: ["OK", "Quit GAIA"],
|
|
370
|
+
defaultId: 0,
|
|
371
|
+
cancelId: 0,
|
|
372
|
+
noLink: true,
|
|
373
|
+
});
|
|
374
|
+
return response === 1;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── Private: Rendering ───────────────────────────────────────────────
|
|
196
378
|
|
|
197
|
-
|
|
198
|
-
|
|
379
|
+
_render() {
|
|
380
|
+
this._view = deriveTrayView(this._activity, {
|
|
381
|
+
backendAlive: this._backendAlive,
|
|
382
|
+
unread: this._notificationCount,
|
|
383
|
+
attention: this._attentionCount,
|
|
384
|
+
});
|
|
385
|
+
const trayAlive =
|
|
386
|
+
this.tray &&
|
|
387
|
+
!(typeof this.tray.isDestroyed === "function" && this.tray.isDestroyed());
|
|
388
|
+
if (trayAlive) {
|
|
389
|
+
const icon = this._iconFor(this._view.state);
|
|
390
|
+
if (icon !== this._icon) {
|
|
391
|
+
this._icon = icon;
|
|
392
|
+
this.tray.setImage(icon);
|
|
393
|
+
console.log(`[tray] State: ${this._view.state}`);
|
|
394
|
+
}
|
|
395
|
+
this.tray.setToolTip(this._view.tooltip);
|
|
396
|
+
// Linux trays never show the tooltip and only redraw the menu on
|
|
397
|
+
// setContextMenu, so the menu is rebuilt whenever its text changes.
|
|
398
|
+
const menuKey = JSON.stringify([
|
|
399
|
+
this._view.statusLine,
|
|
400
|
+
this._view.needsYou,
|
|
401
|
+
this._view.recent,
|
|
402
|
+
this._view.canFreeMemory,
|
|
403
|
+
this._view.freeMemoryHint,
|
|
404
|
+
]);
|
|
405
|
+
if (menuKey !== this._menuKey) {
|
|
406
|
+
this._menuKey = menuKey;
|
|
407
|
+
this.tray.setContextMenu(
|
|
408
|
+
Menu.buildFromTemplate(buildMenuTemplate(this._view, this._menuActions()))
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
this._renderOverlay();
|
|
413
|
+
}
|
|
199
414
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
415
|
+
/** Windows has no badge, so the taskbar button carries needs-you and unread. */
|
|
416
|
+
_renderOverlay() {
|
|
417
|
+
if (process.platform !== "win32") return;
|
|
418
|
+
const win = this.mainWindow;
|
|
419
|
+
if (!win || win.isDestroyed() || typeof win.setOverlayIcon !== "function") return;
|
|
420
|
+
const needs = this._view.state === "needs-you" ? this._view.needsYou.length : 0;
|
|
421
|
+
const unread = this._notificationCount;
|
|
422
|
+
let key = "none";
|
|
423
|
+
if (needs > 0) key = `needs-you:${needs}`;
|
|
424
|
+
else if (unread > 0) key = `unread:${unread}`;
|
|
425
|
+
if (key === this._overlayKey) return;
|
|
426
|
+
this._overlayKey = key;
|
|
427
|
+
if (needs > 0) {
|
|
428
|
+
win.setOverlayIcon(
|
|
429
|
+
this._overlayImage("needs-you"),
|
|
430
|
+
`${needs} ${needs === 1 ? "chat needs" : "chats need"} you`
|
|
431
|
+
);
|
|
432
|
+
} else if (unread > 0) {
|
|
433
|
+
win.setOverlayIcon(
|
|
434
|
+
this._overlayImage("unread"),
|
|
435
|
+
`${unread} unread notification${unread === 1 ? "" : "s"}`
|
|
436
|
+
);
|
|
437
|
+
} else {
|
|
438
|
+
win.setOverlayIcon(null, "");
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
_menuActions() {
|
|
443
|
+
return {
|
|
444
|
+
openChat: (sessionId) => this._command({ type: "open-chat", sessionId }),
|
|
445
|
+
newChat: () => this._command({ type: "new-chat" }),
|
|
446
|
+
showWindow: () => this._showWindow(),
|
|
447
|
+
freeMemory: () => {
|
|
448
|
+
// A menu click has no caller to report to, so the user hears about it.
|
|
449
|
+
this._freeMemory().catch((err) => {
|
|
450
|
+
console.error("[tray] Free memory failed:", err);
|
|
451
|
+
dialog.showErrorBox("GAIA", `Free memory failed: ${err.message}`);
|
|
452
|
+
});
|
|
213
453
|
},
|
|
214
|
-
|
|
215
|
-
|
|
454
|
+
openSettings: () => this._command({ type: "open-settings", section: "general" }),
|
|
455
|
+
quit: () => this._quit(),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Show the window and hand it a menu action. */
|
|
460
|
+
_command(command) {
|
|
461
|
+
const reopened = this._showWindow();
|
|
462
|
+
if (reopened) {
|
|
463
|
+
this._pendingCommands.push(command);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
this.mainWindow.webContents.send("tray:command", command);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── Private: Free memory ─────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
async _freeMemory() {
|
|
472
|
+
if (this._freeingMemory || !this._view || !this._view.canFreeMemory) return;
|
|
473
|
+
// Set before the first dialog, so a second click can't open a second one.
|
|
474
|
+
this._freeingMemory = true;
|
|
475
|
+
try {
|
|
476
|
+
await this._freeMemoryFlow(this._view.modelName);
|
|
477
|
+
} finally {
|
|
478
|
+
this._freeingMemory = false;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async _freeMemoryFlow(model) {
|
|
483
|
+
const failed = (message, detail) =>
|
|
484
|
+
dialog.showMessageBox({
|
|
485
|
+
type: "error",
|
|
486
|
+
title: "Free memory",
|
|
487
|
+
message,
|
|
488
|
+
detail,
|
|
489
|
+
buttons: ["OK"],
|
|
490
|
+
noLink: true,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// The window only knows its own chats; the backend knows every answer
|
|
494
|
+
// and index job, so ask it now rather than poll it all the time.
|
|
495
|
+
let work;
|
|
496
|
+
try {
|
|
497
|
+
work = await fetchWorkInProgress(this._backendPort);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
await failed("GAIA couldn't check whether the model is in use.", err.message);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const busy = describeBusy(work);
|
|
503
|
+
if (busy) {
|
|
504
|
+
await dialog.showMessageBox({
|
|
505
|
+
type: "info",
|
|
506
|
+
title: "Free memory",
|
|
507
|
+
message: "The model is in use.",
|
|
508
|
+
detail: `${busy} Try again when it has finished.`,
|
|
509
|
+
buttons: ["OK"],
|
|
510
|
+
noLink: true,
|
|
511
|
+
});
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const { response } = await dialog.showMessageBox({
|
|
516
|
+
type: "question",
|
|
517
|
+
title: "Free memory?",
|
|
518
|
+
message: `Unload ${model} from memory?`,
|
|
519
|
+
detail:
|
|
520
|
+
"This frees the memory the model uses. Your next message, here or in another GAIA app, loads it again, which takes a moment.",
|
|
521
|
+
buttons: ["Free memory", "Cancel"],
|
|
522
|
+
defaultId: 0,
|
|
523
|
+
cancelId: 1,
|
|
524
|
+
noLink: true,
|
|
525
|
+
});
|
|
526
|
+
if (response !== 0) return;
|
|
527
|
+
try {
|
|
528
|
+
await backendJson(this._backendPort, "/api/system/unload-model", {
|
|
529
|
+
method: "POST",
|
|
530
|
+
body: { model_name: model },
|
|
531
|
+
timeoutMs: 30000,
|
|
532
|
+
});
|
|
533
|
+
} catch (err) {
|
|
534
|
+
await failed("GAIA couldn't free the model's memory.", err.message);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
console.log(`[tray] Unloaded ${model} to free memory`);
|
|
538
|
+
// The backend just confirmed it; the window's next status read agrees.
|
|
539
|
+
if (this._activity) this._activity = { ...this._activity, model: null };
|
|
540
|
+
this._render();
|
|
541
|
+
const win = this.mainWindow;
|
|
542
|
+
if (win && !win.isDestroyed()) {
|
|
543
|
+
win.webContents.send("tray:command", { type: "refresh-status" });
|
|
544
|
+
}
|
|
216
545
|
}
|
|
217
546
|
|
|
218
547
|
// ── Private: Window management ───────────────────────────────────────
|
|
219
548
|
|
|
549
|
+
/** @returns {boolean} True when the window had to be recreated. */
|
|
220
550
|
_showWindow() {
|
|
221
|
-
if (!this.mainWindow || this.mainWindow.isDestroyed())
|
|
222
|
-
|
|
551
|
+
if (!this.mainWindow || this.mainWindow.isDestroyed()) {
|
|
552
|
+
this.mainWindow = this._reopenWindow();
|
|
553
|
+
this._overlayKey = null;
|
|
554
|
+
this._renderOverlay();
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
223
557
|
if (this.mainWindow.isMinimized()) {
|
|
224
558
|
this.mainWindow.restore();
|
|
225
559
|
}
|
|
226
560
|
this.mainWindow.show();
|
|
227
561
|
this.mainWindow.focus();
|
|
562
|
+
return false;
|
|
228
563
|
}
|
|
229
564
|
|
|
230
|
-
|
|
565
|
+
_quit() {
|
|
231
566
|
console.log("[tray] Quit requested");
|
|
567
|
+
// main.cjs's before-quit asks first when work would be lost.
|
|
232
568
|
app.quit();
|
|
233
569
|
}
|
|
234
570
|
|
|
235
|
-
// ── Private:
|
|
571
|
+
// ── Private: Icons ───────────────────────────────────────────────────
|
|
236
572
|
|
|
237
573
|
/**
|
|
238
|
-
*
|
|
239
|
-
*
|
|
574
|
+
* The tray image for a state: the logo from the platform's small assets
|
|
575
|
+
* (never the 4K app icon) with that state's badge drawn on.
|
|
240
576
|
* @returns {Electron.NativeImage}
|
|
241
577
|
*/
|
|
242
|
-
|
|
243
|
-
if (
|
|
244
|
-
|
|
578
|
+
_iconFor(state) {
|
|
579
|
+
if (!STATES.includes(state)) throw new Error(`[tray] unknown tray state "${state}"`);
|
|
580
|
+
const cached = this._icons.get(state);
|
|
581
|
+
if (cached) return cached;
|
|
582
|
+
const spec = this._iconSpec;
|
|
583
|
+
const image = nativeImage.createEmpty();
|
|
584
|
+
for (const rep of spec.reps) {
|
|
585
|
+
let source = this._loadIcon(rep.file);
|
|
586
|
+
if (source.getSize().width !== rep.px) {
|
|
587
|
+
source = source.resize({ width: rep.px, height: rep.px, quality: "best" });
|
|
588
|
+
}
|
|
589
|
+
const buffer = drawStateIcon(
|
|
590
|
+
{ width: rep.px, height: rep.px, buffer: source.toBitmap() },
|
|
591
|
+
state,
|
|
592
|
+
{ template: spec.template }
|
|
593
|
+
);
|
|
594
|
+
image.addRepresentation({ scaleFactor: rep.scale, width: rep.px, height: rep.px, buffer });
|
|
245
595
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
596
|
+
// macOS menu-bar extras must be template images: the OS tints the
|
|
597
|
+
// alpha mask for light/dark mode.
|
|
598
|
+
if (spec.template) image.setTemplateImage(true);
|
|
599
|
+
this._icons.set(state, image);
|
|
600
|
+
return image;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
_overlayImage(kind) {
|
|
604
|
+
const key = `overlay:${kind}`;
|
|
605
|
+
const cached = this._icons.get(key);
|
|
606
|
+
if (cached) return cached;
|
|
607
|
+
const image = nativeImage.createEmpty();
|
|
608
|
+
for (const rep of OVERLAY_REPS) {
|
|
609
|
+
image.addRepresentation({
|
|
610
|
+
scaleFactor: rep.scale,
|
|
611
|
+
width: rep.px,
|
|
612
|
+
height: rep.px,
|
|
613
|
+
buffer: drawOverlayIcon(kind, rep.px),
|
|
614
|
+
});
|
|
253
615
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
return this._loadIcon("tray-icon.png");
|
|
616
|
+
this._icons.set(key, image);
|
|
617
|
+
return image;
|
|
257
618
|
}
|
|
258
619
|
|
|
259
620
|
_loadIcon(filename) {
|
|
@@ -283,7 +644,13 @@ class TrayManager {
|
|
|
283
644
|
console.warn("[tray] Could not load tray config:", err.message);
|
|
284
645
|
}
|
|
285
646
|
if (!isPlainObject(loaded)) {
|
|
286
|
-
return {
|
|
647
|
+
return {
|
|
648
|
+
...DEFAULT_CONFIG,
|
|
649
|
+
tray: { ...DEFAULT_CONFIG.tray },
|
|
650
|
+
agents: {},
|
|
651
|
+
notices: { ...DEFAULT_CONFIG.notices },
|
|
652
|
+
choices: { ...DEFAULT_CONFIG.choices },
|
|
653
|
+
};
|
|
287
654
|
}
|
|
288
655
|
|
|
289
656
|
// Scoped to the agents section: one bad entry must not cost the user their
|
|
@@ -312,11 +679,25 @@ class TrayManager {
|
|
|
312
679
|
}
|
|
313
680
|
}
|
|
314
681
|
|
|
682
|
+
const storedNotices = isPlainObject(loaded.notices) ? loaded.notices : {};
|
|
683
|
+
const notices = { ...DEFAULT_CONFIG.notices };
|
|
684
|
+
for (const key of Object.keys(DEFAULT_CONFIG.notices)) {
|
|
685
|
+
if (typeof storedNotices[key] === "boolean") notices[key] = storedNotices[key];
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const storedChoices = isPlainObject(loaded.choices) ? loaded.choices : {};
|
|
689
|
+
const choices = { ...DEFAULT_CONFIG.choices };
|
|
690
|
+
for (const key of Object.keys(DEFAULT_CONFIG.choices)) {
|
|
691
|
+
if (typeof storedChoices[key] === "boolean") choices[key] = storedChoices[key];
|
|
692
|
+
}
|
|
693
|
+
|
|
315
694
|
return {
|
|
316
695
|
...DEFAULT_CONFIG,
|
|
317
696
|
...loaded,
|
|
318
697
|
tray,
|
|
319
698
|
agents,
|
|
699
|
+
notices,
|
|
700
|
+
choices,
|
|
320
701
|
};
|
|
321
702
|
}
|
|
322
703
|
|
|
@@ -336,6 +717,10 @@ class TrayManager {
|
|
|
336
717
|
// ── Private: IPC handlers ────────────────────────────────────────────
|
|
337
718
|
|
|
338
719
|
_registerIpcHandlers() {
|
|
720
|
+
ipcMain.handle("tray:set-activity", (_event, activity) => {
|
|
721
|
+
this.setActivity(activity);
|
|
722
|
+
});
|
|
723
|
+
|
|
339
724
|
ipcMain.handle("tray:get-config", () => {
|
|
340
725
|
return this.config;
|
|
341
726
|
});
|
|
@@ -367,33 +752,147 @@ class TrayManager {
|
|
|
367
752
|
if (tray === null && agents === null) {
|
|
368
753
|
return this.config;
|
|
369
754
|
}
|
|
755
|
+
const loginChanged =
|
|
756
|
+
tray !== null && tray.startOnLogin !== this.config.tray.startOnLogin;
|
|
370
757
|
const next = {
|
|
371
758
|
...this.config,
|
|
372
759
|
tray: tray ?? this.config.tray,
|
|
373
760
|
agents: agents ? { ...this.config.agents, ...agents } : this.config.agents,
|
|
761
|
+
choices: loginChanged
|
|
762
|
+
? { ...this.config.choices, startOnLogin: true }
|
|
763
|
+
: this.config.choices,
|
|
374
764
|
};
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
this.
|
|
765
|
+
// The OS login item first: if it can't be set, nothing is saved, so the
|
|
766
|
+
// toggle never claims a state the OS doesn't have.
|
|
767
|
+
if (loginChanged) this._applyLoginItemSetting(tray.startOnLogin);
|
|
768
|
+
try {
|
|
769
|
+
this._saveConfig(next);
|
|
770
|
+
} catch (err) {
|
|
771
|
+
// Keep the OS and the saved toggle in step: undo the login item.
|
|
772
|
+
if (loginChanged) this._applyLoginItemSetting(this.config.tray.startOnLogin);
|
|
773
|
+
throw err;
|
|
380
774
|
}
|
|
775
|
+
this.config = next;
|
|
381
776
|
|
|
382
777
|
return this.config;
|
|
383
778
|
});
|
|
384
779
|
}
|
|
385
780
|
|
|
386
|
-
/**
|
|
781
|
+
/**
|
|
782
|
+
* Line the sign-in switch up with what the OS will actually do. Call once
|
|
783
|
+
* at startup. Until the user sets the switch, the OS decides it (the
|
|
784
|
+
* Windows installer turns sign-in start on). Once they turn it off, an
|
|
785
|
+
* entry that came back (a reinstall or update rewrites it) is removed again.
|
|
786
|
+
*/
|
|
787
|
+
syncLoginItem() {
|
|
788
|
+
// A dev build's exe is electron itself; never touch the installed app's entry.
|
|
789
|
+
if (!app.isPackaged) return;
|
|
790
|
+
const osOn = this._loginItemOn();
|
|
791
|
+
const wanted = this.config.tray.startOnLogin;
|
|
792
|
+
if (osOn === wanted) return;
|
|
793
|
+
if (this.config.choices.startOnLogin && !wanted) {
|
|
794
|
+
try {
|
|
795
|
+
this._applyLoginItemSetting(false);
|
|
796
|
+
return;
|
|
797
|
+
} catch (err) {
|
|
798
|
+
console.error(`[tray] Sign-in start is back on and could not be turned off again: ${err.message}`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
this.config = { ...this.config, tray: { ...this.config.tray, startOnLogin: osOn } };
|
|
802
|
+
console.log(`[tray] Start at sign-in is ${osOn ? "on" : "off"} in the OS; the switch follows it`);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** The executable and arguments the login item launches. */
|
|
806
|
+
_loginItemQuery() {
|
|
807
|
+
const args = process.platform === "win32" ? WIN_LOGIN_ITEM_ARGS : [];
|
|
808
|
+
return { path: app.getPath("exe"), args };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** Whether GAIA will start at sign-in, read from the OS. */
|
|
812
|
+
_loginItemOn() {
|
|
813
|
+
if (process.platform === "linux") {
|
|
814
|
+
return fs.existsSync(this._linuxAutostartFile());
|
|
815
|
+
}
|
|
816
|
+
const now = app.getLoginItemSettings(this._loginItemQuery());
|
|
817
|
+
if (process.platform !== "win32") return Boolean(now.openAtLogin);
|
|
818
|
+
// openAtLogin reads the value named after the AppUserModelId, not ours.
|
|
819
|
+
// A Run value disabled in Task Manager is listed with enabled: false.
|
|
820
|
+
const item = (now.launchItems || []).find(
|
|
821
|
+
(i) => i.name === WIN_LOGIN_ITEM_NAME && i.scope === "user"
|
|
822
|
+
);
|
|
823
|
+
return Boolean(item && item.enabled);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Register or remove GAIA as a login item. On Windows that is the same Run
|
|
828
|
+
* value the installer writes, launched into the tray; elsewhere "Start in
|
|
829
|
+
* the tray" decides whether that launch shows the window.
|
|
830
|
+
* @throws {Error} When the OS refused; the caller's save is skipped.
|
|
831
|
+
*/
|
|
387
832
|
_applyLoginItemSetting(enabled) {
|
|
833
|
+
if (process.platform === "linux") {
|
|
834
|
+
this._applyLinuxAutostart(enabled);
|
|
835
|
+
} else {
|
|
836
|
+
const what = enabled ? "add GAIA to" : "remove GAIA from";
|
|
837
|
+
try {
|
|
838
|
+
const name = process.platform === "win32" ? { name: WIN_LOGIN_ITEM_NAME } : {};
|
|
839
|
+
app.setLoginItemSettings({ ...this._loginItemQuery(), ...name, openAtLogin: enabled });
|
|
840
|
+
} catch (err) {
|
|
841
|
+
throw new Error(`Could not ${what} the apps that start at sign-in: ${err.message}`);
|
|
842
|
+
}
|
|
843
|
+
// The OS can refuse without throwing; read back what it actually has.
|
|
844
|
+
if (process.platform === "darwin" && enabled) {
|
|
845
|
+
if (app.getLoginItemSettings(this._loginItemQuery()).status === "requires-approval") {
|
|
846
|
+
throw new Error(
|
|
847
|
+
"macOS needs your approval to start GAIA at sign-in. Allow it in System Settings → General → Login Items, then turn this on again."
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
const now = this._loginItemOn();
|
|
852
|
+
if (now !== enabled) {
|
|
853
|
+
throw new Error(
|
|
854
|
+
`Could not ${what} the apps that start at sign-in: the system still reports it ${now ? "on" : "off"}. ` +
|
|
855
|
+
"Check your startup apps in the system settings."
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
console.log(`[tray] Login item ${enabled ? "enabled" : "disabled"}`);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
_linuxAutostartFile() {
|
|
863
|
+
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
864
|
+
return path.join(configHome, "autostart", LINUX_AUTOSTART_FILE);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Electron has no login items on Linux; desktops read XDG autostart entries. */
|
|
868
|
+
_applyLinuxAutostart(enabled) {
|
|
869
|
+
const file = this._linuxAutostartFile();
|
|
870
|
+
// An AppImage's exe is a temporary mount; APPIMAGE is the file the user runs.
|
|
871
|
+
const exe = process.env.APPIMAGE || app.getPath("exe");
|
|
388
872
|
try {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
873
|
+
if (enabled) {
|
|
874
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
875
|
+
fs.writeFileSync(
|
|
876
|
+
file,
|
|
877
|
+
[
|
|
878
|
+
"[Desktop Entry]",
|
|
879
|
+
"Type=Application",
|
|
880
|
+
"Name=GAIA",
|
|
881
|
+
"Comment=Start GAIA when you sign in",
|
|
882
|
+
`Exec=${desktopExecArg(exe)}`,
|
|
883
|
+
"X-GNOME-Autostart-enabled=true",
|
|
884
|
+
"",
|
|
885
|
+
].join("\n"),
|
|
886
|
+
"utf8"
|
|
887
|
+
);
|
|
888
|
+
} else if (fs.existsSync(file)) {
|
|
889
|
+
fs.unlinkSync(file);
|
|
890
|
+
}
|
|
395
891
|
} catch (err) {
|
|
396
|
-
|
|
892
|
+
throw new Error(
|
|
893
|
+
`Could not ${enabled ? "write" : "remove"} the autostart entry ${file}: ${err.message}. ` +
|
|
894
|
+
"Check that the folder is writable."
|
|
895
|
+
);
|
|
397
896
|
}
|
|
398
897
|
}
|
|
399
898
|
}
|