@amd-gaia/agent-ui 0.17.1 → 0.17.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amd-gaia/agent-ui",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
4
4
  "type": "module",
5
5
  "productName": "GAIA Agent UI",
6
6
  "description": "Privacy-first agentic AI interface with document Q&A - runs 100% locally on AMD Ryzen AI",
@@ -29,7 +29,7 @@
29
29
  ],
30
30
  "main": "main.cjs",
31
31
  "bin": {
32
- "gaia-ui": "bin/gaia-ui.mjs"
32
+ "gaia-ui": "bin/gaia-ui.cjs"
33
33
  },
34
34
  "files": [
35
35
  "bin/",
@@ -50,24 +50,21 @@
50
50
  "build": "tsc && vite build",
51
51
  "preview": "vite preview",
52
52
  "start": "electron .",
53
- "package": "npm run build && electron-forge package",
54
- "make": "npm run build && electron-forge make",
53
+ "package": "npm run build && electron-builder --config electron-builder.yml",
54
+ "package:win": "npm run build && electron-builder --win --config electron-builder.yml",
55
+ "package:mac": "npm run build && electron-builder --mac --config electron-builder.yml",
56
+ "package:linux": "npm run build && electron-builder --linux --config electron-builder.yml",
55
57
  "prepublishOnly": "npm run build"
56
58
  },
57
- "config": {
58
- "forge": "./forge.config.cjs"
59
- },
60
59
  "dependencies": {
61
- "electron-squirrel-startup": "^1.0.0"
60
+ "electron-updater": "^6.8.3"
62
61
  },
63
62
  "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
63
  "@types/react": "^18.2.48",
68
64
  "@types/react-dom": "^18.2.18",
69
65
  "@vitejs/plugin-react": "^4.2.1",
70
66
  "electron": "^40.6.1",
67
+ "electron-builder": "^26.8.1",
71
68
  "lucide-react": "^0.312.0",
72
69
  "qrcode": "^1.5.4",
73
70
  "react": "^18.2.0",
package/preload.cjs CHANGED
@@ -11,6 +11,8 @@
11
11
  * agent:* — Agent process management (T2)
12
12
  * tray:* — Tray icon/config (T1)
13
13
  * notification:* — Desktop notifications & permission prompts (T5)
14
+ * install:* — First-run backend install progress (Phase A)
15
+ * gaia:update:* — Auto-update status & manual check (Phase F)
14
16
  */
15
17
 
16
18
  const { contextBridge, ipcRenderer } = require("electron");
@@ -59,3 +61,43 @@ contextBridge.exposeInMainWorld("gaiaAPI", {
59
61
  onNotification: (cb) => onEvent("notification:new", cb),
60
62
  },
61
63
  });
64
+
65
+ // ── Install progress (Phase A) ──────────────────────────────────────────
66
+ // Exposed as a separate global so the progress window can use it without
67
+ // pulling in the full gaiaAPI surface (and so it keeps working if an
68
+ // install dialog runs before the main window is ready).
69
+ contextBridge.exposeInMainWorld("gaiaInstall", {
70
+ // Subscribe to progress updates. Returns an unsubscribe function.
71
+ onProgress: (cb) => onEvent("install:progress", cb),
72
+
73
+ // Query the current install state (state machine + log/state paths).
74
+ status: () => ipcRenderer.invoke("install:status"),
75
+
76
+ // Copy the log file path to the clipboard.
77
+ copyLogPath: () => ipcRenderer.invoke("install:copy-log-path"),
78
+
79
+ // Open the log file in the OS's default viewer.
80
+ openLogFile: () => ipcRenderer.invoke("install:open-log-file"),
81
+ });
82
+
83
+ // ── Auto-update (Phase F) ───────────────────────────────────────────────
84
+ // Exposed as a separate global so the renderer can show a small "update
85
+ // available" chip without pulling in the full gaiaAPI surface. Mirrors
86
+ // the naming convention (gaiaInstall, gaiaUpdater, gaiaAPI).
87
+ contextBridge.exposeInMainWorld("gaiaUpdater", {
88
+ /** Get the current update state (status, version, progress, error). */
89
+ getStatus: () => ipcRenderer.invoke("gaia:update:get-status"),
90
+
91
+ /** Manually trigger a check. Resolves with the post-check state. */
92
+ check: () => ipcRenderer.invoke("gaia:update:check"),
93
+
94
+ /**
95
+ * Subscribe to status changes. The callback is invoked with the full
96
+ * state object on every state transition. Returns an unsubscribe fn.
97
+ */
98
+ onStatusChange: (callback) => {
99
+ const handler = (_event, state) => callback(state);
100
+ ipcRenderer.on("gaia:update:status", handler);
101
+ return () => ipcRenderer.removeListener("gaia:update:status", handler);
102
+ },
103
+ });
@@ -0,0 +1,301 @@
1
+ // Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * agent-seeder.cjs — First-launch bundled-agent seeder.
6
+ *
7
+ * Copies agents bundled with the installer (placed at
8
+ * `<resourcesPath>/agents/` by electron-builder's extraResources rule) into
9
+ * the user's per-agent home directory at `~/.gaia/agents/<agent-id>/`. A
10
+ * `.seeded` sentinel file is written after a successful copy so subsequent
11
+ * launches skip the agent.
12
+ *
13
+ * Design invariants (see .claude/plans/bundle-path-contract.md):
14
+ * - Source: path.join(process.resourcesPath, "agents", "<id>")
15
+ * - Windows: <install>\resources\agents\<id>\
16
+ * - macOS: <Bundle>.app/Contents/Resources/agents/<id>/
17
+ * - Linux: /opt/<AppName>/resources/agents/<id>/
18
+ * - Target: path.join(os.homedir(), ".gaia", "agents", "<id>")
19
+ * - Sentinel: <target>/.seeded (exists → already seeded → skip)
20
+ *
21
+ * Write protocol (atomic-ish, crash-safe):
22
+ * 1. Remove any stale `<id>.partial/` sibling from a prior failed run.
23
+ * 2. Copy source → `<id>.partial/`.
24
+ * 3. `fs.renameSync(<id>.partial, <id>)` — atomic on the same filesystem.
25
+ * 4. Write `<id>/.seeded` last, so a partial seed never looks complete.
26
+ *
27
+ * Behaviour:
28
+ * - Target `<id>/` exists WITH `.seeded` → already seeded, skip.
29
+ * - Target `<id>/` exists WITHOUT `.seeded` → treat as user-owned data,
30
+ * log a warning, and skip (never clobber a hand-authored agent).
31
+ * - `process.resourcesPath` unset (dev / Jest) or source dir missing →
32
+ * empty result, no error.
33
+ * - Per-agent failures are isolated: they go into `errors[]` but do not
34
+ * stop the next agent from being seeded.
35
+ *
36
+ * Pure CommonJS. Only Node stdlib (fs / path / os). No Electron imports so
37
+ * the module is testable without spinning up Electron.
38
+ */
39
+
40
+ "use strict";
41
+
42
+ const fs = require("fs");
43
+ const path = require("path");
44
+ const os = require("os");
45
+
46
+ // ── Path helpers ─────────────────────────────────────────────────────────
47
+
48
+ function gaiaHome() {
49
+ return path.join(os.homedir(), ".gaia");
50
+ }
51
+
52
+ function agentsTargetRoot() {
53
+ return path.join(gaiaHome(), "agents");
54
+ }
55
+
56
+ function logsDir() {
57
+ return path.join(gaiaHome(), "logs");
58
+ }
59
+
60
+ function logFilePath() {
61
+ return path.join(logsDir(), "seeder.log");
62
+ }
63
+
64
+ // ── Logging ──────────────────────────────────────────────────────────────
65
+
66
+ function log(level, message) {
67
+ const line = `${new Date().toISOString()} [${level}] ${message}\n`;
68
+ try {
69
+ fs.mkdirSync(logsDir(), { recursive: true });
70
+ fs.appendFileSync(logFilePath(), line, { encoding: "utf8" });
71
+ } catch {
72
+ // If we cannot write the log, fall back to console so the message
73
+ // isn't lost entirely. We never let logging failure propagate.
74
+ }
75
+ // Also mirror to console so `electron .` tail-of-stdout users see it.
76
+ // eslint-disable-next-line no-console
77
+ const writer =
78
+ level === "ERROR" ? console.error : level === "WARN" ? console.warn : console.log;
79
+ writer(`[agent-seeder] ${message}`);
80
+ }
81
+
82
+ // ── Filesystem helpers ───────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Recursive copy using fs.cpSync when available (Node 16.7+), falling back
86
+ * to a hand-rolled recursive copy for older runtimes. Electron 40 ships
87
+ * Node 20, so cpSync is always present in production — but we keep the
88
+ * fallback for test environments that might mock cpSync.
89
+ */
90
+ function copyDirRecursive(src, dest) {
91
+ if (typeof fs.cpSync === "function") {
92
+ // dereference: true flattens symlinks into their targets rather than
93
+ // copying the symlink itself. This prevents a malicious or accidentally
94
+ // symlinked installer bundle from planting out-of-tree references in
95
+ // ~/.gaia/agents/<id>/.
96
+ fs.cpSync(src, dest, { recursive: true, errorOnExist: false, force: true, dereference: true });
97
+ return;
98
+ }
99
+ // Fallback path (shouldn't normally hit on Electron 40 / Node 20).
100
+ fs.mkdirSync(dest, { recursive: true });
101
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
102
+ const s = path.join(src, entry.name);
103
+ const d = path.join(dest, entry.name);
104
+ if (entry.isDirectory()) {
105
+ copyDirRecursive(s, d);
106
+ } else if (entry.isSymbolicLink()) {
107
+ // Skip symlinks in the fallback path for the same reason as dereference:true above.
108
+ log("WARN", `Skipping symlink in installer bundle: ${s}`);
109
+ } else {
110
+ fs.copyFileSync(s, d);
111
+ }
112
+ }
113
+ }
114
+
115
+ function rmDirRecursive(target) {
116
+ fs.rmSync(target, { recursive: true, force: true });
117
+ }
118
+
119
+ function isDirectory(p) {
120
+ try {
121
+ return fs.statSync(p).isDirectory();
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ // ── Seeding core ─────────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Seed a single agent directory. Returns a category string:
131
+ * "seeded" — copied successfully, sentinel written.
132
+ * "skipped" — already seeded or user-owned; left untouched.
133
+ * "error" — copy failed; partial data cleaned up (best effort).
134
+ *
135
+ * Throws only on programmer error. All IO errors are caught and logged.
136
+ */
137
+ function seedOneAgent(sourceDir, targetRoot, id) {
138
+ const src = path.join(sourceDir, id);
139
+ const target = path.join(targetRoot, id);
140
+ const partial = path.join(targetRoot, `${id}.partial`);
141
+ const sentinel = path.join(target, ".seeded");
142
+
143
+ // Already seeded?
144
+ if (fs.existsSync(sentinel)) {
145
+ log("INFO", `Skipping "${id}" — already seeded (sentinel present)`);
146
+ return { status: "skipped" };
147
+ }
148
+
149
+ // Target exists but no sentinel → user-owned data. Do not touch.
150
+ if (fs.existsSync(target)) {
151
+ log(
152
+ "WARN",
153
+ `Skipping "${id}" — target exists without .seeded sentinel ` +
154
+ `(treating as user-owned data): ${target}`
155
+ );
156
+ return { status: "skipped" };
157
+ }
158
+
159
+ // Verify the source is actually a directory before doing anything.
160
+ if (!isDirectory(src)) {
161
+ log("WARN", `Skipping "${id}" — source is not a directory: ${src}`);
162
+ return { status: "skipped" };
163
+ }
164
+
165
+ try {
166
+ // Clean up any leftover from a prior failed run.
167
+ if (fs.existsSync(partial)) {
168
+ log("INFO", `Removing stale partial directory for "${id}": ${partial}`);
169
+ rmDirRecursive(partial);
170
+ }
171
+
172
+ // Ensure the parent exists.
173
+ fs.mkdirSync(targetRoot, { recursive: true });
174
+
175
+ // Copy into sibling, then atomically rename.
176
+ copyDirRecursive(src, partial);
177
+ fs.renameSync(partial, target);
178
+
179
+ // Write sentinel LAST — its presence means "copy completed".
180
+ fs.writeFileSync(
181
+ sentinel,
182
+ JSON.stringify(
183
+ {
184
+ seededAt: new Date().toISOString(),
185
+ source: src,
186
+ },
187
+ null,
188
+ 2
189
+ ),
190
+ { encoding: "utf8" }
191
+ );
192
+
193
+ log("INFO", `Seeded "${id}" from ${src} to ${target}`);
194
+ return { status: "seeded" };
195
+ } catch (err) {
196
+ // Best-effort cleanup. If the rename already happened (partial no longer
197
+ // exists but target does and has no sentinel), remove target so the next
198
+ // launch retries cleanly instead of treating it as user-owned data.
199
+ try {
200
+ if (fs.existsSync(partial)) {
201
+ rmDirRecursive(partial);
202
+ } else if (fs.existsSync(target) && !fs.existsSync(sentinel)) {
203
+ rmDirRecursive(target);
204
+ }
205
+ } catch {
206
+ // ignore — original error is more important
207
+ }
208
+
209
+ log(
210
+ "ERROR",
211
+ `Failed to seed "${id}": ${err && err.message ? err.message : err}`
212
+ );
213
+ return { status: "error", error: err };
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Seed all bundled agents found under `<resourcesPath>/agents/`.
219
+ *
220
+ * Idempotent — safe to call on every app launch.
221
+ *
222
+ * @returns {Promise<{seeded: string[], skipped: string[], errors: {id: string, error: Error}[]}>}
223
+ */
224
+ async function seedBundledAgents() {
225
+ const result = { seeded: [], skipped: [], errors: [] };
226
+
227
+ // Guard against dev / test environments where resourcesPath is unset.
228
+ if (!process.resourcesPath) {
229
+ log(
230
+ "INFO",
231
+ "process.resourcesPath is undefined — skipping bundled-agent seeding"
232
+ );
233
+ return result;
234
+ }
235
+
236
+ const sourceDir = path.join(process.resourcesPath, "agents");
237
+
238
+ if (!fs.existsSync(sourceDir) || !isDirectory(sourceDir)) {
239
+ // Not an error — a build might simply ship without bundled agents.
240
+ // In a packaged Electron app the directory is expected to exist, so raise
241
+ // to WARN; in dev/test contexts leave it at INFO.
242
+ let isPackaged = false;
243
+ try {
244
+ isPackaged = require("electron").app?.isPackaged === true;
245
+ } catch (_) {
246
+ // not in an Electron context (tests, CLI)
247
+ }
248
+ log(
249
+ isPackaged ? "WARN" : "INFO",
250
+ `No bundled agents directory at ${sourceDir} — nothing to seed`
251
+ );
252
+ return result;
253
+ }
254
+
255
+ let entries;
256
+ try {
257
+ entries = fs.readdirSync(sourceDir, { withFileTypes: true });
258
+ } catch (err) {
259
+ log(
260
+ "ERROR",
261
+ `Failed to read bundled agents directory ${sourceDir}: ${
262
+ err && err.message ? err.message : err
263
+ }`
264
+ );
265
+ return result;
266
+ }
267
+
268
+ const targetRoot = agentsTargetRoot();
269
+
270
+ for (const entry of entries) {
271
+ if (!entry.isDirectory()) continue;
272
+ const id = entry.name;
273
+
274
+ const outcome = seedOneAgent(sourceDir, targetRoot, id);
275
+ if (outcome.status === "seeded") {
276
+ result.seeded.push(id);
277
+ } else if (outcome.status === "skipped") {
278
+ result.skipped.push(id);
279
+ } else {
280
+ result.errors.push({ id, error: outcome.error });
281
+ }
282
+ }
283
+
284
+ log(
285
+ "INFO",
286
+ `Seeding complete — seeded=${result.seeded.length} ` +
287
+ `skipped=${result.skipped.length} errors=${result.errors.length}`
288
+ );
289
+
290
+ return result;
291
+ }
292
+
293
+ module.exports = {
294
+ seedBundledAgents,
295
+ // Exposed for tests — do not rely on these from production code.
296
+ _internals: {
297
+ seedOneAgent,
298
+ agentsTargetRoot,
299
+ logFilePath,
300
+ },
301
+ };