aegis-desktop 0.3.0 → 0.4.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/README.md +61 -0
- package/lib/deep-link.js +83 -0
- package/lib/local/engine.js +398 -29
- package/lib/local/tools.js +257 -13
- package/lib/quick-launcher.js +81 -0
- package/lib/settings.js +76 -0
- package/lib/sync/sessions.js +27 -0
- package/lib/window-state.js +63 -0
- package/main.js +971 -32
- package/package.json +8 -4
- package/preload.js +121 -0
- package/renderer/app.js +533 -16
- package/renderer/index.html +49 -0
- package/renderer/markdown.js +165 -0
- package/renderer/quick.css +126 -0
- package/renderer/quick.html +39 -0
- package/renderer/quick.js +188 -0
- package/renderer/style.css +341 -0
- package/renderer/vendor/NOTICE.md +20 -0
- package/renderer/vendor/aegis-highlight.js +135 -0
- package/renderer/vendor/marked.umd.js +80 -0
- package/renderer/vendor/purify.min.js +3 -0
- package/vendor/aegis.js +34 -4
package/renderer/app.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
const aegis = window.aegis;
|
|
32
32
|
const models = window.models;
|
|
33
33
|
const sync = window.sync;
|
|
34
|
+
const quickLauncher = window.quickLauncher;
|
|
34
35
|
|
|
35
36
|
if (!aegis || !models) {
|
|
36
37
|
document.body.textContent =
|
|
@@ -45,6 +46,11 @@ if (!aegis || !models) {
|
|
|
45
46
|
// getters re-read the live DOM on each access, so boot order can never cause
|
|
46
47
|
// that failure mode.
|
|
47
48
|
const ELEMENT_IDS = {
|
|
49
|
+
updateBanner: 'update-banner',
|
|
50
|
+
updateBannerText: 'update-banner-text',
|
|
51
|
+
updateDownloadBtn: 'update-download-btn',
|
|
52
|
+
updateRestartBtn: 'update-restart-btn',
|
|
53
|
+
updateLaterBtn: 'update-later-btn',
|
|
48
54
|
connDot: 'conn-dot',
|
|
49
55
|
connText: 'conn-text',
|
|
50
56
|
app: 'st-app',
|
|
@@ -71,7 +77,14 @@ const ELEMENT_IDS = {
|
|
|
71
77
|
modelHint: 'model-hint',
|
|
72
78
|
settingsList: 'settings-list',
|
|
73
79
|
settingsHint: 'settings-hint',
|
|
80
|
+
quickLauncherEnabled: 'quick-launcher-enabled',
|
|
81
|
+
quickLauncherShortcut: 'quick-launcher-shortcut',
|
|
82
|
+
quickLauncherSave: 'quick-launcher-save',
|
|
83
|
+
quickLauncherHint: 'quick-launcher-hint',
|
|
84
|
+
confirmMode: 'confirm-mode-toggle',
|
|
85
|
+
confirmModeHint: 'confirm-mode-hint',
|
|
74
86
|
sessionsRefresh: 'sessions-refresh',
|
|
87
|
+
sessionsExport: 'sessions-export',
|
|
75
88
|
sessionsList: 'sessions-list',
|
|
76
89
|
sessionsHint: 'sessions-hint',
|
|
77
90
|
syncNow: 'sync-now',
|
|
@@ -160,6 +173,13 @@ const RESERVED_PROVIDERS = new Set(['__aegis', 'aegis']);
|
|
|
160
173
|
|
|
161
174
|
let pendingEl = null;
|
|
162
175
|
let pendingSessionId = null;
|
|
176
|
+
// The active thread's session id + prior turns. Both used to reset only on
|
|
177
|
+
// "New Chat" / opening a different session — NOT on every send() — so the
|
|
178
|
+
// model actually sees what was said earlier in the same open conversation
|
|
179
|
+
// instead of starting from a blank slate on every message (the root cause
|
|
180
|
+
// of "the model doesn't remember anything I just said").
|
|
181
|
+
let currentSessionId = null;
|
|
182
|
+
let threadMessages = [];
|
|
163
183
|
let classOptions = [];
|
|
164
184
|
let modelMeta = new Map(); // model id -> raw model object from listModels() (P2 §6.3 ceiling)
|
|
165
185
|
|
|
@@ -271,6 +291,55 @@ function renderStatus(s) {
|
|
|
271
291
|
setConn(s.keyConfigured, s.keyConfigured ? 'key configured' : 'no API key');
|
|
272
292
|
}
|
|
273
293
|
|
|
294
|
+
// -------------------------------------------------------------- auto-update
|
|
295
|
+
//
|
|
296
|
+
// Mirrors the state machine in desktop/main.js createUpdateManager: 'idle' /
|
|
297
|
+
// 'disabled' (dev build) / 'checking' / 'up-to-date' render nothing, since
|
|
298
|
+
// none of them need the user's attention. `updateDismissedFor` remembers the
|
|
299
|
+
// status the user last clicked "Later" on, so the banner stays gone for that
|
|
300
|
+
// status (an hourly re-check finding the SAME pending update shouldn't keep
|
|
301
|
+
// resurrecting a banner the user already dismissed) while still reappearing
|
|
302
|
+
// the moment the status actually advances (e.g. available -> downloaded).
|
|
303
|
+
let lastUpdateState = null;
|
|
304
|
+
let updateDismissedFor = null;
|
|
305
|
+
|
|
306
|
+
function renderUpdateBanner(state) {
|
|
307
|
+
if (!els.updateBanner) return;
|
|
308
|
+
lastUpdateState = state;
|
|
309
|
+
const status = state && state.status;
|
|
310
|
+
const silent = !status || status === 'idle' || status === 'disabled' ||
|
|
311
|
+
status === 'checking' || status === 'up-to-date';
|
|
312
|
+
if (silent || status === updateDismissedFor) {
|
|
313
|
+
els.updateBanner.hidden = true;
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let text = '';
|
|
318
|
+
let showDownload = false;
|
|
319
|
+
let showRestart = false;
|
|
320
|
+
const version = state.version ? `v${state.version} ` : '';
|
|
321
|
+
if (status === 'available') {
|
|
322
|
+
text = `Update ${version}available.`;
|
|
323
|
+
showDownload = true;
|
|
324
|
+
} else if (status === 'downloading') {
|
|
325
|
+
const pct = typeof state.progress === 'number' ? ` (${Math.round(state.progress)}%)` : '';
|
|
326
|
+
text = `Downloading update${pct}…`;
|
|
327
|
+
} else if (status === 'downloaded') {
|
|
328
|
+
text = `Update ${version}downloaded — restart to install.`;
|
|
329
|
+
showRestart = true;
|
|
330
|
+
} else if (status === 'error') {
|
|
331
|
+
text = `Update check failed: ${state.error || 'unknown error'}`;
|
|
332
|
+
} else {
|
|
333
|
+
els.updateBanner.hidden = true;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
els.updateBannerText.textContent = text;
|
|
338
|
+
els.updateDownloadBtn.hidden = !showDownload;
|
|
339
|
+
els.updateRestartBtn.hidden = !showRestart;
|
|
340
|
+
els.updateBanner.hidden = false;
|
|
341
|
+
}
|
|
342
|
+
|
|
274
343
|
async function loadAccountInfo() {
|
|
275
344
|
try {
|
|
276
345
|
const verify = await aegis.verifyApiKey();
|
|
@@ -344,6 +413,126 @@ async function verifyAegisKey() {
|
|
|
344
413
|
}
|
|
345
414
|
}
|
|
346
415
|
|
|
416
|
+
// --------------------------------------------------------- quick launcher
|
|
417
|
+
|
|
418
|
+
/** Render a `{ enabled, shortcut, packaged, active, reason }` status (see
|
|
419
|
+
* main.js createQuickLauncherDispatch) into the settings card. */
|
|
420
|
+
function renderQuickLauncherStatus(status) {
|
|
421
|
+
if (!status) return;
|
|
422
|
+
els.quickLauncherEnabled.checked = Boolean(status.enabled);
|
|
423
|
+
els.quickLauncherShortcut.value = status.shortcut || '';
|
|
424
|
+
els.quickLauncherShortcut.placeholder = status.shortcut || 'CmdOrCtrl+Shift+Space';
|
|
425
|
+
const bits = [];
|
|
426
|
+
if (status.packaged) bits.push('always on in this packaged build');
|
|
427
|
+
bits.push(status.active ? `active — press ${status.shortcut} anywhere` : 'inactive');
|
|
428
|
+
if (status.reason) bits.push(status.reason);
|
|
429
|
+
els.quickLauncherHint.textContent = bits.join(' · ');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function loadQuickLauncherSettings() {
|
|
433
|
+
if (!quickLauncher) return;
|
|
434
|
+
try {
|
|
435
|
+
renderQuickLauncherStatus(await quickLauncher.status());
|
|
436
|
+
} catch (err) {
|
|
437
|
+
els.quickLauncherHint.textContent =
|
|
438
|
+
`status failed: ${err && err.message ? err.message : err}`;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function saveQuickLauncherSettings() {
|
|
443
|
+
if (!quickLauncher) return;
|
|
444
|
+
els.quickLauncherSave.disabled = true;
|
|
445
|
+
els.quickLauncherHint.textContent = 'saving…';
|
|
446
|
+
try {
|
|
447
|
+
renderQuickLauncherStatus(
|
|
448
|
+
await quickLauncher.setConfig({
|
|
449
|
+
enabled: els.quickLauncherEnabled.checked,
|
|
450
|
+
shortcut: els.quickLauncherShortcut.value.trim(),
|
|
451
|
+
})
|
|
452
|
+
);
|
|
453
|
+
} catch (err) {
|
|
454
|
+
els.quickLauncherHint.textContent =
|
|
455
|
+
`save failed: ${err && err.message ? err.message : err}`;
|
|
456
|
+
} finally {
|
|
457
|
+
els.quickLauncherSave.disabled = false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ------------------------------------------------------ tool approvals
|
|
462
|
+
//
|
|
463
|
+
// "Confirm before running tools" — the user-facing ON/OFF switch for the
|
|
464
|
+
// engine's tool-call approval gate (desktop/lib/local/engine.js
|
|
465
|
+
// gatedExecuteTool; persisted by same-named IPC methods on
|
|
466
|
+
// createConfirmModeDispatch). ON (the default, and the behaviour every
|
|
467
|
+
// existing install has) previews exec/writeFile/editFile and asks before they
|
|
468
|
+
// run; OFF runs them straight through, exactly like a tool already allowed for
|
|
469
|
+
// the session — no approval card at all. The engine reads the flag per tool
|
|
470
|
+
// call, so a flip here applies to the next call in flight, no restart.
|
|
471
|
+
|
|
472
|
+
/** Paint a `{ enabled }` payload (from aegis.getConfirmMode/setConfirmMode)
|
|
473
|
+
* into the card, and say plainly what the current state means. */
|
|
474
|
+
function renderConfirmMode(status) {
|
|
475
|
+
if (!els.confirmMode || !status) return;
|
|
476
|
+
const enabled = status.enabled !== false;
|
|
477
|
+
els.confirmMode.checked = enabled;
|
|
478
|
+
els.confirmModeHint.textContent = enabled
|
|
479
|
+
? 'On — exec, writeFile and editFile ask for your approval before they run.'
|
|
480
|
+
: 'Off — the agent runs exec, writeFile and editFile without asking.';
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Read the persisted value. Called on boot (the Settings pane is a single
|
|
484
|
+
* always-visible column, so that is "when Settings opens") and again on every
|
|
485
|
+
* change via saveConfirmMode below. */
|
|
486
|
+
async function loadConfirmMode() {
|
|
487
|
+
if (!els.confirmMode || !aegis.getConfirmMode) return;
|
|
488
|
+
try {
|
|
489
|
+
renderConfirmMode(await aegis.getConfirmMode());
|
|
490
|
+
} catch (err) {
|
|
491
|
+
els.confirmModeHint.textContent =
|
|
492
|
+
`status failed: ${err && err.message ? err.message : err}`;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Persist a flip of the switch. The card is only repainted from what the
|
|
497
|
+
* main process actually stored, so a failed write leaves the switch showing
|
|
498
|
+
* the truth rather than the click. */
|
|
499
|
+
async function saveConfirmMode(enabled) {
|
|
500
|
+
if (!els.confirmMode) return;
|
|
501
|
+
els.confirmMode.disabled = true;
|
|
502
|
+
try {
|
|
503
|
+
renderConfirmMode(await aegis.setConfirmMode(enabled));
|
|
504
|
+
} catch (err) {
|
|
505
|
+
els.confirmMode.checked = !enabled;
|
|
506
|
+
els.confirmModeHint.textContent =
|
|
507
|
+
`save failed: ${err && err.message ? err.message : err}`;
|
|
508
|
+
} finally {
|
|
509
|
+
els.confirmMode.disabled = false;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Land a quick-launcher answer (main.js QUICK_LAUNCHER_PUSH_CHANNEL, see
|
|
515
|
+
* preload.js onQuickLauncherPush) as a real turn in the open thread — same
|
|
516
|
+
* "renderer owns the state, main.js just pings" split every other menu
|
|
517
|
+
* channel in this file uses (onMenuNewChat, onMenuSearch, …). Starts a fresh
|
|
518
|
+
* thread first if none is open yet, exactly like send() does for a first
|
|
519
|
+
* message, then persists both turns via sync.append — the same call send()
|
|
520
|
+
* makes — so the pushed Q&A survives exactly like one typed in this window.
|
|
521
|
+
*/
|
|
522
|
+
function handleQuickLauncherPush(payload) {
|
|
523
|
+
const prompt = payload && payload.prompt;
|
|
524
|
+
const response = payload && payload.response;
|
|
525
|
+
if (!prompt || !response) return;
|
|
526
|
+
if (!currentSessionId) currentSessionId = newSessionId();
|
|
527
|
+
const sessionId = currentSessionId;
|
|
528
|
+
addMessage('user', prompt);
|
|
529
|
+
addMessage('assistant', response, undefined, sessionId);
|
|
530
|
+
threadMessages.push({ role: 'user', content: prompt });
|
|
531
|
+
threadMessages.push({ role: 'assistant', content: response });
|
|
532
|
+
sync.append(sessionId, { role: 'user', content: prompt }).catch(() => {});
|
|
533
|
+
sync.append(sessionId, { role: 'assistant', content: response }).catch(() => {});
|
|
534
|
+
}
|
|
535
|
+
|
|
347
536
|
// ----------------------------------------------------------------- memory
|
|
348
537
|
//
|
|
349
538
|
// Two surfaces read the same endpoint:
|
|
@@ -444,13 +633,17 @@ async function fetchMemory(query, limit) {
|
|
|
444
633
|
// `{ entries: [], upgrade }` instead. Checking the resolved payload first
|
|
445
634
|
// is what makes the upgrade UI reachable at all — the catch below only
|
|
446
635
|
// covers a non-IPC caller that still throws the raw client error.
|
|
636
|
+
// An over-quota account arrives as a SUCCESS with `upgrade` attached (reads
|
|
637
|
+
// are served over quota — main.js quotaFromPayload), so the entries must
|
|
638
|
+
// render alongside the notice. Returning `{entries: []}` here is only right
|
|
639
|
+
// for the 402 path, where there is genuinely nothing to show.
|
|
640
|
+
const raw = data && (data.entries || data.results);
|
|
641
|
+
const entries = Array.isArray(raw) ? raw.map(normalizeMemoryEntry) : [];
|
|
447
642
|
if (data && data.upgrade) {
|
|
448
|
-
return { entries
|
|
643
|
+
return { entries, error: '', upgrade: data.upgrade };
|
|
449
644
|
}
|
|
450
645
|
// `entries` is the real key. Keep the `results` fallback only so an older
|
|
451
646
|
// backend that still sends it degrades to a working list, not an empty one.
|
|
452
|
-
const raw = data && (data.entries || data.results);
|
|
453
|
-
const entries = Array.isArray(raw) ? raw.map(normalizeMemoryEntry) : [];
|
|
454
647
|
return { entries, error: '', upgrade: null };
|
|
455
648
|
} catch (err) {
|
|
456
649
|
const status = err && err.status;
|
|
@@ -461,8 +654,8 @@ async function fetchMemory(query, limit) {
|
|
|
461
654
|
error: '',
|
|
462
655
|
upgrade: {
|
|
463
656
|
url: (err.data && err.data.upgradeUrl) || 'https://aegiscloud.org/subscribe',
|
|
464
|
-
used: err.data && err.data.sessionsUsed,
|
|
465
|
-
limit: err.data && err.data.freeSessionLimit,
|
|
657
|
+
used: err.data && (err.data.tokensUsed != null ? err.data.tokensUsed : err.data.sessionsUsed),
|
|
658
|
+
limit: err.data && (err.data.tokenLimit != null ? err.data.tokenLimit : err.data.freeSessionLimit),
|
|
466
659
|
},
|
|
467
660
|
};
|
|
468
661
|
}
|
|
@@ -479,12 +672,24 @@ async function fetchMemory(query, limit) {
|
|
|
479
672
|
* inspector's block. The hint elements are bare <p>s, so the subscribe link
|
|
480
673
|
* has to be a real child node — a plain text assignment would wipe it, and an
|
|
481
674
|
* href left in text is not clickable. */
|
|
675
|
+
/** Human token count for the sync quota: 10000000 -> "10M", 42800 -> "43k".
|
|
676
|
+
* The quota is denominated in tokens (aegis1 FREE_SYNC_TOKENS/PRO_SYNC_TOKENS),
|
|
677
|
+
* which for stored prose is about one per character — rendering the raw
|
|
678
|
+
* integer ("10000000") tells a user nothing at a glance. */
|
|
679
|
+
function formatTokens(n) {
|
|
680
|
+
if (n == null || isNaN(n)) return '?';
|
|
681
|
+
const v = Number(n);
|
|
682
|
+
if (v >= 1e6) return `${(v / 1e6).toFixed(v % 1e6 === 0 ? 0 : 1)}M`;
|
|
683
|
+
if (v >= 1e4) return `${Math.round(v / 1e3)}k`;
|
|
684
|
+
return v.toLocaleString('en-US');
|
|
685
|
+
}
|
|
686
|
+
|
|
482
687
|
function capNotice(el, upgrade, prefix, cta) {
|
|
483
688
|
if (!el) return;
|
|
484
|
-
const used = upgrade.used != null ? upgrade.used : '?';
|
|
485
|
-
const cap = upgrade.limit != null ? upgrade.limit : '?';
|
|
689
|
+
const used = upgrade.used != null ? formatTokens(upgrade.used) : '?';
|
|
690
|
+
const cap = upgrade.limit != null ? formatTokens(upgrade.limit) : '?';
|
|
486
691
|
el.textContent =
|
|
487
|
-
`${prefix || '
|
|
692
|
+
`${prefix || 'sync limit reached'} — ${used} of ${cap} tokens synced. ` +
|
|
488
693
|
'Nothing was lost. ';
|
|
489
694
|
const a = document.createElement('a');
|
|
490
695
|
a.href = upgrade.url || 'https://aegiscloud.org/subscribe';
|
|
@@ -683,11 +888,11 @@ function renderMemoryOverlay() {
|
|
|
683
888
|
const h = document.createElement('strong');
|
|
684
889
|
h.textContent = 'Cloud memory is paused on the free plan';
|
|
685
890
|
const p = document.createElement('span');
|
|
686
|
-
const used = memoryView.upgrade.used != null ? memoryView.upgrade.used : '?';
|
|
687
|
-
const cap = memoryView.upgrade.limit != null ? memoryView.upgrade.limit : '?';
|
|
891
|
+
const used = memoryView.upgrade.used != null ? formatTokens(memoryView.upgrade.used) : '?';
|
|
892
|
+
const cap = memoryView.upgrade.limit != null ? formatTokens(memoryView.upgrade.limit) : '?';
|
|
688
893
|
p.textContent =
|
|
689
|
-
`This account has used ${used} of ${cap}
|
|
690
|
-
'
|
|
894
|
+
`This account has used ${used} of ${cap} tokens of synced conversation. ` +
|
|
895
|
+
'Saved memory is still readable — only new saves are paused until there is room again.';
|
|
691
896
|
const a = document.createElement('a');
|
|
692
897
|
a.href = memoryView.upgrade.url;
|
|
693
898
|
a.target = '_blank';
|
|
@@ -922,6 +1127,26 @@ function hideWelcome() {
|
|
|
922
1127
|
if (w) w.remove();
|
|
923
1128
|
}
|
|
924
1129
|
|
|
1130
|
+
// Assistant text is rendered as sanitized markdown (headings, lists, links,
|
|
1131
|
+
// highlighted fenced code with a copy button — see renderer/markdown.js);
|
|
1132
|
+
// user text always stays plain via textContent, and this is the only place
|
|
1133
|
+
// that decides which one a role gets. AegisMarkdown.renderInto() itself
|
|
1134
|
+
// falls back to textContent if marked/DOMPurify failed to load, so this
|
|
1135
|
+
// never risks putting raw model output into innerHTML.
|
|
1136
|
+
function renderMessageBody(bodyEl, role, text) {
|
|
1137
|
+
if (role === 'assistant' && window.AegisMarkdown) {
|
|
1138
|
+
// Rendered markdown supplies its own block spacing (marked emits real
|
|
1139
|
+
// <p>/<pre>/<li> elements); the plain-text `white-space: pre-wrap` on
|
|
1140
|
+
// .body/.flow-body would otherwise turn the newlines *between* those
|
|
1141
|
+
// tags into extra visible blank lines. md-body opts back to normal flow.
|
|
1142
|
+
bodyEl.classList.add('md-body');
|
|
1143
|
+
window.AegisMarkdown.renderInto(bodyEl, text);
|
|
1144
|
+
} else {
|
|
1145
|
+
bodyEl.classList.remove('md-body');
|
|
1146
|
+
bodyEl.textContent = text;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
925
1150
|
// `sessionId`, when given for an assistant message, renders a "copy" button
|
|
926
1151
|
// that puts the message text on the clipboard. `toolLog` (assistant only) is
|
|
927
1152
|
// the turn's collected `{name, args, ok}` tool calls, rendered above the
|
|
@@ -939,7 +1164,7 @@ function addMessage(role, text, meta, sessionId, toolLog) {
|
|
|
939
1164
|
|
|
940
1165
|
const body = document.createElement('div');
|
|
941
1166
|
body.className = 'body';
|
|
942
|
-
body
|
|
1167
|
+
renderMessageBody(body, role, text);
|
|
943
1168
|
|
|
944
1169
|
row.appendChild(who);
|
|
945
1170
|
if (Array.isArray(toolLog) && toolLog.length) {
|
|
@@ -1065,6 +1290,14 @@ async function spawnPath(card, spec) {
|
|
|
1065
1290
|
|
|
1066
1291
|
let streamed = '';
|
|
1067
1292
|
const onDelta = (chunk) => {
|
|
1293
|
+
if (chunk && chunk.approval) {
|
|
1294
|
+
if (card.classList.contains('pending')) {
|
|
1295
|
+
card.classList.remove('pending');
|
|
1296
|
+
state.textContent = 'needs approval';
|
|
1297
|
+
}
|
|
1298
|
+
renderApprovalCard(card, chunk.approval, '.flow-body');
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1068
1301
|
if (chunk && chunk.tool) {
|
|
1069
1302
|
if (card.classList.contains('pending')) {
|
|
1070
1303
|
card.classList.remove('pending');
|
|
@@ -1100,6 +1333,12 @@ async function spawnPath(card, spec) {
|
|
|
1100
1333
|
prompt: `Original request:\n${spec.prompt}\n\n${spec.path.hint}`,
|
|
1101
1334
|
model: spec.model,
|
|
1102
1335
|
maxTokens: Math.min(spec.maxTokens || 1024, 1024),
|
|
1336
|
+
// A single-shot summariser: one lead line and 3-5 bullets, no tools.
|
|
1337
|
+
// Without this the engine puts the agent loop (and its tool schemas)
|
|
1338
|
+
// behind a 1024-token budget, and a turn that comes back with neither
|
|
1339
|
+
// text nor a tool call would trigger the empty-turn recovery — an
|
|
1340
|
+
// extra dispatch this lane has no gathered context to justify.
|
|
1341
|
+
tools: false,
|
|
1103
1342
|
sessionId: id,
|
|
1104
1343
|
},
|
|
1105
1344
|
onDelta
|
|
@@ -1108,7 +1347,7 @@ async function spawnPath(card, spec) {
|
|
|
1108
1347
|
const choice = (data && data.choices && data.choices[0]) || {};
|
|
1109
1348
|
const text =
|
|
1110
1349
|
(choice.message && choice.message.content) || streamed || '(no path found)';
|
|
1111
|
-
body
|
|
1350
|
+
renderMessageBody(body, 'assistant', text);
|
|
1112
1351
|
card.classList.remove('pending');
|
|
1113
1352
|
card.classList.add('done');
|
|
1114
1353
|
state.textContent = 'done';
|
|
@@ -1246,6 +1485,128 @@ function appendToolActivity(row, tool, containerClass, beforeSelector) {
|
|
|
1246
1485
|
return toolsEl;
|
|
1247
1486
|
}
|
|
1248
1487
|
|
|
1488
|
+
/**
|
|
1489
|
+
* Lazily create a message row's extended-reasoning block and return it.
|
|
1490
|
+
*
|
|
1491
|
+
* Only pooled brain turns emit `reasoning` deltas (the "work autonomously"
|
|
1492
|
+
* fan-out's worker findings), so this block exists for AEGIS Cloud autonomous
|
|
1493
|
+
* turns and nowhere else. It sits ABOVE `.body` — deliberation first, then the
|
|
1494
|
+
* answer the synthesis pass writes — and is transient UI: it is never pushed
|
|
1495
|
+
* into the thread history, so it cannot leak back into the model's context.
|
|
1496
|
+
*/
|
|
1497
|
+
function ensureReasoningEl(row) {
|
|
1498
|
+
let el = row.querySelector('.reasoning');
|
|
1499
|
+
if (!el) {
|
|
1500
|
+
el = document.createElement('div');
|
|
1501
|
+
el.className = 'reasoning';
|
|
1502
|
+
const body = row.querySelector('.body');
|
|
1503
|
+
if (body) row.insertBefore(el, body);
|
|
1504
|
+
else row.appendChild(el);
|
|
1505
|
+
}
|
|
1506
|
+
return el;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/** The one-line summary shown above an approval card's diff (or alone, for
|
|
1510
|
+
* exec, which has none). */
|
|
1511
|
+
function approvalSummary(tool, args) {
|
|
1512
|
+
const a = args || {};
|
|
1513
|
+
if (tool === 'exec') return a.description ? `${a.command} — ${a.description}` : a.command || '';
|
|
1514
|
+
return a.file_path || '';
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* Render one +/- colored line of a unified diff. `@@` hunk headers and the
|
|
1519
|
+
* `---`/`+++` file headers get their own class; everything else falls back
|
|
1520
|
+
* to a plain context line.
|
|
1521
|
+
*/
|
|
1522
|
+
function diffLineClass(line) {
|
|
1523
|
+
if (line.startsWith('@@')) return 'diff-hunk';
|
|
1524
|
+
if (line.startsWith('+++') || line.startsWith('---')) return 'diff-file';
|
|
1525
|
+
if (line.startsWith('+')) return 'diff-add';
|
|
1526
|
+
if (line.startsWith('-')) return 'diff-del';
|
|
1527
|
+
return 'diff-ctx';
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* Render one tool-call approval card (`onDelta`'s `{ approval: {id, tool,
|
|
1532
|
+
* args, diff} }` chunk — see desktop/lib/local/engine.js requestApproval)
|
|
1533
|
+
* into `row`, before it runs. The three buttons resolve the main process's
|
|
1534
|
+
* pending promise via models.respondApproval; the card disables itself the
|
|
1535
|
+
* instant one is clicked so a double-click can't send two decisions for the
|
|
1536
|
+
* same id.
|
|
1537
|
+
*/
|
|
1538
|
+
function renderApprovalCard(row, approval, beforeSelector) {
|
|
1539
|
+
if (!row || !approval) return;
|
|
1540
|
+
|
|
1541
|
+
const card = document.createElement('div');
|
|
1542
|
+
card.className = 'approval-card';
|
|
1543
|
+
card.dataset.approvalId = approval.id;
|
|
1544
|
+
|
|
1545
|
+
const title = document.createElement('div');
|
|
1546
|
+
title.className = 'approval-title';
|
|
1547
|
+
title.textContent = `${approval.tool} wants to run — review before it executes`;
|
|
1548
|
+
card.appendChild(title);
|
|
1549
|
+
|
|
1550
|
+
const summary = approvalSummary(approval.tool, approval.args);
|
|
1551
|
+
if (summary) {
|
|
1552
|
+
const summaryEl = document.createElement('div');
|
|
1553
|
+
summaryEl.className = 'approval-summary';
|
|
1554
|
+
summaryEl.textContent = summary;
|
|
1555
|
+
card.appendChild(summaryEl);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
if (approval.diff) {
|
|
1559
|
+
const diffEl = document.createElement('pre');
|
|
1560
|
+
diffEl.className = 'approval-diff';
|
|
1561
|
+
for (const line of approval.diff.split('\n')) {
|
|
1562
|
+
const lineEl = document.createElement('div');
|
|
1563
|
+
lineEl.className = diffLineClass(line);
|
|
1564
|
+
lineEl.textContent = line;
|
|
1565
|
+
diffEl.appendChild(lineEl);
|
|
1566
|
+
}
|
|
1567
|
+
card.appendChild(diffEl);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
const actions = document.createElement('div');
|
|
1571
|
+
actions.className = 'approval-actions';
|
|
1572
|
+
|
|
1573
|
+
const decide = (decision) => {
|
|
1574
|
+
for (const btn of actions.querySelectorAll('button')) btn.disabled = true;
|
|
1575
|
+
card.classList.add('resolved');
|
|
1576
|
+
const tag = document.createElement('div');
|
|
1577
|
+
tag.className = 'approval-decision';
|
|
1578
|
+
tag.textContent =
|
|
1579
|
+
decision === 'deny' ? 'Denied' : decision === 'session' ? 'Allowed for this session' : 'Allowed once';
|
|
1580
|
+
card.appendChild(tag);
|
|
1581
|
+
models.respondApproval(approval.id, decision);
|
|
1582
|
+
};
|
|
1583
|
+
|
|
1584
|
+
const mkButton = (label, cls, decision) => {
|
|
1585
|
+
const btn = document.createElement('button');
|
|
1586
|
+
btn.type = 'button';
|
|
1587
|
+
btn.className = `approval-btn ${cls}`;
|
|
1588
|
+
btn.textContent = label;
|
|
1589
|
+
btn.addEventListener('click', () => decide(decision));
|
|
1590
|
+
return btn;
|
|
1591
|
+
};
|
|
1592
|
+
|
|
1593
|
+
actions.appendChild(mkButton('Allow once', 'allow', 'once'));
|
|
1594
|
+
actions.appendChild(mkButton('Allow for this session', 'allow-session', 'session'));
|
|
1595
|
+
actions.appendChild(mkButton('Deny', 'deny', 'deny'));
|
|
1596
|
+
card.appendChild(actions);
|
|
1597
|
+
|
|
1598
|
+
let slot = row.querySelector('.approval-slot');
|
|
1599
|
+
if (!slot) {
|
|
1600
|
+
slot = document.createElement('div');
|
|
1601
|
+
slot.className = 'approval-slot';
|
|
1602
|
+
const before = beforeSelector ? row.querySelector(beforeSelector) : null;
|
|
1603
|
+
if (before) row.insertBefore(slot, before);
|
|
1604
|
+
else row.appendChild(slot);
|
|
1605
|
+
}
|
|
1606
|
+
slot.appendChild(card);
|
|
1607
|
+
return card;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1249
1610
|
function classLabel(cls) {
|
|
1250
1611
|
const found = classOptions.find((c) => c.class === cls);
|
|
1251
1612
|
return found ? found.label : cls;
|
|
@@ -1703,6 +2064,13 @@ function openSession(id) {
|
|
|
1703
2064
|
: 'user';
|
|
1704
2065
|
addMessage(role, m.content || m.text || '', undefined, role === 'assistant' ? s.id : undefined);
|
|
1705
2066
|
}
|
|
2067
|
+
// Resuming a past conversation must resume its context too, not just
|
|
2068
|
+
// its on-screen transcript — continuing it as sessionId reuses the same
|
|
2069
|
+
// id and threadMessages carries the prior turns into the next send().
|
|
2070
|
+
currentSessionId = s.id;
|
|
2071
|
+
threadMessages = msgs
|
|
2072
|
+
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
|
2073
|
+
.map((m) => ({ role: m.role, content: m.content || m.text || '' }));
|
|
1706
2074
|
els.sessionsHint.textContent = `opened ${s.id.slice(0, 8)}…`;
|
|
1707
2075
|
})
|
|
1708
2076
|
.catch((err) => {
|
|
@@ -1711,15 +2079,73 @@ function openSession(id) {
|
|
|
1711
2079
|
});
|
|
1712
2080
|
}
|
|
1713
2081
|
|
|
2082
|
+
/**
|
|
2083
|
+
* Export the open thread to a user-picked file — Markdown or JSON, both
|
|
2084
|
+
* serialized in main.js from the same lib/sync/sessions.js record `sync.*`
|
|
2085
|
+
* already uses as the source of truth. One code path for all three entry
|
|
2086
|
+
* points: the sidebar's Export button and both File-menu items (see boot()
|
|
2087
|
+
* wiring below) call this with the format they want.
|
|
2088
|
+
*/
|
|
2089
|
+
function exportSession(format) {
|
|
2090
|
+
const id = currentSessionId;
|
|
2091
|
+
if (!id) {
|
|
2092
|
+
els.sessionsHint.textContent = 'no open session to export';
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
aegis.exportSession(id, format)
|
|
2096
|
+
.then((result) => {
|
|
2097
|
+
if (result && result.ok) {
|
|
2098
|
+
els.sessionsHint.textContent = `exported to ${result.filePath}`;
|
|
2099
|
+
} else if (!result || !result.canceled) {
|
|
2100
|
+
els.sessionsHint.textContent =
|
|
2101
|
+
`export failed: ${(result && result.reason) || 'unknown error'}`;
|
|
2102
|
+
}
|
|
2103
|
+
})
|
|
2104
|
+
.catch((err) => {
|
|
2105
|
+
els.sessionsHint.textContent =
|
|
2106
|
+
`export failed: ${err && err.message ? err.message : err}`;
|
|
2107
|
+
});
|
|
2108
|
+
}
|
|
2109
|
+
|
|
1714
2110
|
function newChat() {
|
|
1715
2111
|
abortBranches();
|
|
2112
|
+
// A fresh thread must never inherit the outgoing one's "allow for this
|
|
2113
|
+
// session" tool grants (desktop/lib/local/engine.js sessionAllowlists is
|
|
2114
|
+
// keyed by this exact id) — best-effort, never blocks starting the chat.
|
|
2115
|
+
if (currentSessionId) {
|
|
2116
|
+
models.clearApprovals(currentSessionId).catch(() => {});
|
|
2117
|
+
}
|
|
1716
2118
|
renderWelcome();
|
|
1717
2119
|
els.sessionsHint.textContent = '';
|
|
1718
2120
|
pendingEl = null;
|
|
1719
2121
|
pendingSessionId = null;
|
|
2122
|
+
currentSessionId = null;
|
|
2123
|
+
threadMessages = [];
|
|
1720
2124
|
flowCount = 0;
|
|
1721
2125
|
}
|
|
1722
2126
|
|
|
2127
|
+
/**
|
|
2128
|
+
* Route a resolved aegis:// link (main.js DEEP_LINK_CHANNEL, see
|
|
2129
|
+
* preload.js onDeepLink) into the UI: `open` jumps straight to the named
|
|
2130
|
+
* session; `new` starts a fresh thread with the prompt prefilled in the
|
|
2131
|
+
* composer — prefilled, not auto-sent, so the user still confirms before
|
|
2132
|
+
* anything reaches a model.
|
|
2133
|
+
*/
|
|
2134
|
+
function handleDeepLink(parsed) {
|
|
2135
|
+
if (!parsed) return;
|
|
2136
|
+
if (parsed.action === 'open' && parsed.sessionId) {
|
|
2137
|
+
openSession(parsed.sessionId);
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
if (parsed.action === 'new') {
|
|
2141
|
+
newChat();
|
|
2142
|
+
if (parsed.prompt) {
|
|
2143
|
+
els.prompt.value = parsed.prompt;
|
|
2144
|
+
els.prompt.focus();
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
|
|
1723
2149
|
// ------------------------------------------------------------------ actions
|
|
1724
2150
|
|
|
1725
2151
|
async function send() {
|
|
@@ -1750,9 +2176,19 @@ async function send() {
|
|
|
1750
2176
|
// clamping this feeds.
|
|
1751
2177
|
const effort = autonomous ? els.autonomousEffort.value : undefined;
|
|
1752
2178
|
const workers = autonomous ? parseInt(els.autonomousWorkers.value, 10) || undefined : undefined;
|
|
1753
|
-
|
|
2179
|
+
// Reuse the open thread's session id (minted once, on its first message)
|
|
2180
|
+
// instead of a fresh one per send — a new id every turn is what made both
|
|
2181
|
+
// the local `messages` history below and the cloud class's server-side
|
|
2182
|
+
// session memory reset on every single message.
|
|
2183
|
+
if (!currentSessionId) currentSessionId = newSessionId();
|
|
2184
|
+
const sessionId = currentSessionId;
|
|
1754
2185
|
pendingSessionId = sessionId;
|
|
1755
2186
|
|
|
2187
|
+
// Snapshot prior turns for the model — the new prompt travels separately
|
|
2188
|
+
// as `prompt` and providers.js appends it after `messages` on the wire.
|
|
2189
|
+
const historyForModel = threadMessages.slice();
|
|
2190
|
+
threadMessages.push({ role: 'user', content: prompt });
|
|
2191
|
+
|
|
1756
2192
|
setBusy(true, { cancellable: true });
|
|
1757
2193
|
|
|
1758
2194
|
// Persist the user turn locally (best-effort — never blocks chat).
|
|
@@ -1763,8 +2199,29 @@ async function send() {
|
|
|
1763
2199
|
}
|
|
1764
2200
|
|
|
1765
2201
|
let streamedText = '';
|
|
2202
|
+
let reasoningText = '';
|
|
1766
2203
|
const toolLog = [];
|
|
1767
2204
|
const onDelta = (chunk) => {
|
|
2205
|
+
// Extended-reasoning trace from a pooled brain turn: the fan-out's worker
|
|
2206
|
+
// findings, streamed before the synthesis pass writes the answer. Shown so
|
|
2207
|
+
// "work autonomously" doesn't look idle for the whole worker phase.
|
|
2208
|
+
if (chunk && typeof chunk.reasoning === 'string' && chunk.reasoning) {
|
|
2209
|
+
reasoningText += chunk.reasoning;
|
|
2210
|
+
if (pendingEl) {
|
|
2211
|
+
pendingEl.classList.remove('pending');
|
|
2212
|
+
ensureReasoningEl(pendingEl).textContent = reasoningText;
|
|
2213
|
+
els.messages.scrollTop = els.messages.scrollHeight;
|
|
2214
|
+
}
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
if (chunk && chunk.approval) {
|
|
2218
|
+
if (pendingEl) {
|
|
2219
|
+
pendingEl.classList.remove('pending');
|
|
2220
|
+
renderApprovalCard(pendingEl, chunk.approval, '.body');
|
|
2221
|
+
els.messages.scrollTop = els.messages.scrollHeight;
|
|
2222
|
+
}
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
1768
2225
|
if (chunk && chunk.tool) {
|
|
1769
2226
|
toolLog.push(chunk.tool);
|
|
1770
2227
|
if (pendingEl) {
|
|
@@ -1789,7 +2246,7 @@ async function send() {
|
|
|
1789
2246
|
// All four classes route through the model: surface (the main process
|
|
1790
2247
|
// decides transport — cloud client, ollama, or a direct provider).
|
|
1791
2248
|
const data = await models.chat(
|
|
1792
|
-
{ class: cls, prompt, model, maxTokens, sessionId, autonomous, effort, workers },
|
|
2249
|
+
{ class: cls, prompt, model, maxTokens, sessionId, autonomous, effort, workers, messages: historyForModel },
|
|
1793
2250
|
onDelta
|
|
1794
2251
|
);
|
|
1795
2252
|
|
|
@@ -1798,6 +2255,7 @@ async function send() {
|
|
|
1798
2255
|
(choice.message && choice.message.content) ||
|
|
1799
2256
|
streamedText ||
|
|
1800
2257
|
'(empty response)';
|
|
2258
|
+
threadMessages.push({ role: 'assistant', content: text });
|
|
1801
2259
|
const bits = [];
|
|
1802
2260
|
if (data && data.model) bits.push(`model: ${data.model}`);
|
|
1803
2261
|
else if (model) bits.push(`model: ${model}`);
|
|
@@ -1908,8 +2366,26 @@ async function init() {
|
|
|
1908
2366
|
});
|
|
1909
2367
|
|
|
1910
2368
|
els.newChat.addEventListener('click', newChat);
|
|
2369
|
+
// Native File > New Chat (Cmd/Ctrl+N) and View > Search (Cmd/Ctrl+K) —
|
|
2370
|
+
// main.js's application menu has no renderer state of its own, so it just
|
|
2371
|
+
// pings these channels (see preload.js onMenuNewChat/onMenuSearch).
|
|
2372
|
+
if (aegis.onMenuNewChat) aegis.onMenuNewChat(newChat);
|
|
2373
|
+
if (aegis.onMenuSearch) aegis.onMenuSearch(openMemoryOverlay);
|
|
2374
|
+
// File > Save as… / Export Session… — same "menu pings, renderer acts"
|
|
2375
|
+
// pattern as New Chat/Search above; both call exportSession() with the
|
|
2376
|
+
// format the menu label promised (see main.js buildAppMenu).
|
|
2377
|
+
if (aegis.onMenuExportMarkdown) aegis.onMenuExportMarkdown(() => exportSession('markdown'));
|
|
2378
|
+
if (aegis.onMenuExportJson) aegis.onMenuExportJson(() => exportSession('json'));
|
|
2379
|
+
// aegis:// deep link (main.js sendDeepLinkToWindow) — delivered once the
|
|
2380
|
+
// window has finished loading, so registering it here at boot is in time
|
|
2381
|
+
// for both a cold-launch link and one that arrives while running.
|
|
2382
|
+
if (aegis.onDeepLink) aegis.onDeepLink(handleDeepLink);
|
|
2383
|
+
// Quick launcher "add to chat" push (main.js pushQuickLauncherResult) —
|
|
2384
|
+
// same ping/act split as the channels above.
|
|
2385
|
+
if (aegis.onQuickLauncherPush) aegis.onQuickLauncherPush(handleQuickLauncherPush);
|
|
1911
2386
|
els.sessionsRefresh.addEventListener('click', loadSessions);
|
|
1912
2387
|
els.syncNow.addEventListener('click', syncNow);
|
|
2388
|
+
els.sessionsExport.addEventListener('click', () => exportSession('markdown'));
|
|
1913
2389
|
|
|
1914
2390
|
els.apiKeySave.addEventListener('click', saveApiKey);
|
|
1915
2391
|
els.apiKeyVerify.addEventListener('click', verifyAegisKey);
|
|
@@ -1920,6 +2396,20 @@ async function init() {
|
|
|
1920
2396
|
}
|
|
1921
2397
|
});
|
|
1922
2398
|
|
|
2399
|
+
els.quickLauncherSave.addEventListener('click', saveQuickLauncherSettings);
|
|
2400
|
+
els.quickLauncherShortcut.addEventListener('keydown', (e) => {
|
|
2401
|
+
if (e.key === 'Enter') {
|
|
2402
|
+
e.preventDefault();
|
|
2403
|
+
saveQuickLauncherSettings();
|
|
2404
|
+
}
|
|
2405
|
+
});
|
|
2406
|
+
|
|
2407
|
+
// Tool-approval switch: persist on change (no Save button), repainting from
|
|
2408
|
+
// what the main process stored — see saveConfirmMode.
|
|
2409
|
+
if (els.confirmMode) {
|
|
2410
|
+
els.confirmMode.addEventListener('change', () => saveConfirmMode(els.confirmMode.checked));
|
|
2411
|
+
}
|
|
2412
|
+
|
|
1923
2413
|
els.composer.addEventListener('submit', (e) => {
|
|
1924
2414
|
e.preventDefault();
|
|
1925
2415
|
send();
|
|
@@ -1966,11 +2456,38 @@ async function init() {
|
|
|
1966
2456
|
if (e.key === 'Escape' && overlayOpen()) closeMemoryOverlay();
|
|
1967
2457
|
});
|
|
1968
2458
|
|
|
2459
|
+
// Auto-update banner: `?`-guarded like the memory inspector above, since
|
|
2460
|
+
// the markup is optional and a missing node must never crash boot.
|
|
2461
|
+
if (els.updateBanner && aegis.onUpdateStatus) {
|
|
2462
|
+
aegis.onUpdateStatus(renderUpdateBanner);
|
|
2463
|
+
aegis.updateStatus().then(renderUpdateBanner).catch(() => {});
|
|
2464
|
+
|
|
2465
|
+
els.updateDownloadBtn.addEventListener('click', () => {
|
|
2466
|
+
els.updateDownloadBtn.disabled = true;
|
|
2467
|
+
aegis.downloadUpdate().finally(() => {
|
|
2468
|
+
els.updateDownloadBtn.disabled = false;
|
|
2469
|
+
});
|
|
2470
|
+
});
|
|
2471
|
+
|
|
2472
|
+
// The only place quitAndInstallUpdate is ever called — an explicit user
|
|
2473
|
+
// click. Nothing in this app restarts itself without that.
|
|
2474
|
+
els.updateRestartBtn.addEventListener('click', () => {
|
|
2475
|
+
aegis.quitAndInstallUpdate();
|
|
2476
|
+
});
|
|
2477
|
+
|
|
2478
|
+
els.updateLaterBtn.addEventListener('click', () => {
|
|
2479
|
+
updateDismissedFor = lastUpdateState && lastUpdateState.status;
|
|
2480
|
+
els.updateBanner.hidden = true;
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
|
|
1969
2484
|
// First paint: show the welcome panel unless a session already rendered rows.
|
|
1970
2485
|
if (!els.messages.querySelector('.msg, .chatflow')) renderWelcome();
|
|
1971
2486
|
|
|
1972
2487
|
await loadClasses();
|
|
1973
2488
|
await loadSettings();
|
|
2489
|
+
await loadQuickLauncherSettings();
|
|
2490
|
+
await loadConfirmMode();
|
|
1974
2491
|
await loadSessions();
|
|
1975
2492
|
await refreshSyncStatus();
|
|
1976
2493
|
loadAccountInfo();
|