@amd-gaia/agent-ui 0.17.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/main.cjs ADDED
@@ -0,0 +1,511 @@
1
+ // Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // GAIA Agent UI - Electron main process
5
+ // Self-contained entry point for the desktop installer.
6
+ //
7
+ // Starts the Python backend (gaia chat --ui), creates the system tray icon,
8
+ // manages OS agent subprocesses, and loads the frontend.
9
+ //
10
+ // Services (co-located per T0 decision):
11
+ // services/tray-manager.js — System tray icon + context menu (T1)
12
+ // services/agent-process-manager.js — OS agent subprocess lifecycle (T2)
13
+ // services/notification-service.js — Desktop notifications + permission prompts (T5)
14
+ // preload.cjs — contextBridge for IPC channels (T0/T1)
15
+
16
+ const { app, BrowserWindow, shell } = require("electron");
17
+ const path = require("path");
18
+ const fs = require("fs");
19
+ const { spawn } = require("child_process");
20
+
21
+ // Services (loaded after app.whenReady)
22
+ const TrayManager = require("./services/tray-manager.cjs");
23
+ const AgentProcessManager = require("./services/agent-process-manager.cjs");
24
+ const NotificationService = require("./services/notification-service.cjs");
25
+
26
+ // ── Configuration ──────────────────────────────────────────────────────────
27
+
28
+ const APP_NAME = "GAIA Agent UI";
29
+ const BACKEND_PORT = 4200;
30
+ const HEALTH_CHECK_URL = `http://localhost:${BACKEND_PORT}/api/health`;
31
+ const STARTUP_TIMEOUT = 30000;
32
+
33
+ // Parse CLI args (T11: --minimized flag for auto-start)
34
+ const startMinimized = process.argv.includes("--minimized");
35
+
36
+ // Load app.config.json if available
37
+ let appConfig = {};
38
+ try {
39
+ const configPath = path.join(__dirname, "app.config.json");
40
+ if (fs.existsSync(configPath)) {
41
+ appConfig = JSON.parse(fs.readFileSync(configPath, "utf8"));
42
+ }
43
+ } catch (error) {
44
+ console.warn("Could not load app.config.json:", error.message);
45
+ }
46
+
47
+ const windowConfig = appConfig.window || {
48
+ width: 1200,
49
+ height: 800,
50
+ minWidth: 800,
51
+ minHeight: 500,
52
+ };
53
+
54
+ // ── State ──────────────────────────────────────────────────────────────────
55
+
56
+ let backendProcess = null;
57
+ let mainWindow = null;
58
+
59
+ /** @type {TrayManager | null} */
60
+ let trayManager = null;
61
+
62
+ /** @type {AgentProcessManager | null} */
63
+ let agentProcessManager = null;
64
+
65
+ /** @type {NotificationService | null} */
66
+ let notificationService = null;
67
+
68
+ /**
69
+ * Set to true when the user explicitly quits (via tray "Quit" or Cmd+Q).
70
+ * Prevents minimize-to-tray from intercepting the close event.
71
+ */
72
+ let isQuitting = false;
73
+
74
+ // ── Backend Process ────────────────────────────────────────────────────────
75
+
76
+ function findGaiaCommand() {
77
+ const isWindows = process.platform === "win32";
78
+
79
+ // Check common locations
80
+ const candidates = isWindows
81
+ ? ["gaia.exe", "gaia", "gaia.cmd"]
82
+ : ["gaia"];
83
+
84
+ for (const cmd of candidates) {
85
+ try {
86
+ const { execSync } = require("child_process");
87
+ const check = isWindows ? `where ${cmd}` : `which ${cmd}`;
88
+ execSync(check, { stdio: "ignore" });
89
+ return cmd;
90
+ } catch {
91
+ continue;
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ function startBackend() {
98
+ const gaiaCmd = findGaiaCommand();
99
+
100
+ if (!gaiaCmd) {
101
+ console.warn(
102
+ "Warning: gaia CLI not found. Backend will not start automatically."
103
+ );
104
+ console.warn("Install with: pip install amd-gaia");
105
+ return null;
106
+ }
107
+
108
+ console.log(`Starting backend: ${gaiaCmd} chat --ui --ui-port ${BACKEND_PORT}`);
109
+
110
+ const child = spawn(
111
+ gaiaCmd,
112
+ ["chat", "--ui", "--ui-port", String(BACKEND_PORT)],
113
+ {
114
+ stdio: ["ignore", "pipe", "pipe"],
115
+ env: { ...process.env },
116
+ detached: false,
117
+ windowsHide: true, // Prevent console window flash on Windows
118
+ }
119
+ );
120
+
121
+ child.stdout.on("data", (data) => {
122
+ const line = data.toString().trim();
123
+ if (line) console.log(`[backend] ${line}`);
124
+ });
125
+
126
+ child.stderr.on("data", (data) => {
127
+ const line = data.toString().trim();
128
+ if (line) console.log(`[backend] ${line}`);
129
+ });
130
+
131
+ child.on("error", (err) => {
132
+ console.error("Failed to start backend:", err.message);
133
+ });
134
+
135
+ child.on("exit", (code) => {
136
+ if (code !== 0 && code !== null) {
137
+ console.error(`Backend exited with code ${code}`);
138
+ }
139
+ backendProcess = null;
140
+ });
141
+
142
+ return child;
143
+ }
144
+
145
+ async function waitForBackend(timeoutMs) {
146
+ const start = Date.now();
147
+ const http = require("http");
148
+
149
+ while (Date.now() - start < timeoutMs) {
150
+ try {
151
+ await new Promise((resolve, reject) => {
152
+ const req = http.get(HEALTH_CHECK_URL, (res) => {
153
+ if (res.statusCode === 200) {
154
+ resolve();
155
+ } else {
156
+ reject(new Error(`Status ${res.statusCode}`));
157
+ }
158
+ });
159
+ req.on("error", reject);
160
+ req.setTimeout(2000, () => {
161
+ req.destroy();
162
+ reject(new Error("timeout"));
163
+ });
164
+ });
165
+ return true;
166
+ } catch {
167
+ await new Promise((r) => setTimeout(r, 500));
168
+ }
169
+ }
170
+ return false;
171
+ }
172
+
173
+ // ── Window ─────────────────────────────────────────────────────────────────
174
+
175
+ function findDistPath() {
176
+ // Check multiple locations (dev vs packaged)
177
+ const candidates = [
178
+ path.join(__dirname, "dist", "index.html"), // Development
179
+ path.join(process.resourcesPath || "", "dist", "index.html"), // Packaged (extraResource)
180
+ path.join(__dirname, "..", "dist", "index.html"), // Alternative packaged
181
+ ];
182
+
183
+ for (const candidate of candidates) {
184
+ if (fs.existsSync(candidate)) {
185
+ return path.dirname(candidate);
186
+ }
187
+ }
188
+ return null;
189
+ }
190
+
191
+ function createWindow() {
192
+ mainWindow = new BrowserWindow({
193
+ width: windowConfig.width,
194
+ height: windowConfig.height,
195
+ minWidth: windowConfig.minWidth,
196
+ minHeight: windowConfig.minHeight,
197
+ title: APP_NAME,
198
+ icon: path.join(__dirname, "assets", process.platform === "win32" ? "icon.ico" : "icon.png"),
199
+ show: false, // Don't show until ready (prevents flash)
200
+ webPreferences: {
201
+ nodeIntegration: false,
202
+ contextIsolation: true,
203
+ preload: path.join(__dirname, "preload.cjs"), // C2 fix: expose IPC via contextBridge
204
+ },
205
+ });
206
+
207
+ // Remove default menu bar
208
+ mainWindow.setMenuBarVisibility(false);
209
+
210
+ // Open external links in the default browser
211
+ mainWindow.webContents.setWindowOpenHandler(({ url }) => {
212
+ shell.openExternal(url);
213
+ return { action: "deny" };
214
+ });
215
+
216
+ // ── Minimize-to-tray on close (C4 fix) ──────────────────────────────
217
+ // Intercept window close — hide instead of closing when tray mode is active
218
+ mainWindow.on("close", (event) => {
219
+ if (!isQuitting && trayManager && trayManager.minimizeToTray) {
220
+ event.preventDefault();
221
+ mainWindow.hide();
222
+ console.log("[main] Window hidden to tray");
223
+ }
224
+ });
225
+
226
+ mainWindow.on("closed", () => {
227
+ mainWindow = null;
228
+ });
229
+
230
+ // Show window when ready (unless --minimized or startMinimized config)
231
+ mainWindow.once("ready-to-show", () => {
232
+ const shouldStartMinimized =
233
+ startMinimized || (trayManager && trayManager.startMinimized);
234
+
235
+ if (!shouldStartMinimized) {
236
+ mainWindow.show();
237
+ } else {
238
+ console.log("[main] Starting minimized to tray");
239
+ }
240
+ });
241
+
242
+ return mainWindow;
243
+ }
244
+
245
+ async function loadApp() {
246
+ const distPath = findDistPath();
247
+
248
+ if (distPath) {
249
+ // Load the built frontend directly (for when backend serves it)
250
+ // First try loading from the backend URL
251
+ try {
252
+ await mainWindow.loadURL(`http://localhost:${BACKEND_PORT}`);
253
+ console.log("Loaded app from backend server");
254
+ return;
255
+ } catch {
256
+ // Fall through to loading from file
257
+ }
258
+
259
+ // Load from built files
260
+ const indexPath = path.join(distPath, "index.html");
261
+ console.log("Loading app from:", indexPath);
262
+ await mainWindow.loadFile(indexPath);
263
+ } else {
264
+ // Show a simple loading/error page
265
+ mainWindow.loadURL(
266
+ `data:text/html,
267
+ <html>
268
+ <head><title>${APP_NAME}</title></head>
269
+ <body style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#1a1a2e; color:#eee;">
270
+ <div style="text-align:center;">
271
+ <h1>${APP_NAME}</h1>
272
+ <p>Waiting for backend to start...</p>
273
+ <p style="color:#888; font-size:12px;">Backend: http://localhost:${BACKEND_PORT}</p>
274
+ </div>
275
+ </body>
276
+ </html>`
277
+ );
278
+ }
279
+ }
280
+
281
+ // ── Services Setup ─────────────────────────────────────────────────────────
282
+
283
+ function initializeServices() {
284
+ console.log("[main] Initializing services...");
285
+
286
+ // T2: Agent Process Manager (manages OS agent subprocesses)
287
+ agentProcessManager = new AgentProcessManager(mainWindow);
288
+
289
+ // T1: Tray Manager (system tray icon + context menu)
290
+ trayManager = new TrayManager(mainWindow, { backendPort: BACKEND_PORT });
291
+ trayManager.create();
292
+
293
+ // T5: Notification Service (routes agent notifications to OS + renderer)
294
+ notificationService = new NotificationService(
295
+ mainWindow,
296
+ agentProcessManager,
297
+ trayManager
298
+ );
299
+
300
+ console.log("[main] Services initialized");
301
+ }
302
+
303
+ // ── Windows Jump List (T11) ────────────────────────────────────────────────
304
+
305
+ function setupJumpList() {
306
+ if (process.platform !== "win32") return;
307
+
308
+ try {
309
+ app.setJumpList([
310
+ {
311
+ type: "tasks",
312
+ items: [
313
+ {
314
+ type: "task",
315
+ title: "New Task",
316
+ description: "Start a new agent task",
317
+ program: process.execPath,
318
+ args: "",
319
+ iconPath: process.execPath,
320
+ iconIndex: 0,
321
+ },
322
+ {
323
+ type: "task",
324
+ title: "Agent Manager",
325
+ description: "View and manage OS agents",
326
+ program: process.execPath,
327
+ args: "--show-agents",
328
+ iconPath: process.execPath,
329
+ iconIndex: 0,
330
+ },
331
+ ],
332
+ },
333
+ ]);
334
+ console.log("[main] Windows Jump List configured");
335
+ } catch (err) {
336
+ console.warn("[main] Could not set Jump List:", err.message);
337
+ }
338
+ }
339
+
340
+ // ── App Lifecycle ──────────────────────────────────────────────────────────
341
+
342
+ // Handle creating/removing shortcuts on Windows when installing/uninstalling
343
+ try {
344
+ if (require("electron-squirrel-startup")) {
345
+ app.quit();
346
+ }
347
+ } catch {
348
+ // electron-squirrel-startup not available
349
+ }
350
+
351
+ app.whenReady().then(async () => {
352
+ // Start the Python backend
353
+ backendProcess = startBackend();
354
+
355
+ // Create the window (hidden until ready-to-show)
356
+ createWindow();
357
+
358
+ // Initialize services (tray, agent manager, notifications)
359
+ initializeServices();
360
+
361
+ // Setup Windows Jump List (T11)
362
+ setupJumpList();
363
+
364
+ // Show loading state
365
+ await loadApp();
366
+
367
+ // Wait for backend to be ready, then reload
368
+ if (backendProcess) {
369
+ console.log("Waiting for backend to start...");
370
+ const ready = await waitForBackend(STARTUP_TIMEOUT);
371
+
372
+ if (ready && mainWindow && !mainWindow.isDestroyed()) {
373
+ console.log("Backend is ready! Loading app...");
374
+ try {
375
+ await mainWindow.loadURL(`http://localhost:${BACKEND_PORT}`);
376
+ } catch (error) {
377
+ console.error("Failed to load from backend:", error.message);
378
+ }
379
+ } else if (!ready) {
380
+ console.warn("Backend did not respond within timeout.");
381
+ }
382
+ }
383
+
384
+ // Auto-start enabled agents (T2)
385
+ if (agentProcessManager) {
386
+ try {
387
+ await agentProcessManager.startAllEnabled();
388
+ } catch (err) {
389
+ console.error("Failed to auto-start agents:", err.message);
390
+ }
391
+ }
392
+
393
+ app.on("activate", async () => {
394
+ if (BrowserWindow.getAllWindows().length === 0) {
395
+ createWindow();
396
+ // Re-wire existing services to the new window (don't re-create — IPC handlers are already registered)
397
+ if (agentProcessManager) agentProcessManager.mainWindow = mainWindow;
398
+ if (trayManager) trayManager.mainWindow = mainWindow;
399
+ if (notificationService) notificationService.mainWindow = mainWindow;
400
+ try {
401
+ await loadApp();
402
+ } catch (err) {
403
+ console.error("[main] Failed to load app on activate:", err.message);
404
+ }
405
+ } else if (mainWindow) {
406
+ mainWindow.show();
407
+ }
408
+ });
409
+ });
410
+
411
+ // ── Window-all-closed (C4 fix) ────────────────────────────────────────────
412
+ // Don't quit when window is hidden — tray keeps app alive
413
+ app.on("window-all-closed", () => {
414
+ // If minimize-to-tray is active, the window is just hidden, not closed.
415
+ // Only quit on macOS if the user explicitly quit (Cmd+Q).
416
+ const trayActive = trayManager && trayManager.minimizeToTray;
417
+
418
+ if (!trayActive && process.platform !== "darwin") {
419
+ // Trigger the will-quit path which handles async cleanup properly
420
+ app.quit();
421
+ }
422
+ // Otherwise: no-op. App stays running via system tray.
423
+ });
424
+
425
+ // ── Quit lifecycle ─────────────────────────────────────────────────────────
426
+ // Electron's before-quit does NOT await async handlers.
427
+ // We use will-quit + event.preventDefault() to perform async cleanup, then re-quit.
428
+
429
+ let cleanupDone = false;
430
+
431
+ app.on("before-quit", () => {
432
+ isQuitting = true;
433
+ });
434
+
435
+ app.on("will-quit", (event) => {
436
+ if (cleanupDone) return; // Cleanup already finished, let the app quit
437
+
438
+ event.preventDefault(); // Prevent quit until cleanup is done
439
+ console.log("[main] will-quit: performing async cleanup...");
440
+
441
+ cleanup().then(() => {
442
+ cleanupDone = true;
443
+ console.log("[main] Cleanup complete, quitting...");
444
+ app.quit(); // Re-trigger quit — cleanupDone prevents infinite loop
445
+ }).catch((err) => {
446
+ console.error("[main] Cleanup error:", err.message);
447
+ cleanupDone = true;
448
+ app.quit();
449
+ });
450
+ });
451
+
452
+ async function cleanup() {
453
+ // Clean up notification timers
454
+ if (notificationService) {
455
+ notificationService.destroy();
456
+ notificationService = null;
457
+ }
458
+
459
+ // Stop all managed OS agents gracefully
460
+ if (agentProcessManager) {
461
+ console.log("Stopping all managed agents...");
462
+ try {
463
+ await agentProcessManager.stopAll();
464
+ } catch (err) {
465
+ console.error("Error stopping agents:", err.message);
466
+ }
467
+ agentProcessManager = null;
468
+ }
469
+
470
+ // Destroy tray icon
471
+ if (trayManager) {
472
+ trayManager.destroy();
473
+ trayManager = null;
474
+ }
475
+
476
+ // Stop the Python backend
477
+ if (backendProcess) {
478
+ console.log("Stopping backend process...");
479
+ const proc = backendProcess; // Save reference before nulling
480
+ backendProcess = null;
481
+
482
+ try {
483
+ proc.kill("SIGTERM");
484
+ } catch {
485
+ // Already dead
486
+ }
487
+
488
+ // Wait for the process to exit, with a force-kill fallback
489
+ await new Promise((resolve) => {
490
+ // Check if already exited (exitCode is set once the process exits)
491
+ if (proc.exitCode !== null) {
492
+ resolve();
493
+ return;
494
+ }
495
+
496
+ const forceKillTimer = setTimeout(() => {
497
+ try {
498
+ proc.kill("SIGKILL");
499
+ } catch {
500
+ // Already dead
501
+ }
502
+ resolve();
503
+ }, 3000);
504
+
505
+ proc.once("exit", () => {
506
+ clearTimeout(forceKillTimer);
507
+ resolve();
508
+ });
509
+ });
510
+ }
511
+ }
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@amd-gaia/agent-ui",
3
+ "version": "0.17.0",
4
+ "type": "module",
5
+ "productName": "GAIA Agent UI",
6
+ "description": "Privacy-first agentic AI interface with document Q&A - runs 100% locally on AMD Ryzen AI",
7
+ "author": "AMD AI Group",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/amd/gaia.git",
12
+ "directory": "src/gaia/apps/webui"
13
+ },
14
+ "homepage": "https://amd-gaia.ai/guides/agent-ui",
15
+ "bugs": {
16
+ "url": "https://github.com/amd/gaia/issues"
17
+ },
18
+ "keywords": [
19
+ "gaia",
20
+ "amd",
21
+ "ryzen-ai",
22
+ "agent",
23
+ "ai",
24
+ "local-llm",
25
+ "privacy",
26
+ "rag",
27
+ "document-qa",
28
+ "electron"
29
+ ],
30
+ "main": "main.cjs",
31
+ "bin": {
32
+ "gaia-ui": "bin/gaia-ui.mjs"
33
+ },
34
+ "files": [
35
+ "bin/",
36
+ "dist/",
37
+ "main.cjs",
38
+ "preload.cjs",
39
+ "services/",
40
+ "assets/",
41
+ "app.config.json",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "engines": {
46
+ "node": ">=18"
47
+ },
48
+ "scripts": {
49
+ "dev": "vite",
50
+ "build": "tsc && vite build",
51
+ "preview": "vite preview",
52
+ "start": "electron .",
53
+ "package": "npm run build && electron-forge package",
54
+ "make": "npm run build && electron-forge make",
55
+ "prepublishOnly": "npm run build"
56
+ },
57
+ "config": {
58
+ "forge": "./forge.config.cjs"
59
+ },
60
+ "dependencies": {
61
+ "electron-squirrel-startup": "^1.0.0"
62
+ },
63
+ "devDependencies": {
64
+ "@electron-forge/cli": "^7.2.0",
65
+ "@electron-forge/maker-deb": "^7.2.0",
66
+ "@electron-forge/maker-squirrel": "^7.2.0",
67
+ "@types/react": "^18.2.48",
68
+ "@types/react-dom": "^18.2.18",
69
+ "@vitejs/plugin-react": "^4.2.1",
70
+ "electron": "^40.6.1",
71
+ "lucide-react": "^0.312.0",
72
+ "qrcode": "^1.5.4",
73
+ "react": "^18.2.0",
74
+ "react-dom": "^18.2.0",
75
+ "react-markdown": "^9.1.0",
76
+ "rehype-raw": "^7.0.0",
77
+ "rehype-sanitize": "^6.0.0",
78
+ "remark-gfm": "^4.0.1",
79
+ "typescript": "^5.3.3",
80
+ "vite": "^5.0.12",
81
+ "zustand": "^4.5.0"
82
+ }
83
+ }
package/preload.cjs ADDED
@@ -0,0 +1,61 @@
1
+ // Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * GAIA Agent UI — Preload script (contextBridge)
6
+ *
7
+ * Exposes IPC channels to the renderer process via `window.gaiaAPI`.
8
+ * Required because main.cjs uses `contextIsolation: true`.
9
+ *
10
+ * Channels:
11
+ * agent:* — Agent process management (T2)
12
+ * tray:* — Tray icon/config (T1)
13
+ * notification:* — Desktop notifications & permission prompts (T5)
14
+ */
15
+
16
+ const { contextBridge, ipcRenderer } = require("electron");
17
+
18
+ // Helper: subscribe to an IPC event and return an unsubscribe function
19
+ function onEvent(channel, callback) {
20
+ const handler = (_event, data) => callback(data);
21
+ ipcRenderer.on(channel, handler);
22
+ return () => ipcRenderer.removeListener(channel, handler);
23
+ }
24
+
25
+ contextBridge.exposeInMainWorld("gaiaAPI", {
26
+ // ── Agent process management (T2) ─────────────────────────────────────
27
+ agent: {
28
+ start: (id) => ipcRenderer.invoke("agent:start", id),
29
+ stop: (id) => ipcRenderer.invoke("agent:stop", id),
30
+ restart: (id) => ipcRenderer.invoke("agent:restart", id),
31
+ status: (id) => ipcRenderer.invoke("agent:status", id),
32
+ statusAll: () => ipcRenderer.invoke("agent:status-all"),
33
+ sendRpc: (id, method, params) =>
34
+ ipcRenderer.invoke("agent:send-rpc", id, method, params),
35
+ getManifest: () => ipcRenderer.invoke("agent:get-manifest"),
36
+ install: (id) => ipcRenderer.invoke("agent:install", id),
37
+ uninstall: (id) => ipcRenderer.invoke("agent:uninstall", id),
38
+
39
+ // Event streams (return unsubscribe functions)
40
+ onStdout: (cb) => onEvent("agent:stdout", cb),
41
+ onStderr: (cb) => onEvent("agent:stderr", cb),
42
+ onStatusChange: (cb) => onEvent("agent:status-change", cb),
43
+ onCrashed: (cb) => onEvent("agent:crashed", cb),
44
+ },
45
+
46
+ // ── Tray configuration (T1) ───────────────────────────────────────────
47
+ tray: {
48
+ getConfig: () => ipcRenderer.invoke("tray:get-config"),
49
+ setConfig: (cfg) => ipcRenderer.invoke("tray:set-config", cfg),
50
+ onNavigate: (cb) => onEvent("tray:navigate", cb),
51
+ },
52
+
53
+ // ── Notifications & permission prompts (T5) ───────────────────────────
54
+ notification: {
55
+ onPermissionRequest: (cb) =>
56
+ onEvent("notification:permission-request", cb),
57
+ respondPermission: (id, action, remember) =>
58
+ ipcRenderer.invoke("notification:respond", id, action, remember),
59
+ onNotification: (cb) => onEvent("notification:new", cb),
60
+ },
61
+ });