aegis-desktop 0.3.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/bin/aegis.js +30 -0
- package/build/icon.png +0 -0
- package/lib/local/agents.js +102 -0
- package/lib/local/context.js +81 -0
- package/lib/local/engine.js +460 -0
- package/lib/local/ollama.js +77 -0
- package/lib/local/prompt.js +91 -0
- package/lib/local/providers.js +536 -0
- package/lib/local/shell.js +208 -0
- package/lib/local/tools.js +638 -0
- package/lib/settings.js +225 -0
- package/lib/sync/memory-queue.js +57 -0
- package/lib/sync/sessions.js +199 -0
- package/main.js +715 -0
- package/package.json +46 -0
- package/preload.js +168 -0
- package/renderer/app.js +1990 -0
- package/renderer/index.html +289 -0
- package/renderer/max-tokens.js +18 -0
- package/renderer/style.css +1454 -0
- package/vendor/aegis.js +694 -0
- package/vendor/foreign-memory.js +666 -0
package/renderer/app.js
ADDED
|
@@ -0,0 +1,1990 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AEGIS Desktop renderer — thin UI only.
|
|
5
|
+
*
|
|
6
|
+
* Talks exclusively to the surfaces exposed by preload.js (contextBridge):
|
|
7
|
+
* - window.aegis.* cloud status / memory / account (back-compat surface)
|
|
8
|
+
* - window.models.* 3-class provider layer (Aegis Cloud, Ollama,
|
|
9
|
+
* custom OpenAI-/Anthropic-compatible) — the chat path
|
|
10
|
+
* - window.sync.* local session persistence + cloud push/pull (P3 §7)
|
|
11
|
+
*
|
|
12
|
+
* No engine/routing logic lives here; the model class is the user's explicit
|
|
13
|
+
* selection, routed in the main process. If this file grows engine logic it is
|
|
14
|
+
* wrong.
|
|
15
|
+
*
|
|
16
|
+
* `maxTokensCeiling`/`FLAT_CEILING` come from max-tokens.js, a sibling
|
|
17
|
+
* classic script loaded before this one (see index.html) so the per-model
|
|
18
|
+
* ceiling math stays unit-testable without window.aegis/window.models.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// Everything below runs inside an IIFE. preload.js's contextBridge.exposeInMainWorld
|
|
22
|
+
// calls define window.aegis/models/sync as non-configurable globals; a
|
|
23
|
+
// top-level `const aegis = …` in a classic script binds into that *same*
|
|
24
|
+
// global lexical environment, and V8 refuses to shadow a non-configurable
|
|
25
|
+
// global property that way ("Identifier 'aegis' has already been declared").
|
|
26
|
+
// That SyntaxError kills the whole script before a single line runs — the UI
|
|
27
|
+
// is left stuck on "connecting…" with every button unbound. A function scope
|
|
28
|
+
// sidesteps the global environment entirely, so the same names are fine here.
|
|
29
|
+
(function () {
|
|
30
|
+
|
|
31
|
+
const aegis = window.aegis;
|
|
32
|
+
const models = window.models;
|
|
33
|
+
const sync = window.sync;
|
|
34
|
+
|
|
35
|
+
if (!aegis || !models) {
|
|
36
|
+
document.body.textContent =
|
|
37
|
+
'This page must run inside the AEGIS Electron host (window.aegis/window.models missing).';
|
|
38
|
+
throw new Error('window.aegis/window.models unavailable — not running under Electron');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Element handles are resolved lazily instead of eagerly at script parse time.
|
|
42
|
+
// If this script is loaded in <head> before the body exists, an eager
|
|
43
|
+
// document.getElementById() would capture null for every control and leave the
|
|
44
|
+
// whole UI dead (Save/Verify never bound, class/model dropdowns empty). Lazy
|
|
45
|
+
// getters re-read the live DOM on each access, so boot order can never cause
|
|
46
|
+
// that failure mode.
|
|
47
|
+
const ELEMENT_IDS = {
|
|
48
|
+
connDot: 'conn-dot',
|
|
49
|
+
connText: 'conn-text',
|
|
50
|
+
app: 'st-app',
|
|
51
|
+
client: 'st-client',
|
|
52
|
+
base: 'st-base',
|
|
53
|
+
key: 'st-key',
|
|
54
|
+
plan: 'st-plan',
|
|
55
|
+
balance: 'st-balance',
|
|
56
|
+
apiKeyInput: 'api-key-input',
|
|
57
|
+
apiKeySave: 'api-key-save',
|
|
58
|
+
apiKeyVerify: 'api-key-verify',
|
|
59
|
+
apiKeyHint: 'api-key-hint',
|
|
60
|
+
classSelect: 'class-select',
|
|
61
|
+
modelSelect: 'model-select',
|
|
62
|
+
modelPreset: 'model-preset',
|
|
63
|
+
modelInput: 'model-input',
|
|
64
|
+
maxTokens: 'max-tokens',
|
|
65
|
+
maxTokensAdaptive: 'max-tokens-adaptive',
|
|
66
|
+
autonomousToggle: 'autonomous-toggle',
|
|
67
|
+
autonomousToggleWrap: 'autonomous-toggle-wrap',
|
|
68
|
+
autonomousControls: 'autonomous-controls',
|
|
69
|
+
autonomousEffort: 'autonomous-effort',
|
|
70
|
+
autonomousWorkers: 'autonomous-workers',
|
|
71
|
+
modelHint: 'model-hint',
|
|
72
|
+
settingsList: 'settings-list',
|
|
73
|
+
settingsHint: 'settings-hint',
|
|
74
|
+
sessionsRefresh: 'sessions-refresh',
|
|
75
|
+
sessionsList: 'sessions-list',
|
|
76
|
+
sessionsHint: 'sessions-hint',
|
|
77
|
+
syncNow: 'sync-now',
|
|
78
|
+
syncStatus: 'sync-status',
|
|
79
|
+
newChat: 'new-chat',
|
|
80
|
+
memorySearchForm: 'memory-search-form',
|
|
81
|
+
memoryQuery: 'memory-query',
|
|
82
|
+
memoryResults: 'memory-results',
|
|
83
|
+
memoryEntry: 'memory-entry',
|
|
84
|
+
memorySaveBtn: 'memory-save-btn',
|
|
85
|
+
memoryImportBtn: 'memory-import-btn',
|
|
86
|
+
memoryHint: 'memory-hint',
|
|
87
|
+
memoryOpen: 'memory-open',
|
|
88
|
+
memoryOverlay: 'memory-overlay',
|
|
89
|
+
memoryBackdrop: 'memory-backdrop',
|
|
90
|
+
memoryClose: 'memory-close',
|
|
91
|
+
memoryFilters: 'memory-filters',
|
|
92
|
+
memoryOverlayQuery: 'memory-overlay-query',
|
|
93
|
+
memoryFiltersClear: 'memory-filters-clear',
|
|
94
|
+
memoryChips: 'memory-chips',
|
|
95
|
+
memoryList: 'memory-list',
|
|
96
|
+
memoryCount: 'memory-count',
|
|
97
|
+
memoryOverlayEntry: 'memory-overlay-entry',
|
|
98
|
+
memoryOverlaySave: 'memory-overlay-save',
|
|
99
|
+
messages: 'messages',
|
|
100
|
+
composer: 'composer',
|
|
101
|
+
prompt: 'prompt',
|
|
102
|
+
send: 'send',
|
|
103
|
+
exploreToggle: 'explore-toggle',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const els = {};
|
|
107
|
+
for (const [prop, id] of Object.entries(ELEMENT_IDS)) {
|
|
108
|
+
Object.defineProperty(els, prop, {
|
|
109
|
+
get: () => document.getElementById(id),
|
|
110
|
+
enumerable: true,
|
|
111
|
+
configurable: true,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const CLASS_KEY = 'aegis.class';
|
|
116
|
+
const MAX_TOKENS_KEY = 'aegis.maxTokens';
|
|
117
|
+
const MAX_TOKENS_ADAPTIVE_KEY = 'aegis.maxTokensAdaptive';
|
|
118
|
+
const AUTONOMOUS_KEY = 'aegis.autonomous';
|
|
119
|
+
const AUTONOMOUS_EFFORT_KEY = 'aegis.autonomousEffort';
|
|
120
|
+
const AUTONOMOUS_WORKERS_KEY = 'aegis.autonomousWorkers';
|
|
121
|
+
const EXPLORE_KEY = 'aegis.explore';
|
|
122
|
+
// "Work autonomously" (pool_brain worker fan-out, aegis1 services/pool_brain.py)
|
|
123
|
+
// is only billable/routable through the pooled AEGIS Cloud class.
|
|
124
|
+
const AUTONOMOUS_CLASS = 'aegis';
|
|
125
|
+
const CUSTOM_CLASSES = new Set(['openai-compat', 'anthropic']);
|
|
126
|
+
// Placeholders for the typed model-id field: custom endpoints enumerate
|
|
127
|
+
// nothing, so the field has to say what a valid id looks like.
|
|
128
|
+
const MODEL_ID_PLACEHOLDER = {
|
|
129
|
+
'openai-compat': 'type a model id — e.g. gpt-4o-mini',
|
|
130
|
+
anthropic: 'type a model id — e.g. claude-sonnet-4-5',
|
|
131
|
+
};
|
|
132
|
+
// Quick-fill presets for the two custom-endpoint classes — model id + the
|
|
133
|
+
// base URL it actually lives at, since typing the right model string is only
|
|
134
|
+
// half the problem (the wrong base URL 400s just as hard). Base URLs and
|
|
135
|
+
// default model ids match aegis1 services/nexus_provider/catalog.py exactly:
|
|
136
|
+
// DeepSeek is served via Anthropic-Messages transport, Gemini via
|
|
137
|
+
// OpenAI-chat transport — that is why each shows up under the *other*
|
|
138
|
+
// custom class from what its own name suggests.
|
|
139
|
+
const CUSTOM_MODEL_PRESETS = {
|
|
140
|
+
'openai-compat': [
|
|
141
|
+
{ label: 'OpenAI — gpt-4o-mini', baseURL: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
|
|
142
|
+
{ label: 'OpenAI — gpt-4o', baseURL: 'https://api.openai.com/v1', model: 'gpt-4o' },
|
|
143
|
+
{
|
|
144
|
+
label: 'Gemini — 3.5 Flash',
|
|
145
|
+
baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai/',
|
|
146
|
+
model: 'gemini-3.5-flash',
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
anthropic: [
|
|
150
|
+
{ label: 'Anthropic — Claude Sonnet 5', baseURL: 'https://api.anthropic.com/v1', model: 'claude-sonnet-5' },
|
|
151
|
+
{ label: 'Anthropic — Claude Haiku 4.5', baseURL: 'https://api.anthropic.com/v1', model: 'claude-haiku-4-5' },
|
|
152
|
+
{ label: 'DeepSeek — v4 Flash', baseURL: 'https://api.deepseek.com/anthropic', model: 'deepseek-v4-flash' },
|
|
153
|
+
{ label: 'DeepSeek — v4 Pro', baseURL: 'https://api.deepseek.com/anthropic', model: 'deepseek-v4-pro' },
|
|
154
|
+
],
|
|
155
|
+
};
|
|
156
|
+
// The in-app AEGIS key is stored in a reserved namespace the main process
|
|
157
|
+
// already filters out of settings.list(); never render it as a provider row
|
|
158
|
+
// even if a stale store still surfaces it (defect #1).
|
|
159
|
+
const RESERVED_PROVIDERS = new Set(['__aegis', 'aegis']);
|
|
160
|
+
|
|
161
|
+
let pendingEl = null;
|
|
162
|
+
let pendingSessionId = null;
|
|
163
|
+
let classOptions = [];
|
|
164
|
+
let modelMeta = new Map(); // model id -> raw model object from listModels() (P2 §6.3 ceiling)
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------- discovery lane
|
|
167
|
+
//
|
|
168
|
+
// The chat flow reads vertically: one prompt, one answer, forever. That makes
|
|
169
|
+
// the AI's *alternatives* a cost the user has to pay for manually ("ask again,
|
|
170
|
+
// differently"). The discovery lane turns that into a first-class part of the
|
|
171
|
+
// flow: after every answer the AI is sent down extra paths in parallel, and
|
|
172
|
+
// each path streams into its own card on a horizontal track beside the thread
|
|
173
|
+
// — the 2nd path, the new genre, the unexpected discovery, read left→right.
|
|
174
|
+
//
|
|
175
|
+
// Concurrency is what makes this non-trivial: several `models.chat` calls are
|
|
176
|
+
// live at once, so every stream owns a derived sessionId and the preload
|
|
177
|
+
// listener only accepts chunks tagged with it (main.js `taggedChunk`). Without
|
|
178
|
+
// that tag the replies would interleave into a single bubble.
|
|
179
|
+
const FLOW_SYSTEM =
|
|
180
|
+
'You are the exploratory half of a chat assistant. The user is reading the ' +
|
|
181
|
+
'main answer elsewhere, so never restate it. Be concrete and brief: one ' +
|
|
182
|
+
'lead line naming the path, then 3-5 tight bullets. Never pad.';
|
|
183
|
+
|
|
184
|
+
/** The paths the lane offers. `hint` is the whole instruction for that card. */
|
|
185
|
+
const FLOW_PATHS = [
|
|
186
|
+
{
|
|
187
|
+
key: 'alternate',
|
|
188
|
+
badge: 'A',
|
|
189
|
+
title: 'Alternative angle',
|
|
190
|
+
hint:
|
|
191
|
+
'Answer the request from a genuinely different angle: another method, ' +
|
|
192
|
+
'school of thought or genre. Name the angle in one line, then 3-5 ' +
|
|
193
|
+
'bullets of how it actually plays out. Do not restate the main answer.',
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
key: 'discovery',
|
|
197
|
+
badge: 'B',
|
|
198
|
+
title: 'Unexpected discovery',
|
|
199
|
+
hint:
|
|
200
|
+
'Act as a scout, not an assistant. Surface ONE non-obvious connection, ' +
|
|
201
|
+
'adjacent field or surprise finding the user did not ask for but which ' +
|
|
202
|
+
'reframes the request. One line naming the discovery, then 2-4 bullets ' +
|
|
203
|
+
'on why it matters and how to test it. Flag uncertainty honestly.',
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
key: 'genre',
|
|
207
|
+
badge: 'C',
|
|
208
|
+
title: 'New genre',
|
|
209
|
+
hint:
|
|
210
|
+
'Recast the request in an unfamiliar genre or discipline — pick one that ' +
|
|
211
|
+
'fits oddly well (e.g. field biology, contract law, ecology, jazz, ' +
|
|
212
|
+
'logistics, restoration). Name the genre in one line, then 3-5 bullets ' +
|
|
213
|
+
'of what that discipline would do first.',
|
|
214
|
+
},
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
let flowCount = 0;
|
|
218
|
+
const activeBranches = new Set();
|
|
219
|
+
|
|
220
|
+
/** Every in-flight path for the current thread, so New chat can stop them. */
|
|
221
|
+
function abortBranches() {
|
|
222
|
+
for (const id of activeBranches) {
|
|
223
|
+
try {
|
|
224
|
+
models.cancel(id);
|
|
225
|
+
} catch {
|
|
226
|
+
/* a dead controller is not an error */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
activeBranches.clear();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function exploreEnabled() {
|
|
233
|
+
const box = els.exploreToggle;
|
|
234
|
+
return Boolean(box && box.checked);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function maxTokensAdaptive() {
|
|
238
|
+
const box = els.maxTokensAdaptive;
|
|
239
|
+
return Boolean(box && box.checked);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function autonomousEnabled() {
|
|
243
|
+
const box = els.autonomousToggle;
|
|
244
|
+
return Boolean(box && box.checked);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Effort/workers only matter (and only show) once autonomous mode is on for
|
|
248
|
+
* the pooled class — same gating as the toggle itself, plus its checked state. */
|
|
249
|
+
function updateAutonomousControlsVisibility() {
|
|
250
|
+
if (!els.autonomousControls) return;
|
|
251
|
+
const wrapVisible = els.autonomousToggleWrap && !els.autonomousToggleWrap.hidden;
|
|
252
|
+
els.autonomousControls.hidden = !(wrapVisible && autonomousEnabled());
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------- UI helpers
|
|
256
|
+
|
|
257
|
+
function setConn(ok, text) {
|
|
258
|
+
els.connDot.classList.toggle('ok', Boolean(ok));
|
|
259
|
+
els.connText.textContent = text;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function renderStatus(s) {
|
|
263
|
+
if (!s) {
|
|
264
|
+
setConn(false, 'IPC unavailable');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
els.app.textContent = s.appVersion ? `v${s.appVersion}` : '–';
|
|
268
|
+
els.client.textContent = s.clientVersion || '–';
|
|
269
|
+
els.base.textContent = s.apiBase || '–';
|
|
270
|
+
els.key.textContent = s.keyConfigured ? s.keyMask : 'not set';
|
|
271
|
+
setConn(s.keyConfigured, s.keyConfigured ? 'key configured' : 'no API key');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function loadAccountInfo() {
|
|
275
|
+
try {
|
|
276
|
+
const verify = await aegis.verifyApiKey();
|
|
277
|
+
els.plan.textContent = verify && verify.valid
|
|
278
|
+
? (verify.plan || 'active')
|
|
279
|
+
: 'invalid key';
|
|
280
|
+
} catch {
|
|
281
|
+
els.plan.textContent = 'unavailable';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const bank = await aegis.tokenBankBalance();
|
|
286
|
+
els.balance.textContent = bank && bank.balance_eur != null
|
|
287
|
+
? `€${bank.balance_eur}`
|
|
288
|
+
: '–';
|
|
289
|
+
} catch {
|
|
290
|
+
els.balance.textContent = 'unavailable';
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Refresh every key-dependent surface after the in-app key changes. The main
|
|
295
|
+
// process already replaced the live client key; this re-reads status (masked
|
|
296
|
+
// preview) and repopulates class/model dropdowns + account plan/balance.
|
|
297
|
+
async function refreshAfterKeyChange() {
|
|
298
|
+
try {
|
|
299
|
+
renderStatus(await aegis.status());
|
|
300
|
+
} catch {
|
|
301
|
+
renderStatus(null);
|
|
302
|
+
}
|
|
303
|
+
// loadClasses() also re-runs loadModels() for the currently selected class.
|
|
304
|
+
await loadClasses();
|
|
305
|
+
await loadAccountInfo();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function saveApiKey() {
|
|
309
|
+
const key = els.apiKeyInput.value.trim();
|
|
310
|
+
els.apiKeySave.disabled = true;
|
|
311
|
+
els.apiKeyVerify.disabled = true;
|
|
312
|
+
els.apiKeyHint.textContent = 'saving…';
|
|
313
|
+
try {
|
|
314
|
+
const res = await aegis.setApiKey(key);
|
|
315
|
+
// Never retain the raw key in the DOM once saved — show only the masked
|
|
316
|
+
// preview the main process returned.
|
|
317
|
+
els.apiKeyInput.value = '';
|
|
318
|
+
els.apiKeyHint.textContent = key
|
|
319
|
+
? `saved (${res && res.keyMask ? res.keyMask : 'configured'})`
|
|
320
|
+
: 'key cleared';
|
|
321
|
+
await refreshAfterKeyChange();
|
|
322
|
+
} catch (err) {
|
|
323
|
+
els.apiKeyHint.textContent =
|
|
324
|
+
`save failed: ${err && err.message ? err.message : err}`;
|
|
325
|
+
} finally {
|
|
326
|
+
els.apiKeySave.disabled = false;
|
|
327
|
+
els.apiKeyVerify.disabled = false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function verifyAegisKey() {
|
|
332
|
+
els.apiKeyVerify.disabled = true;
|
|
333
|
+
els.apiKeyHint.textContent = 'verifying…';
|
|
334
|
+
try {
|
|
335
|
+
const verify = await aegis.verifyApiKey();
|
|
336
|
+
els.apiKeyHint.textContent = verify && verify.valid
|
|
337
|
+
? `valid (${verify.plan || 'active'})`
|
|
338
|
+
: 'invalid key';
|
|
339
|
+
} catch (err) {
|
|
340
|
+
els.apiKeyHint.textContent =
|
|
341
|
+
`verify failed: ${err && err.message ? err.message : err}`;
|
|
342
|
+
} finally {
|
|
343
|
+
els.apiKeyVerify.disabled = false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ----------------------------------------------------------------- memory
|
|
348
|
+
//
|
|
349
|
+
// Two surfaces read the same endpoint:
|
|
350
|
+
// - the sidebar card (last 10, one line each) — the quick peek;
|
|
351
|
+
// - the full inspector overlay (up to 50, every field) — "all memories and
|
|
352
|
+
// their sources" behind a translucent backdrop.
|
|
353
|
+
//
|
|
354
|
+
// Backend contract (aegis1/app.py:9214 `memory_search`): the response key is
|
|
355
|
+
// `entries`, NOT `results` — reading `results` is what kept this card empty
|
|
356
|
+
// for every account. `limit` is clamped server-side to 50 with no offset
|
|
357
|
+
// (app.py:9243), so the inspector is honestly "the 50 most recent", not an
|
|
358
|
+
// unbounded list. The endpoint also runs `_memory_sync_access` and can answer
|
|
359
|
+
// HTTP 402 `free_session_limit_reached`, which must render as an upgrade
|
|
360
|
+
// prompt — never as an empty list.
|
|
361
|
+
//
|
|
362
|
+
// Field set per entry (aegis1/app.py:9349 `_memory_row_to_dict`): id,
|
|
363
|
+
// timestamp, createdAt (epoch ms), source, role, tags[], content, session,
|
|
364
|
+
// importance, summary(bool), topics[], entities[], sentiment, tokenCount,
|
|
365
|
+
// embedding[]. There is no `tier` — L0–L3 is the local CLI engine's concept
|
|
366
|
+
// (aegiscodex-dev/src/memory.js) and does not exist on the cloud rows, so
|
|
367
|
+
// source/role are the real provenance axes here.
|
|
368
|
+
//
|
|
369
|
+
// `embedding` is dropped on ingest: it is a raw float vector, sometimes
|
|
370
|
+
// thousands of numbers, and nothing in this view renders or needs it.
|
|
371
|
+
|
|
372
|
+
const MEMORY_SIDEBAR_LIMIT = 10;
|
|
373
|
+
const MEMORY_INSPECTOR_LIMIT = 50; // server clamp — app.py:9243
|
|
374
|
+
|
|
375
|
+
/** Inspector state. `entries` is the fetched page; chips filter it in place. */
|
|
376
|
+
const memoryView = {
|
|
377
|
+
entries: [],
|
|
378
|
+
query: '',
|
|
379
|
+
source: '',
|
|
380
|
+
role: '',
|
|
381
|
+
error: '',
|
|
382
|
+
upgrade: null,
|
|
383
|
+
loading: false,
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
function overlayOpen() {
|
|
387
|
+
return !!els.memoryOverlay && !els.memoryOverlay.hidden;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** "3m ago" / "2d ago" from epoch ms. Empty string when unknown. */
|
|
391
|
+
function relTime(ms) {
|
|
392
|
+
if (!Number.isFinite(ms)) return '';
|
|
393
|
+
const secs = Math.round((Date.now() - ms) / 1000);
|
|
394
|
+
if (secs < 45) return 'just now';
|
|
395
|
+
const mins = Math.round(secs / 60);
|
|
396
|
+
if (mins < 60) return `${mins}m ago`;
|
|
397
|
+
const hrs = Math.round(mins / 60);
|
|
398
|
+
if (hrs < 24) return `${hrs}h ago`;
|
|
399
|
+
const days = Math.round(hrs / 24);
|
|
400
|
+
if (days < 30) return `${days}d ago`;
|
|
401
|
+
const mos = Math.round(days / 30);
|
|
402
|
+
if (mos < 12) return `${mos}mo ago`;
|
|
403
|
+
return `${Math.round(mos / 12)}y ago`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Coerce one raw row into the shape the renderers expect. Never trusts the
|
|
407
|
+
* payload: every field is type-checked so a malformed row degrades to a
|
|
408
|
+
* readable card instead of throwing mid-render. */
|
|
409
|
+
function normalizeMemoryEntry(raw) {
|
|
410
|
+
const r = raw && typeof raw === 'object' ? raw : {};
|
|
411
|
+
const asArray = (v) => (Array.isArray(v) ? v.filter((x) => x != null && x !== '') : []);
|
|
412
|
+
const asString = (v) => (typeof v === 'string' ? v : '');
|
|
413
|
+
const asNumber = (v) => (Number.isFinite(Number(v)) ? Number(v) : null);
|
|
414
|
+
const content = asString(r.content) || asString(r.text) || asString(r.entry);
|
|
415
|
+
return {
|
|
416
|
+
id: r.id != null ? String(r.id) : '',
|
|
417
|
+
content,
|
|
418
|
+
source: asString(r.source) || '(unknown source)',
|
|
419
|
+
role: asString(r.role),
|
|
420
|
+
tags: asArray(r.tags),
|
|
421
|
+
topics: asArray(r.topics),
|
|
422
|
+
entities: asArray(r.entities),
|
|
423
|
+
session: asString(r.session),
|
|
424
|
+
sentiment: asString(r.sentiment),
|
|
425
|
+
importance: asNumber(r.importance),
|
|
426
|
+
tokenCount: asNumber(r.tokenCount),
|
|
427
|
+
summary: r.summary === true,
|
|
428
|
+
createdAt: asNumber(r.createdAt),
|
|
429
|
+
timestamp: asString(r.timestamp),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Fetch one page of memory. Returns a discriminated result rather than
|
|
434
|
+
* throwing, because "needs upgrade" and "failed" render very differently
|
|
435
|
+
* from "empty" and conflating them is the bug this view exists to fix. */
|
|
436
|
+
async function fetchMemory(query, limit) {
|
|
437
|
+
try {
|
|
438
|
+
const data = query
|
|
439
|
+
? await aegis.memorySearch(query, limit)
|
|
440
|
+
: await aegis.memoryList(limit);
|
|
441
|
+
// The free-plan cap does NOT arrive as a rejection on this path: main.js
|
|
442
|
+
// detects it where `err.status`/`err.data` still exist (ipcRenderer.invoke
|
|
443
|
+
// carries only the message string across the process boundary) and resolves
|
|
444
|
+
// `{ entries: [], upgrade }` instead. Checking the resolved payload first
|
|
445
|
+
// is what makes the upgrade UI reachable at all — the catch below only
|
|
446
|
+
// covers a non-IPC caller that still throws the raw client error.
|
|
447
|
+
if (data && data.upgrade) {
|
|
448
|
+
return { entries: [], error: '', upgrade: data.upgrade };
|
|
449
|
+
}
|
|
450
|
+
// `entries` is the real key. Keep the `results` fallback only so an older
|
|
451
|
+
// 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
|
+
return { entries, error: '', upgrade: null };
|
|
455
|
+
} catch (err) {
|
|
456
|
+
const status = err && err.status;
|
|
457
|
+
const code = err && err.data && err.data.error;
|
|
458
|
+
if (status === 402 || code === 'free_session_limit_reached') {
|
|
459
|
+
return {
|
|
460
|
+
entries: [],
|
|
461
|
+
error: '',
|
|
462
|
+
upgrade: {
|
|
463
|
+
url: (err.data && err.data.upgradeUrl) || 'https://aegiscloud.org/subscribe',
|
|
464
|
+
used: err.data && err.data.sessionsUsed,
|
|
465
|
+
limit: err.data && err.data.freeSessionLimit,
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
return {
|
|
470
|
+
entries: [],
|
|
471
|
+
error: `search failed: ${err && err.message ? err.message : err}`,
|
|
472
|
+
upgrade: null,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Shared free-plan-cap notice. Every surface that can hit the 402 funnels
|
|
478
|
+
* through here so the wording and the target stay identical to the
|
|
479
|
+
* inspector's block. The hint elements are bare <p>s, so the subscribe link
|
|
480
|
+
* has to be a real child node — a plain text assignment would wipe it, and an
|
|
481
|
+
* href left in text is not clickable. */
|
|
482
|
+
function capNotice(el, upgrade, prefix, cta) {
|
|
483
|
+
if (!el) return;
|
|
484
|
+
const used = upgrade.used != null ? upgrade.used : '?';
|
|
485
|
+
const cap = upgrade.limit != null ? upgrade.limit : '?';
|
|
486
|
+
el.textContent =
|
|
487
|
+
`${prefix || 'free plan limit reached'} — ${used} of ${cap} sync sessions used. ` +
|
|
488
|
+
'Nothing was lost. ';
|
|
489
|
+
const a = document.createElement('a');
|
|
490
|
+
a.href = upgrade.url || 'https://aegiscloud.org/subscribe';
|
|
491
|
+
a.target = '_blank';
|
|
492
|
+
a.rel = 'noreferrer noopener';
|
|
493
|
+
a.textContent = cta || 'Upgrade to keep saving →';
|
|
494
|
+
el.appendChild(a);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** The cap notice in the memory section's hint line. */
|
|
498
|
+
function renderCapHint(upgrade, prefix) {
|
|
499
|
+
capNotice(els.memoryHint, upgrade, prefix);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Compact sidebar row: content only, with the source as a quiet prefix. */
|
|
503
|
+
function renderMemoryResults(entries, error, upgrade) {
|
|
504
|
+
els.memoryResults.innerHTML = '';
|
|
505
|
+
if (upgrade) {
|
|
506
|
+
const li = document.createElement('li');
|
|
507
|
+
li.className = 'empty mem-side-upgrade';
|
|
508
|
+
const a = document.createElement('a');
|
|
509
|
+
a.href = upgrade.url || 'https://aegiscloud.org/subscribe';
|
|
510
|
+
a.target = '_blank';
|
|
511
|
+
a.rel = 'noreferrer noopener';
|
|
512
|
+
a.textContent = 'Free plan limit reached — upgrade to read memory →';
|
|
513
|
+
li.appendChild(a);
|
|
514
|
+
els.memoryResults.appendChild(li);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (error) {
|
|
518
|
+
const li = document.createElement('li');
|
|
519
|
+
li.className = 'empty';
|
|
520
|
+
li.textContent = error;
|
|
521
|
+
els.memoryResults.appendChild(li);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
525
|
+
if (!list.length) {
|
|
526
|
+
const li = document.createElement('li');
|
|
527
|
+
li.className = 'empty';
|
|
528
|
+
li.textContent = 'no memory entries';
|
|
529
|
+
els.memoryResults.appendChild(li);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
for (const e of list) {
|
|
533
|
+
const li = document.createElement('li');
|
|
534
|
+
const src = document.createElement('span');
|
|
535
|
+
src.className = 'mem-side-src';
|
|
536
|
+
src.textContent = e.source;
|
|
537
|
+
const body = document.createElement('span');
|
|
538
|
+
body.className = 'mem-side-body';
|
|
539
|
+
body.textContent = e.content || '(empty)';
|
|
540
|
+
li.appendChild(src);
|
|
541
|
+
li.appendChild(body);
|
|
542
|
+
els.memoryResults.appendChild(li);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function searchMemory(query) {
|
|
547
|
+
els.memoryHint.textContent = 'searching…';
|
|
548
|
+
const res = await fetchMemory(query, MEMORY_SIDEBAR_LIMIT);
|
|
549
|
+
renderMemoryResults(res.entries, res.error, res.upgrade);
|
|
550
|
+
if (res.upgrade) renderCapHint(res.upgrade, 'cloud memory needs an active plan');
|
|
551
|
+
else els.memoryHint.textContent = res.error || '';
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ------------------------------------------------- memory inspector overlay
|
|
555
|
+
|
|
556
|
+
/** Small coloured label used for source / role / tag / topic / entity. */
|
|
557
|
+
function memoryChip(text, cls) {
|
|
558
|
+
const span = document.createElement('span');
|
|
559
|
+
span.className = `mem-chip${cls ? ` ${cls}` : ''}`;
|
|
560
|
+
span.textContent = text;
|
|
561
|
+
return span;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** One expandable card: content plus every provenance field the row carries. */
|
|
565
|
+
function memoryCard(e) {
|
|
566
|
+
const li = document.createElement('li');
|
|
567
|
+
li.className = 'mem-card';
|
|
568
|
+
|
|
569
|
+
const head = document.createElement('div');
|
|
570
|
+
head.className = 'mem-card-head';
|
|
571
|
+
head.appendChild(memoryChip(e.source, 'src'));
|
|
572
|
+
if (e.role) head.appendChild(memoryChip(e.role, 'role'));
|
|
573
|
+
if (e.importance != null) head.appendChild(memoryChip(`imp ${e.importance}`, 'imp'));
|
|
574
|
+
if (e.sentiment) head.appendChild(memoryChip(e.sentiment, 'sent'));
|
|
575
|
+
if (e.summary) head.appendChild(memoryChip('summary', 'flag'));
|
|
576
|
+
|
|
577
|
+
const meta = document.createElement('span');
|
|
578
|
+
meta.className = 'mem-card-meta';
|
|
579
|
+
const bits = [];
|
|
580
|
+
const rel = relTime(e.createdAt);
|
|
581
|
+
if (rel) bits.push(rel);
|
|
582
|
+
else if (e.timestamp) bits.push(e.timestamp);
|
|
583
|
+
if (e.session) bits.push(`session ${e.session}`);
|
|
584
|
+
if (e.tokenCount != null) bits.push(`${e.tokenCount} tok`);
|
|
585
|
+
meta.textContent = bits.join(' · ');
|
|
586
|
+
head.appendChild(meta);
|
|
587
|
+
li.appendChild(head);
|
|
588
|
+
|
|
589
|
+
const body = document.createElement('p');
|
|
590
|
+
body.className = 'mem-card-body';
|
|
591
|
+
body.textContent = e.content || '(empty)';
|
|
592
|
+
li.appendChild(body);
|
|
593
|
+
|
|
594
|
+
const pills = [...e.tags, ...e.topics, ...e.entities];
|
|
595
|
+
if (pills.length) {
|
|
596
|
+
const row = document.createElement('div');
|
|
597
|
+
row.className = 'mem-card-pills';
|
|
598
|
+
for (const p of pills) row.appendChild(memoryChip(p, 'pill'));
|
|
599
|
+
li.appendChild(row);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Expand on click, but never while the user is selecting text to copy.
|
|
603
|
+
body.addEventListener('click', () => {
|
|
604
|
+
if (window.getSelection && String(window.getSelection())) return;
|
|
605
|
+
li.classList.toggle('expanded');
|
|
606
|
+
});
|
|
607
|
+
return li;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Distinct values of `key` across the fetched page, with counts. */
|
|
611
|
+
function memoryFacets(key) {
|
|
612
|
+
const counts = new Map();
|
|
613
|
+
for (const e of memoryView.entries) {
|
|
614
|
+
const v = e[key];
|
|
615
|
+
if (!v) continue;
|
|
616
|
+
counts.set(v, (counts.get(v) || 0) + 1);
|
|
617
|
+
}
|
|
618
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Case-insensitive match over content plus every metadata field. */
|
|
622
|
+
function memoryMatches(e, q) {
|
|
623
|
+
const hay = [
|
|
624
|
+
e.content,
|
|
625
|
+
e.source,
|
|
626
|
+
e.role,
|
|
627
|
+
e.session,
|
|
628
|
+
e.sentiment,
|
|
629
|
+
...e.tags,
|
|
630
|
+
...e.topics,
|
|
631
|
+
...e.entities,
|
|
632
|
+
].join('\n').toLowerCase();
|
|
633
|
+
return hay.includes(String(q == null ? '' : q).toLowerCase());
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Chip filters (source / role) — click a chip to narrow, again to clear. */
|
|
637
|
+
function renderMemoryChips() {
|
|
638
|
+
els.memoryChips.innerHTML = '';
|
|
639
|
+
if (memoryView.upgrade || memoryView.error) return;
|
|
640
|
+
const groups = [
|
|
641
|
+
['source', 'source', memoryFacets('source')],
|
|
642
|
+
['role', 'role', memoryFacets('role')],
|
|
643
|
+
];
|
|
644
|
+
for (const [key, label, facets] of groups) {
|
|
645
|
+
if (facets.length < 2) continue; // a single value is not a useful filter
|
|
646
|
+
const group = document.createElement('div');
|
|
647
|
+
group.className = 'mem-chip-group';
|
|
648
|
+
const lab = document.createElement('span');
|
|
649
|
+
lab.className = 'mem-chip-label';
|
|
650
|
+
lab.textContent = label;
|
|
651
|
+
group.appendChild(lab);
|
|
652
|
+
for (const [value, count] of facets) {
|
|
653
|
+
const b = document.createElement('button');
|
|
654
|
+
b.type = 'button';
|
|
655
|
+
b.className = `mem-chip btn${memoryView[key] === value ? ' on' : ''}`;
|
|
656
|
+
b.textContent = `${value} ${count}`;
|
|
657
|
+
b.addEventListener('click', () => {
|
|
658
|
+
memoryView[key] = memoryView[key] === value ? '' : value;
|
|
659
|
+
renderMemoryOverlay();
|
|
660
|
+
});
|
|
661
|
+
group.appendChild(b);
|
|
662
|
+
}
|
|
663
|
+
els.memoryChips.appendChild(group);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** Repaint the inspector from `memoryView`. Pure — makes no network calls. */
|
|
668
|
+
function renderMemoryOverlay() {
|
|
669
|
+
if (!overlayOpen()) return;
|
|
670
|
+
const total = memoryView.entries.length;
|
|
671
|
+
els.memoryList.innerHTML = '';
|
|
672
|
+
renderMemoryChips();
|
|
673
|
+
|
|
674
|
+
if (memoryView.loading) {
|
|
675
|
+
els.memoryCount.textContent = 'loading…';
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (memoryView.upgrade) {
|
|
680
|
+
els.memoryCount.textContent = '';
|
|
681
|
+
const li = document.createElement('li');
|
|
682
|
+
li.className = 'mem-empty mem-upgrade';
|
|
683
|
+
const h = document.createElement('strong');
|
|
684
|
+
h.textContent = 'Cloud memory is paused on the free plan';
|
|
685
|
+
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 : '?';
|
|
688
|
+
p.textContent =
|
|
689
|
+
`This account has used ${used} of ${cap} sync sessions. Saved memory is ` +
|
|
690
|
+
'not lost — it becomes readable again once the plan is active.';
|
|
691
|
+
const a = document.createElement('a');
|
|
692
|
+
a.href = memoryView.upgrade.url;
|
|
693
|
+
a.target = '_blank';
|
|
694
|
+
a.rel = 'noreferrer noopener';
|
|
695
|
+
a.textContent = 'Open subscribe page ↗';
|
|
696
|
+
li.appendChild(h);
|
|
697
|
+
li.appendChild(p);
|
|
698
|
+
li.appendChild(a);
|
|
699
|
+
els.memoryList.appendChild(li);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (memoryView.error) {
|
|
704
|
+
els.memoryCount.textContent = '';
|
|
705
|
+
const li = document.createElement('li');
|
|
706
|
+
li.className = 'mem-empty';
|
|
707
|
+
li.textContent = memoryView.error;
|
|
708
|
+
els.memoryList.appendChild(li);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (!total) {
|
|
713
|
+
els.memoryCount.textContent = '0 entries';
|
|
714
|
+
const li = document.createElement('li');
|
|
715
|
+
li.className = 'mem-empty';
|
|
716
|
+
li.textContent = 'No memory entries yet — save a note below, or import from other AI tools.';
|
|
717
|
+
els.memoryList.appendChild(li);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const q = memoryView.query.trim().toLowerCase();
|
|
722
|
+
const shown = memoryView.entries.filter(
|
|
723
|
+
(e) =>
|
|
724
|
+
(!memoryView.source || e.source === memoryView.source) &&
|
|
725
|
+
(!memoryView.role || e.role === memoryView.role) &&
|
|
726
|
+
(!q || memoryMatches(e, q))
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
const sources = new Set(memoryView.entries.map((e) => e.source)).size;
|
|
730
|
+
els.memoryCount.textContent =
|
|
731
|
+
`${shown.length} of ${total} shown · ${sources} source${sources === 1 ? '' : 's'}` +
|
|
732
|
+
(total >= MEMORY_INSPECTOR_LIMIT ? ` · newest ${MEMORY_INSPECTOR_LIMIT}` : '');
|
|
733
|
+
|
|
734
|
+
if (!shown.length) {
|
|
735
|
+
const li = document.createElement('li');
|
|
736
|
+
li.className = 'mem-empty';
|
|
737
|
+
li.textContent = 'Nothing matches those filters.';
|
|
738
|
+
els.memoryList.appendChild(li);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
for (const e of shown) els.memoryList.appendChild(memoryCard(e));
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Fetch a fresh page into the inspector and repaint. Preserves the filter
|
|
745
|
+
* text so typing a query does not reset the chips you just clicked. */
|
|
746
|
+
async function loadMemoryOverlay(query) {
|
|
747
|
+
memoryView.loading = true;
|
|
748
|
+
memoryView.error = '';
|
|
749
|
+
memoryView.upgrade = null;
|
|
750
|
+
renderMemoryOverlay();
|
|
751
|
+
const res = await fetchMemory(query, MEMORY_INSPECTOR_LIMIT);
|
|
752
|
+
memoryView.entries = res.entries;
|
|
753
|
+
memoryView.error = res.error;
|
|
754
|
+
memoryView.upgrade = res.upgrade;
|
|
755
|
+
memoryView.loading = false;
|
|
756
|
+
renderMemoryOverlay();
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function openMemoryOverlay() {
|
|
760
|
+
if (!els.memoryOverlay) return;
|
|
761
|
+
els.memoryOverlay.hidden = false;
|
|
762
|
+
document.body.classList.add('memory-open');
|
|
763
|
+
// Start from whatever the sidebar is showing so the two never disagree.
|
|
764
|
+
els.memoryOverlayQuery.value = els.memoryQuery ? els.memoryQuery.value.trim() : '';
|
|
765
|
+
memoryView.query = els.memoryOverlayQuery.value;
|
|
766
|
+
memoryView.source = '';
|
|
767
|
+
memoryView.role = '';
|
|
768
|
+
renderMemoryOverlay();
|
|
769
|
+
loadMemoryOverlay(memoryView.query);
|
|
770
|
+
if (els.memoryOverlayQuery) els.memoryOverlayQuery.focus();
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function closeMemoryOverlay() {
|
|
774
|
+
if (!els.memoryOverlay) return;
|
|
775
|
+
els.memoryOverlay.hidden = true;
|
|
776
|
+
document.body.classList.remove('memory-open');
|
|
777
|
+
if (els.memoryOpen) els.memoryOpen.focus();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Textarea the save button should read from — the inspector's when it is up,
|
|
781
|
+
* otherwise the sidebar's. Keeps both save paths on one code path. */
|
|
782
|
+
function activeMemoryEntry() {
|
|
783
|
+
return overlayOpen() && els.memoryOverlayEntry ? els.memoryOverlayEntry : els.memoryEntry;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async function importMemory() {
|
|
787
|
+
if (!aegis.memoryImport) return;
|
|
788
|
+
els.memoryImportBtn.disabled = true;
|
|
789
|
+
els.memoryHint.textContent = 'scanning for other AI tool memory…';
|
|
790
|
+
try {
|
|
791
|
+
// Phase 1 — dry run. The scan is read-only against the foreign stores
|
|
792
|
+
// (client/foreign-memory.js never writes to them) and the user already
|
|
793
|
+
// asked for the import by clicking, so this runs unattended: no confirm
|
|
794
|
+
// prompt. The preview text left in the hint is the audit trail of what
|
|
795
|
+
// was found and from where, which is what the dialog used to be for.
|
|
796
|
+
const preview = await aegis.memoryImport({ confirm: false });
|
|
797
|
+
if (!preview || !preview.totals || !preview.totals.entries) {
|
|
798
|
+
els.memoryHint.textContent = preview && preview.summary ? preview.summary : 'nothing found.';
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const detail = (preview.sources || [])
|
|
803
|
+
.filter((s) => s.present && s.count > 0)
|
|
804
|
+
.map((s) => `${s.label}: ${s.count}`)
|
|
805
|
+
.join(' · ');
|
|
806
|
+
|
|
807
|
+
// Phase 2 — the confirmed write, immediately.
|
|
808
|
+
els.memoryHint.textContent = `importing ${preview.totals.entries} entries — ${detail}`;
|
|
809
|
+
const result = await aegis.memoryImport({ confirm: true, limit: 1000 });
|
|
810
|
+
if (result && result.upgrade) {
|
|
811
|
+
// The cap stopped the import part-way. Entries already in the cloud stay
|
|
812
|
+
// there and the rest are still in the foreign stores, so a later scan
|
|
813
|
+
// re-finds them — nothing to retry by hand, just show the plan page.
|
|
814
|
+
renderCapHint(result.upgrade, `import stopped after ${result.saved || 0} entries`);
|
|
815
|
+
await refreshMemoryViews();
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const queued = result && result.queued ? ` (${result.queued} queued offline)` : '';
|
|
819
|
+
els.memoryHint.textContent = result && result.ok
|
|
820
|
+
? `imported ${result.saved} entries${queued}.`
|
|
821
|
+
: `import stopped: ${(result && result.reason) || 'unknown error'}`;
|
|
822
|
+
await refreshMemoryViews();
|
|
823
|
+
} catch (err) {
|
|
824
|
+
els.memoryHint.textContent = `import failed: ${err && err.message ? err.message : err}`;
|
|
825
|
+
} finally {
|
|
826
|
+
els.memoryImportBtn.disabled = false;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
async function saveMemory() {
|
|
831
|
+
const box = activeMemoryEntry();
|
|
832
|
+
if (!box) return;
|
|
833
|
+
const text = box.value.trim();
|
|
834
|
+
if (!text) return;
|
|
835
|
+
els.memorySaveBtn.disabled = true;
|
|
836
|
+
if (els.memoryOverlaySave) els.memoryOverlaySave.disabled = true;
|
|
837
|
+
els.memoryHint.textContent = 'saving…';
|
|
838
|
+
try {
|
|
839
|
+
const result = await aegis.memorySave({ text, source: 'aegis-desktop' });
|
|
840
|
+
if (result && result.upgrade) {
|
|
841
|
+
// Nothing was stored, and it is not queued either (main.js deliberately
|
|
842
|
+
// does not queue a cap — it would flush-fail forever). So keep the text
|
|
843
|
+
// in the box: clearing it here would destroy the note the user just
|
|
844
|
+
// tried to remember, while the hint claimed it was saved.
|
|
845
|
+
renderCapHint(result.upgrade, 'free plan limit reached');
|
|
846
|
+
await refreshMemoryViews();
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
box.value = '';
|
|
850
|
+
els.memoryHint.textContent =
|
|
851
|
+
result && result.queued ? 'saved offline — syncs when AEGIS is reachable.' : 'saved.';
|
|
852
|
+
await refreshMemoryViews();
|
|
853
|
+
} catch (err) {
|
|
854
|
+
els.memoryHint.textContent = `save failed: ${err && err.message ? err.message : err}`;
|
|
855
|
+
} finally {
|
|
856
|
+
els.memorySaveBtn.disabled = false;
|
|
857
|
+
if (els.memoryOverlaySave) els.memoryOverlaySave.disabled = false;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** Re-read both surfaces after a write. The inspector only refetches when it
|
|
862
|
+
* is actually on screen — a hidden overlay should not spend a request. */
|
|
863
|
+
async function refreshMemoryViews() {
|
|
864
|
+
const q = els.memoryQuery ? els.memoryQuery.value.trim() : '';
|
|
865
|
+
await searchMemory(q);
|
|
866
|
+
if (overlayOpen()) await loadMemoryOverlay(memoryView.query);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ------------------------------------------------------------ chat welcome
|
|
870
|
+
// The welcome panel is cloned from #welcome-template in index.html rather than
|
|
871
|
+
// built with createElement, so its markup lives in exactly one place and
|
|
872
|
+
// survives the innerHTML clears in newChat()/openSession().
|
|
873
|
+
//
|
|
874
|
+
// Pills PREFILL the composer (matching ae-guix native-chat); they never
|
|
875
|
+
// auto-send, so a mis-click costs nothing. Wiring is addEventListener rather
|
|
876
|
+
// than inline onclick because this renderer runs under CSP `script-src 'self'`,
|
|
877
|
+
// which would silently drop inline handlers.
|
|
878
|
+
|
|
879
|
+
/** Time-aware greeting, recomputed each time the panel is rendered. */
|
|
880
|
+
function applyGreeting() {
|
|
881
|
+
const el = document.getElementById('chat-greeting');
|
|
882
|
+
if (!el) return;
|
|
883
|
+
const h = new Date().getHours();
|
|
884
|
+
el.textContent =
|
|
885
|
+
h >= 5 && h < 12
|
|
886
|
+
? 'Good morning'
|
|
887
|
+
: h >= 12 && h < 17
|
|
888
|
+
? 'Good afternoon'
|
|
889
|
+
: h >= 17 && h < 22
|
|
890
|
+
? 'Good evening'
|
|
891
|
+
: 'Working late?';
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Prefill the composer with a starter question, caret at the end. */
|
|
895
|
+
function quickAction(text) {
|
|
896
|
+
const inp = els.prompt;
|
|
897
|
+
if (!inp) return;
|
|
898
|
+
inp.value = text;
|
|
899
|
+
inp.focus();
|
|
900
|
+
try {
|
|
901
|
+
inp.setSelectionRange(text.length, text.length);
|
|
902
|
+
} catch (err) {
|
|
903
|
+
/* not supported on every input type; focus alone is enough */
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** Clear the transcript and render the welcome panel. */
|
|
908
|
+
function renderWelcome() {
|
|
909
|
+
const tpl = document.getElementById('welcome-template');
|
|
910
|
+
if (!tpl) return;
|
|
911
|
+
els.messages.innerHTML = '';
|
|
912
|
+
els.messages.appendChild(tpl.content.cloneNode(true));
|
|
913
|
+
applyGreeting();
|
|
914
|
+
for (const btn of els.messages.querySelectorAll('.chat-quick-pill')) {
|
|
915
|
+
btn.addEventListener('click', () => quickAction(btn.dataset.quick || ''));
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** Drop the welcome panel once real transcript content exists. */
|
|
920
|
+
function hideWelcome() {
|
|
921
|
+
const w = document.getElementById('chat-welcome');
|
|
922
|
+
if (w) w.remove();
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// `sessionId`, when given for an assistant message, renders a "copy" button
|
|
926
|
+
// that puts the message text on the clipboard. `toolLog` (assistant only) is
|
|
927
|
+
// the turn's collected `{name, args, ok}` tool calls, rendered above the
|
|
928
|
+
// answer text so the reply the user reads is followed by, not replaced by,
|
|
929
|
+
// what the model actually did to produce it.
|
|
930
|
+
function addMessage(role, text, meta, sessionId, toolLog) {
|
|
931
|
+
hideWelcome();
|
|
932
|
+
const row = document.createElement('div');
|
|
933
|
+
row.className = `msg ${role}`;
|
|
934
|
+
|
|
935
|
+
const who = document.createElement('div');
|
|
936
|
+
who.className = 'who';
|
|
937
|
+
who.textContent =
|
|
938
|
+
role === 'user' ? 'You' : role === 'assistant' ? 'AEGIS' : 'System';
|
|
939
|
+
|
|
940
|
+
const body = document.createElement('div');
|
|
941
|
+
body.className = 'body';
|
|
942
|
+
body.textContent = text;
|
|
943
|
+
|
|
944
|
+
row.appendChild(who);
|
|
945
|
+
if (Array.isArray(toolLog) && toolLog.length) {
|
|
946
|
+
const toolsEl = document.createElement('div');
|
|
947
|
+
toolsEl.className = 'tool-activity';
|
|
948
|
+
for (const t of toolLog) {
|
|
949
|
+
const line = document.createElement('div');
|
|
950
|
+
line.textContent = toolActivityLabel(t);
|
|
951
|
+
toolsEl.appendChild(line);
|
|
952
|
+
}
|
|
953
|
+
row.appendChild(toolsEl);
|
|
954
|
+
}
|
|
955
|
+
row.appendChild(body);
|
|
956
|
+
|
|
957
|
+
if (meta) {
|
|
958
|
+
const m = document.createElement('div');
|
|
959
|
+
m.className = 'meta';
|
|
960
|
+
m.textContent = meta;
|
|
961
|
+
row.appendChild(m);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (role === 'assistant' && sessionId) {
|
|
965
|
+
const copyBtn = document.createElement('button');
|
|
966
|
+
copyBtn.type = 'button';
|
|
967
|
+
copyBtn.className = 'copy-btn';
|
|
968
|
+
copyBtn.textContent = 'copy';
|
|
969
|
+
copyBtn.addEventListener('click', () => copyMessage(text, copyBtn));
|
|
970
|
+
row.appendChild(copyBtn);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
els.messages.appendChild(row);
|
|
974
|
+
els.messages.scrollTop = els.messages.scrollHeight;
|
|
975
|
+
return row;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** Copy one message's text to the clipboard, with brief button feedback. */
|
|
979
|
+
async function copyMessage(text, btn) {
|
|
980
|
+
try {
|
|
981
|
+
await navigator.clipboard.writeText(text);
|
|
982
|
+
if (btn) btn.textContent = 'copied!';
|
|
983
|
+
} catch (err) {
|
|
984
|
+
if (btn) btn.textContent = 'copy failed';
|
|
985
|
+
} finally {
|
|
986
|
+
if (btn) setTimeout(() => { btn.textContent = 'copy'; }, 1500);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Build one card of the lane. Each card is a self-contained stream slot: its
|
|
992
|
+
* own `.flow-body`, its own state chip and its own cancel button. The card is
|
|
993
|
+
* inserted into the track *before* the trailing "+ another path" button so the
|
|
994
|
+
* track always ends with the affordance that extends it.
|
|
995
|
+
*/
|
|
996
|
+
function flowCard(path, track, spec) {
|
|
997
|
+
const card = document.createElement('article');
|
|
998
|
+
card.className = 'flow-card pending';
|
|
999
|
+
card.dataset.path = path.key;
|
|
1000
|
+
|
|
1001
|
+
const head = document.createElement('div');
|
|
1002
|
+
head.className = 'flow-head';
|
|
1003
|
+
|
|
1004
|
+
const badge = document.createElement('span');
|
|
1005
|
+
badge.className = 'flow-badge';
|
|
1006
|
+
badge.textContent = path.badge;
|
|
1007
|
+
|
|
1008
|
+
const title = document.createElement('span');
|
|
1009
|
+
title.className = 'flow-title';
|
|
1010
|
+
title.textContent = path.title;
|
|
1011
|
+
|
|
1012
|
+
const state = document.createElement('span');
|
|
1013
|
+
state.className = 'flow-state';
|
|
1014
|
+
state.textContent = 'queued';
|
|
1015
|
+
|
|
1016
|
+
head.appendChild(badge);
|
|
1017
|
+
head.appendChild(title);
|
|
1018
|
+
head.appendChild(state);
|
|
1019
|
+
|
|
1020
|
+
const body = document.createElement('div');
|
|
1021
|
+
body.className = 'flow-body';
|
|
1022
|
+
body.textContent = '…';
|
|
1023
|
+
|
|
1024
|
+
const meta = document.createElement('div');
|
|
1025
|
+
meta.className = 'flow-meta';
|
|
1026
|
+
|
|
1027
|
+
const abort = document.createElement('button');
|
|
1028
|
+
abort.type = 'button';
|
|
1029
|
+
abort.className = 'ghost-btn flow-abort';
|
|
1030
|
+
abort.textContent = 'stop';
|
|
1031
|
+
abort.addEventListener('click', () => {
|
|
1032
|
+
if (card.dataset.session) models.cancel(card.dataset.session);
|
|
1033
|
+
state.textContent = 'stopped';
|
|
1034
|
+
card.classList.remove('pending');
|
|
1035
|
+
card.classList.add('stopped');
|
|
1036
|
+
abort.remove();
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
card.appendChild(head);
|
|
1040
|
+
card.appendChild(body);
|
|
1041
|
+
card.appendChild(meta);
|
|
1042
|
+
card.appendChild(abort);
|
|
1043
|
+
|
|
1044
|
+
const addBtn = track.querySelector('.flow-add');
|
|
1045
|
+
if (addBtn) track.insertBefore(card, addBtn);
|
|
1046
|
+
else track.appendChild(card);
|
|
1047
|
+
|
|
1048
|
+
spawnPath(card, { ...spec, path });
|
|
1049
|
+
return card;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Run one path: a streaming `models.chat` on a derived sessionId, rendered
|
|
1054
|
+
* into the card it owns. Fire-and-forget — the caller never awaits, so N paths
|
|
1055
|
+
* stream simultaneously while the vertical thread stays responsive.
|
|
1056
|
+
*/
|
|
1057
|
+
async function spawnPath(card, spec) {
|
|
1058
|
+
const id = `${spec.parentSessionId}::flow${++flowCount}`;
|
|
1059
|
+
card.dataset.session = id;
|
|
1060
|
+
activeBranches.add(id);
|
|
1061
|
+
|
|
1062
|
+
const body = card.querySelector('.flow-body');
|
|
1063
|
+
const state = card.querySelector('.flow-state');
|
|
1064
|
+
const meta = card.querySelector('.flow-meta');
|
|
1065
|
+
|
|
1066
|
+
let streamed = '';
|
|
1067
|
+
const onDelta = (chunk) => {
|
|
1068
|
+
if (chunk && chunk.tool) {
|
|
1069
|
+
if (card.classList.contains('pending')) {
|
|
1070
|
+
card.classList.remove('pending');
|
|
1071
|
+
state.textContent = 'streaming…';
|
|
1072
|
+
}
|
|
1073
|
+
appendToolActivity(card, chunk.tool, 'flow-tools', '.flow-body');
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
const delta =
|
|
1077
|
+
chunk && (typeof chunk.delta === 'string' ? chunk.delta : chunk.content);
|
|
1078
|
+
if (!delta) return;
|
|
1079
|
+
if (card.classList.contains('pending')) {
|
|
1080
|
+
card.classList.remove('pending');
|
|
1081
|
+
state.textContent = 'streaming…';
|
|
1082
|
+
}
|
|
1083
|
+
streamed += delta;
|
|
1084
|
+
body.textContent = streamed;
|
|
1085
|
+
// Follow the newest text sideways only while this card is the one being
|
|
1086
|
+
// read — horizontal auto-scroll that fights the user is worse than none.
|
|
1087
|
+
if (trackOf(card) && isTrailing(card)) {
|
|
1088
|
+
card.scrollIntoView({ block: 'nearest', inline: 'end' });
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
try {
|
|
1093
|
+
const data = await models.chat(
|
|
1094
|
+
{
|
|
1095
|
+
class: spec.cls,
|
|
1096
|
+
// The path instruction rides in the system prompt so the transcript
|
|
1097
|
+
// stays the user's own words; the echoed request follows it because
|
|
1098
|
+
// not every provider honours `system` (Ollama, some compat gateways).
|
|
1099
|
+
system: `${FLOW_SYSTEM}\n\n${spec.path.hint}`,
|
|
1100
|
+
prompt: `Original request:\n${spec.prompt}\n\n${spec.path.hint}`,
|
|
1101
|
+
model: spec.model,
|
|
1102
|
+
maxTokens: Math.min(spec.maxTokens || 1024, 1024),
|
|
1103
|
+
sessionId: id,
|
|
1104
|
+
},
|
|
1105
|
+
onDelta
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1108
|
+
const choice = (data && data.choices && data.choices[0]) || {};
|
|
1109
|
+
const text =
|
|
1110
|
+
(choice.message && choice.message.content) || streamed || '(no path found)';
|
|
1111
|
+
body.textContent = text;
|
|
1112
|
+
card.classList.remove('pending');
|
|
1113
|
+
card.classList.add('done');
|
|
1114
|
+
state.textContent = 'done';
|
|
1115
|
+
|
|
1116
|
+
const bits = [spec.path.title];
|
|
1117
|
+
if (data && data.model) bits.push(data.model);
|
|
1118
|
+
else if (spec.model) bits.push(spec.model);
|
|
1119
|
+
if (data && data.usage && data.usage.total_tokens != null) {
|
|
1120
|
+
bits.push(`${data.usage.total_tokens} tokens`);
|
|
1121
|
+
}
|
|
1122
|
+
meta.textContent = bits.join(' · ');
|
|
1123
|
+
} catch (err) {
|
|
1124
|
+
const message = err && err.message ? err.message : String(err);
|
|
1125
|
+
card.classList.remove('pending');
|
|
1126
|
+
card.classList.add('stopped');
|
|
1127
|
+
state.textContent = 'stopped';
|
|
1128
|
+
body.textContent = streamed || `Path unavailable: ${message}`;
|
|
1129
|
+
meta.textContent = spec.path.title;
|
|
1130
|
+
} finally {
|
|
1131
|
+
activeBranches.delete(id);
|
|
1132
|
+
const abort = card.querySelector('.flow-abort');
|
|
1133
|
+
if (abort) abort.remove();
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function trackOf(card) {
|
|
1138
|
+
return card.parentElement;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** True when nothing but the "+ another path" button follows this card. */
|
|
1142
|
+
function isTrailing(card) {
|
|
1143
|
+
const next = card.nextElementSibling;
|
|
1144
|
+
return !next || next.classList.contains('flow-add');
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* The lane itself: a horizontal track appended under the vertical thread.
|
|
1149
|
+
* `spec` carries the class/model/budget the user already chose, so a path is
|
|
1150
|
+
* generated by the same provider as the answer it sits beside.
|
|
1151
|
+
*/
|
|
1152
|
+
function addFlowLane(spec) {
|
|
1153
|
+
const lane = document.createElement('div');
|
|
1154
|
+
lane.className = 'chatflow';
|
|
1155
|
+
|
|
1156
|
+
const rail = document.createElement('div');
|
|
1157
|
+
rail.className = 'flow-rail';
|
|
1158
|
+
const label = document.createElement('span');
|
|
1159
|
+
label.className = 'flow-rail-label';
|
|
1160
|
+
label.textContent = 'discovery lane';
|
|
1161
|
+
const hint = document.createElement('span');
|
|
1162
|
+
hint.className = 'flow-rail-hint';
|
|
1163
|
+
hint.textContent = 'alternatives from the AI — scroll →';
|
|
1164
|
+
rail.appendChild(label);
|
|
1165
|
+
rail.appendChild(hint);
|
|
1166
|
+
|
|
1167
|
+
const track = document.createElement('div');
|
|
1168
|
+
track.className = 'flow-track';
|
|
1169
|
+
|
|
1170
|
+
const addBtn = document.createElement('button');
|
|
1171
|
+
addBtn.type = 'button';
|
|
1172
|
+
addBtn.className = 'ghost-btn flow-add';
|
|
1173
|
+
addBtn.textContent = '+ another path';
|
|
1174
|
+
addBtn.addEventListener('click', () => {
|
|
1175
|
+
// Cycle the presets so repeated clicks keep discovering new genres.
|
|
1176
|
+
const path = FLOW_PATHS[flowCount % FLOW_PATHS.length];
|
|
1177
|
+
flowCard(path, track, spec);
|
|
1178
|
+
});
|
|
1179
|
+
track.appendChild(addBtn);
|
|
1180
|
+
|
|
1181
|
+
lane.appendChild(rail);
|
|
1182
|
+
lane.appendChild(track);
|
|
1183
|
+
els.messages.appendChild(lane);
|
|
1184
|
+
|
|
1185
|
+
for (const path of FLOW_PATHS.slice(0, 2)) flowCard(path, track, spec);
|
|
1186
|
+
return lane;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function setBusy(busy, { cancellable } = {}) {
|
|
1190
|
+
els.send.disabled = busy;
|
|
1191
|
+
els.prompt.disabled = busy;
|
|
1192
|
+
if (busy) {
|
|
1193
|
+
pendingEl = addMessage('assistant', '');
|
|
1194
|
+
pendingEl.classList.add('pending');
|
|
1195
|
+
const dots = document.createElement('span');
|
|
1196
|
+
dots.className = 'typing-dots';
|
|
1197
|
+
for (let i = 0; i < 3; i++) dots.appendChild(document.createElement('span'));
|
|
1198
|
+
pendingEl.querySelector('.body').appendChild(dots);
|
|
1199
|
+
if (cancellable) {
|
|
1200
|
+
const cancelBtn = document.createElement('button');
|
|
1201
|
+
cancelBtn.type = 'button';
|
|
1202
|
+
cancelBtn.className = 'cancel-btn';
|
|
1203
|
+
cancelBtn.textContent = 'cancel';
|
|
1204
|
+
cancelBtn.addEventListener('click', () => {
|
|
1205
|
+
if (pendingSessionId) models.cancel(pendingSessionId);
|
|
1206
|
+
});
|
|
1207
|
+
pendingEl.appendChild(cancelBtn);
|
|
1208
|
+
}
|
|
1209
|
+
} else if (pendingEl) {
|
|
1210
|
+
pendingEl.remove();
|
|
1211
|
+
pendingEl = null;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* One display line for a completed tool call (`onDelta`'s `{ tool: {name,
|
|
1217
|
+
* args, ok} }` chunk — see desktop/lib/local/engine.js). Fires after the tool
|
|
1218
|
+
* already ran, so this is a retrospective log line, not a live spinner.
|
|
1219
|
+
*/
|
|
1220
|
+
function toolActivityLabel(tool) {
|
|
1221
|
+
const { name, args, ok } = tool || {};
|
|
1222
|
+
const mark = ok === false ? '✗' : '✓';
|
|
1223
|
+
const a = args || {};
|
|
1224
|
+
if (name === 'task') {
|
|
1225
|
+
const kind = a.subagent_type && a.subagent_type !== 'general' ? a.subagent_type : 'general';
|
|
1226
|
+
return `${mark} task → ${a.description || 'subagent'} (${kind})`;
|
|
1227
|
+
}
|
|
1228
|
+
const detail = a.file_path || a.path || a.pattern || (a.command ? a.command.slice(0, 60) : '') || '';
|
|
1229
|
+
return `${mark} ${name}${detail ? `(${detail})` : ''}`;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/** Append one tool-activity line to `row`, creating the container on first use. */
|
|
1233
|
+
function appendToolActivity(row, tool, containerClass, beforeSelector) {
|
|
1234
|
+
if (!row) return;
|
|
1235
|
+
let toolsEl = row.querySelector(`.${containerClass}`);
|
|
1236
|
+
if (!toolsEl) {
|
|
1237
|
+
toolsEl = document.createElement('div');
|
|
1238
|
+
toolsEl.className = containerClass;
|
|
1239
|
+
const before = beforeSelector ? row.querySelector(beforeSelector) : null;
|
|
1240
|
+
if (before) row.insertBefore(toolsEl, before);
|
|
1241
|
+
else row.appendChild(toolsEl);
|
|
1242
|
+
}
|
|
1243
|
+
const line = document.createElement('div');
|
|
1244
|
+
line.textContent = toolActivityLabel(tool);
|
|
1245
|
+
toolsEl.appendChild(line);
|
|
1246
|
+
return toolsEl;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function classLabel(cls) {
|
|
1250
|
+
const found = classOptions.find((c) => c.class === cls);
|
|
1251
|
+
return found ? found.label : cls;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function newSessionId() {
|
|
1255
|
+
try {
|
|
1256
|
+
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') {
|
|
1257
|
+
return globalThis.crypto.randomUUID();
|
|
1258
|
+
}
|
|
1259
|
+
} catch {
|
|
1260
|
+
/* fall through */
|
|
1261
|
+
}
|
|
1262
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
1263
|
+
const r = (Math.random() * 16) | 0;
|
|
1264
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
1265
|
+
return v.toString(16);
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// ------------------------------------------------------------- model classes
|
|
1270
|
+
|
|
1271
|
+
async function loadClasses() {
|
|
1272
|
+
try {
|
|
1273
|
+
classOptions = (await models.listClasses()) || [];
|
|
1274
|
+
} catch (err) {
|
|
1275
|
+
classOptions = [];
|
|
1276
|
+
els.modelHint.textContent =
|
|
1277
|
+
`listClasses failed: ${err && err.message ? err.message : err}`;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
els.classSelect.innerHTML = '';
|
|
1281
|
+
for (const c of classOptions) {
|
|
1282
|
+
const opt = document.createElement('option');
|
|
1283
|
+
opt.value = c.class;
|
|
1284
|
+
opt.textContent = c.configured
|
|
1285
|
+
? c.label
|
|
1286
|
+
: `${c.label} (not configured)`;
|
|
1287
|
+
els.classSelect.appendChild(opt);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const saved = localStorage.getItem(CLASS_KEY);
|
|
1291
|
+
if (saved && classOptions.some((c) => c.class === saved)) {
|
|
1292
|
+
els.classSelect.value = saved;
|
|
1293
|
+
}
|
|
1294
|
+
await loadModels(els.classSelect.value);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// Disable max-tokens options above the selected model's ceiling and clamp the
|
|
1298
|
+
// current selection down if it no longer fits; falls back to the flat 300k
|
|
1299
|
+
// ceiling (all options enabled) when no per-model metadata is known. When
|
|
1300
|
+
// "adaptive" is on, the manual select is irrelevant — the effective value
|
|
1301
|
+
// (returned here, read by send()) is always the model's own ceiling — so the
|
|
1302
|
+
// select is disabled rather than clamped.
|
|
1303
|
+
function applyMaxTokensClamp(modelId) {
|
|
1304
|
+
const meta = modelId ? modelMeta.get(modelId) : null;
|
|
1305
|
+
const ceiling = maxTokensCeiling(meta);
|
|
1306
|
+
const adaptive = maxTokensAdaptive();
|
|
1307
|
+
els.maxTokens.disabled = adaptive;
|
|
1308
|
+
for (const opt of els.maxTokens.options) {
|
|
1309
|
+
opt.disabled = Number(opt.value) > ceiling;
|
|
1310
|
+
}
|
|
1311
|
+
if (!adaptive && Number(els.maxTokens.value) > ceiling) {
|
|
1312
|
+
const enabled = Array.from(els.maxTokens.options).filter((o) => !o.disabled);
|
|
1313
|
+
const fallback = enabled[enabled.length - 1];
|
|
1314
|
+
if (fallback) {
|
|
1315
|
+
els.maxTokens.value = fallback.value;
|
|
1316
|
+
localStorage.setItem(MAX_TOKENS_KEY, els.maxTokens.value);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
return ceiling;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
async function loadModels(cls) {
|
|
1323
|
+
// "Work autonomously" only makes sense for the pooled AEGIS Cloud class —
|
|
1324
|
+
// hide it for Ollama/custom endpoints rather than showing a checkbox that
|
|
1325
|
+
// would silently do nothing.
|
|
1326
|
+
if (els.autonomousToggleWrap) {
|
|
1327
|
+
els.autonomousToggleWrap.hidden = cls !== AUTONOMOUS_CLASS;
|
|
1328
|
+
}
|
|
1329
|
+
updateAutonomousControlsVisibility();
|
|
1330
|
+
|
|
1331
|
+
const custom = CUSTOM_CLASSES.has(cls);
|
|
1332
|
+
els.modelSelect.hidden = custom;
|
|
1333
|
+
els.modelSelect.disabled = custom;
|
|
1334
|
+
els.modelInput.hidden = !custom;
|
|
1335
|
+
els.modelInput.disabled = !custom;
|
|
1336
|
+
els.modelPreset.hidden = true;
|
|
1337
|
+
els.modelHint.textContent = '';
|
|
1338
|
+
|
|
1339
|
+
if (custom) {
|
|
1340
|
+
modelMeta = new Map();
|
|
1341
|
+
applyMaxTokensClamp(null);
|
|
1342
|
+
let cfg = { baseURL: '', configured: false, keyMask: null };
|
|
1343
|
+
try {
|
|
1344
|
+
const settings = (await models.settings.get()) || [];
|
|
1345
|
+
cfg = (Array.isArray(settings) && settings.find((s) => s.provider === cls)) || cfg;
|
|
1346
|
+
} catch {
|
|
1347
|
+
/* settings unavailable — leave hint below */
|
|
1348
|
+
}
|
|
1349
|
+
// The engine lists no models for a custom endpoint: the only usable id is
|
|
1350
|
+
// one the user types (it used to offer the base URL as an id, which POSTed
|
|
1351
|
+
// `model: "<url>"` and 400'd upstream). `needsModelId` is what turns this
|
|
1352
|
+
// into an explicit "type a model id" prompt rather than an empty picker.
|
|
1353
|
+
let needsModelId = true;
|
|
1354
|
+
try {
|
|
1355
|
+
const data = await models.listModels(cls);
|
|
1356
|
+
if (data && typeof data.needsModelId === 'boolean') needsModelId = data.needsModelId;
|
|
1357
|
+
if (data && typeof data.baseURL === 'string' && data.baseURL) {
|
|
1358
|
+
cfg = { ...cfg, baseURL: data.baseURL };
|
|
1359
|
+
}
|
|
1360
|
+
const list = Array.isArray(data && data.models) ? data.models : [];
|
|
1361
|
+
if (list.length) {
|
|
1362
|
+
// A custom class that does enumerate models (a future provider) still
|
|
1363
|
+
// gets a picker; the typed input is only for the unlistable case.
|
|
1364
|
+
needsModelId = false;
|
|
1365
|
+
els.modelSelect.hidden = false;
|
|
1366
|
+
els.modelSelect.disabled = false;
|
|
1367
|
+
els.modelInput.hidden = true;
|
|
1368
|
+
els.modelInput.disabled = true;
|
|
1369
|
+
els.modelSelect.innerHTML = '';
|
|
1370
|
+
for (const m of list) {
|
|
1371
|
+
modelMeta.set(m.id, m);
|
|
1372
|
+
const opt = document.createElement('option');
|
|
1373
|
+
opt.value = m.id;
|
|
1374
|
+
opt.textContent = m.label || m.id;
|
|
1375
|
+
els.modelSelect.appendChild(opt);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
} catch {
|
|
1379
|
+
/* engine unavailable — fall back to settings + the typed input */
|
|
1380
|
+
}
|
|
1381
|
+
// Quick-fill presets only make sense while the id is still hand-typed —
|
|
1382
|
+
// a class that starts enumerating real models (needsModelId false) gets
|
|
1383
|
+
// a proper picker above instead, so the preset list would be redundant.
|
|
1384
|
+
const presets = needsModelId ? CUSTOM_MODEL_PRESETS[cls] || [] : [];
|
|
1385
|
+
els.modelPreset.hidden = presets.length === 0;
|
|
1386
|
+
if (presets.length) {
|
|
1387
|
+
els.modelPreset.innerHTML = '';
|
|
1388
|
+
const placeholder = document.createElement('option');
|
|
1389
|
+
placeholder.value = '';
|
|
1390
|
+
placeholder.textContent = 'quick pick…';
|
|
1391
|
+
placeholder.disabled = true;
|
|
1392
|
+
placeholder.selected = true;
|
|
1393
|
+
els.modelPreset.appendChild(placeholder);
|
|
1394
|
+
for (const preset of presets) {
|
|
1395
|
+
const opt = document.createElement('option');
|
|
1396
|
+
opt.value = preset.model;
|
|
1397
|
+
opt.textContent = preset.label;
|
|
1398
|
+
opt.title = preset.baseURL;
|
|
1399
|
+
els.modelPreset.appendChild(opt);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
els.modelInput.placeholder = needsModelId
|
|
1403
|
+
? MODEL_ID_PLACEHOLDER[cls] || 'type a model id'
|
|
1404
|
+
: 'model id';
|
|
1405
|
+
els.modelHint.textContent = cfg.baseURL
|
|
1406
|
+
? `endpoint: ${cfg.baseURL} · key: ${cfg.configured ? cfg.keyMask : 'not set'}` +
|
|
1407
|
+
(needsModelId ? ' · type a model id above' : '')
|
|
1408
|
+
: 'Set base URL + key in Provider settings, then type a model id.';
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
els.modelSelect.innerHTML = '';
|
|
1413
|
+
modelMeta = new Map();
|
|
1414
|
+
if (cls === 'aegis') {
|
|
1415
|
+
const auto = document.createElement('option');
|
|
1416
|
+
auto.value = '';
|
|
1417
|
+
auto.textContent = 'server default (auto)';
|
|
1418
|
+
els.modelSelect.appendChild(auto);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
try {
|
|
1422
|
+
const data = await models.listModels(cls);
|
|
1423
|
+
const list = Array.isArray(data && data.models) ? data.models : [];
|
|
1424
|
+
for (const m of list) {
|
|
1425
|
+
modelMeta.set(m.id, m);
|
|
1426
|
+
const opt = document.createElement('option');
|
|
1427
|
+
opt.value = m.id;
|
|
1428
|
+
opt.textContent = m.label || m.id;
|
|
1429
|
+
els.modelSelect.appendChild(opt);
|
|
1430
|
+
}
|
|
1431
|
+
let hint;
|
|
1432
|
+
if (!list.length) {
|
|
1433
|
+
hint = cls === 'ollama' ? 'Ollama not running or no models pulled.' : 'No models listed.';
|
|
1434
|
+
} else {
|
|
1435
|
+
hint = `${list.length} model${list.length === 1 ? '' : 's'} available.`;
|
|
1436
|
+
}
|
|
1437
|
+
const ceiling = applyMaxTokensClamp(els.modelSelect.value);
|
|
1438
|
+
els.modelHint.textContent =
|
|
1439
|
+
ceiling < FLAT_CEILING ? `${hint} · max output: ${ceiling.toLocaleString()}` : hint;
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
modelMeta = new Map();
|
|
1442
|
+
applyMaxTokensClamp(null);
|
|
1443
|
+
els.modelHint.textContent =
|
|
1444
|
+
`listModels failed: ${err && err.message ? err.message : err}`;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* Quick-fill a Model-card preset: sets the typed model id, and fills the
|
|
1450
|
+
* matching Provider-settings base URL field IF it's currently empty. An
|
|
1451
|
+
* already-configured base URL is left alone (never silently overwritten) —
|
|
1452
|
+
* if it doesn't match what the preset expects, the hint says so instead, so
|
|
1453
|
+
* the user's own custom endpoint can't be clobbered by a stray click.
|
|
1454
|
+
*/
|
|
1455
|
+
function applyCustomPreset(cls, modelId) {
|
|
1456
|
+
const preset = (CUSTOM_MODEL_PRESETS[cls] || []).find((p) => p.model === modelId);
|
|
1457
|
+
if (!preset) return;
|
|
1458
|
+
els.modelInput.value = preset.model;
|
|
1459
|
+
|
|
1460
|
+
const row = els.settingsList.querySelector(`.setting-row[data-provider="${cls}"]`);
|
|
1461
|
+
const baseInput = row && row.querySelector('.setting-base');
|
|
1462
|
+
if (!baseInput) return;
|
|
1463
|
+
const current = baseInput.value.trim();
|
|
1464
|
+
if (!current) {
|
|
1465
|
+
baseInput.value = preset.baseURL;
|
|
1466
|
+
els.modelHint.textContent = `filled in — click Save in Provider settings below to store the ${preset.label} endpoint.`;
|
|
1467
|
+
} else if (current !== preset.baseURL) {
|
|
1468
|
+
els.modelHint.textContent =
|
|
1469
|
+
`${preset.label} needs base URL ${preset.baseURL} — Provider settings below has ${current}. Update it there too.`;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// -------------------------------------------------------------- settings pane
|
|
1474
|
+
|
|
1475
|
+
async function loadSettings() {
|
|
1476
|
+
els.settingsList.innerHTML = '';
|
|
1477
|
+
let settings = [];
|
|
1478
|
+
try {
|
|
1479
|
+
settings = (await models.settings.get()) || [];
|
|
1480
|
+
} catch {
|
|
1481
|
+
/* leave empty */
|
|
1482
|
+
}
|
|
1483
|
+
if (Array.isArray(settings)) {
|
|
1484
|
+
settings = settings.filter((s) => s && !RESERVED_PROVIDERS.has(s.provider));
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
const providers = [
|
|
1488
|
+
{ provider: 'openai-compat', name: 'OpenAI-compatible' },
|
|
1489
|
+
{ provider: 'anthropic', name: 'Anthropic-compatible' },
|
|
1490
|
+
];
|
|
1491
|
+
|
|
1492
|
+
for (const { provider, name } of providers) {
|
|
1493
|
+
const cfg = settings.find((s) => s.provider === provider) || {
|
|
1494
|
+
provider,
|
|
1495
|
+
baseURL: '',
|
|
1496
|
+
configured: false,
|
|
1497
|
+
keyMask: null,
|
|
1498
|
+
};
|
|
1499
|
+
|
|
1500
|
+
const row = document.createElement('div');
|
|
1501
|
+
row.className = 'setting-row';
|
|
1502
|
+
// Targeted by applyCustomPreset() so picking a Model-card preset can
|
|
1503
|
+
// quick-fill the matching base URL here without a full loadSettings()
|
|
1504
|
+
// round trip.
|
|
1505
|
+
row.dataset.provider = provider;
|
|
1506
|
+
|
|
1507
|
+
const label = document.createElement('div');
|
|
1508
|
+
label.className = 'setting-name';
|
|
1509
|
+
label.textContent = name;
|
|
1510
|
+
row.appendChild(label);
|
|
1511
|
+
|
|
1512
|
+
const baseInput = document.createElement('input');
|
|
1513
|
+
baseInput.type = 'text';
|
|
1514
|
+
baseInput.className = 'setting-input setting-base';
|
|
1515
|
+
baseInput.placeholder = 'base URL';
|
|
1516
|
+
baseInput.value = cfg.baseURL || '';
|
|
1517
|
+
row.appendChild(baseInput);
|
|
1518
|
+
|
|
1519
|
+
const keyInput = document.createElement('input');
|
|
1520
|
+
keyInput.type = 'password';
|
|
1521
|
+
keyInput.className = 'setting-input';
|
|
1522
|
+
keyInput.placeholder = cfg.configured
|
|
1523
|
+
? `key ${cfg.keyMask} (blank = keep)`
|
|
1524
|
+
: 'API key';
|
|
1525
|
+
row.appendChild(keyInput);
|
|
1526
|
+
|
|
1527
|
+
const status = document.createElement('div');
|
|
1528
|
+
status.className = 'setting-status';
|
|
1529
|
+
status.textContent = cfg.configured ? `configured (${cfg.keyMask})` : 'no key';
|
|
1530
|
+
row.appendChild(status);
|
|
1531
|
+
|
|
1532
|
+
const actions = document.createElement('div');
|
|
1533
|
+
actions.className = 'setting-actions';
|
|
1534
|
+
|
|
1535
|
+
const saveBtn = document.createElement('button');
|
|
1536
|
+
saveBtn.type = 'button';
|
|
1537
|
+
saveBtn.className = 'ghost-btn';
|
|
1538
|
+
saveBtn.textContent = 'Save';
|
|
1539
|
+
saveBtn.addEventListener('click', () =>
|
|
1540
|
+
saveSetting(provider, baseInput.value.trim(), keyInput.value)
|
|
1541
|
+
);
|
|
1542
|
+
actions.appendChild(saveBtn);
|
|
1543
|
+
|
|
1544
|
+
const removeBtn = document.createElement('button');
|
|
1545
|
+
removeBtn.type = 'button';
|
|
1546
|
+
removeBtn.className = 'ghost-btn danger';
|
|
1547
|
+
removeBtn.textContent = 'Remove';
|
|
1548
|
+
removeBtn.disabled = !cfg.configured;
|
|
1549
|
+
removeBtn.addEventListener('click', () => removeSetting(provider));
|
|
1550
|
+
actions.appendChild(removeBtn);
|
|
1551
|
+
|
|
1552
|
+
row.appendChild(actions);
|
|
1553
|
+
els.settingsList.appendChild(row);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
async function saveSetting(provider, baseURL, key) {
|
|
1558
|
+
els.settingsHint.textContent = 'saving…';
|
|
1559
|
+
try {
|
|
1560
|
+
const cfg = { baseURL };
|
|
1561
|
+
if (key) cfg.key = key;
|
|
1562
|
+
await models.settings.set(provider, cfg);
|
|
1563
|
+
els.settingsHint.textContent = 'saved.';
|
|
1564
|
+
await loadSettings();
|
|
1565
|
+
await loadModels(els.classSelect.value);
|
|
1566
|
+
} catch (err) {
|
|
1567
|
+
els.settingsHint.textContent =
|
|
1568
|
+
`save failed: ${err && err.message ? err.message : err}`;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
async function removeSetting(provider) {
|
|
1573
|
+
els.settingsHint.textContent = 'removing…';
|
|
1574
|
+
try {
|
|
1575
|
+
await models.settings.remove(provider);
|
|
1576
|
+
els.settingsHint.textContent = 'removed.';
|
|
1577
|
+
await loadSettings();
|
|
1578
|
+
await loadModels(els.classSelect.value);
|
|
1579
|
+
} catch (err) {
|
|
1580
|
+
els.settingsHint.textContent =
|
|
1581
|
+
`remove failed: ${err && err.message ? err.message : err}`;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// -------------------------------------------------------------- sessions pane
|
|
1586
|
+
|
|
1587
|
+
async function loadSessions() {
|
|
1588
|
+
els.sessionsList.innerHTML = '';
|
|
1589
|
+
els.sessionsHint.textContent = '';
|
|
1590
|
+
let sessions = [];
|
|
1591
|
+
try {
|
|
1592
|
+
const data = await sync.listSessions();
|
|
1593
|
+
sessions = (data && data.sessions) || [];
|
|
1594
|
+
} catch (err) {
|
|
1595
|
+
els.sessionsHint.textContent =
|
|
1596
|
+
`list failed: ${err && err.message ? err.message : err}`;
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
if (!sessions.length) {
|
|
1601
|
+
const li = document.createElement('li');
|
|
1602
|
+
li.className = 'empty';
|
|
1603
|
+
li.textContent = 'no sessions yet';
|
|
1604
|
+
els.sessionsList.appendChild(li);
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
for (const s of sessions) {
|
|
1609
|
+
const li = document.createElement('li');
|
|
1610
|
+
li.className = 'session-row';
|
|
1611
|
+
li.tabIndex = 0;
|
|
1612
|
+
|
|
1613
|
+
const title = document.createElement('span');
|
|
1614
|
+
title.className = 'session-title';
|
|
1615
|
+
title.textContent = (s && s.title) || (s && s.id) || 'untitled';
|
|
1616
|
+
li.appendChild(title);
|
|
1617
|
+
|
|
1618
|
+
const meta = document.createElement('span');
|
|
1619
|
+
meta.className = 'session-meta';
|
|
1620
|
+
const count = (s && Array.isArray(s.messages) && s.messages.length) || 0;
|
|
1621
|
+
const when = (s && s.updatedAt)
|
|
1622
|
+
? new Date(s.updatedAt).toLocaleTimeString()
|
|
1623
|
+
: '';
|
|
1624
|
+
meta.textContent =
|
|
1625
|
+
`${count} msg${count === 1 ? '' : 's'}${when ? ' · ' + when : ''}`;
|
|
1626
|
+
li.appendChild(meta);
|
|
1627
|
+
|
|
1628
|
+
li.addEventListener('click', () => openSession(s.id));
|
|
1629
|
+
els.sessionsList.appendChild(li);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
function renderSyncStatus(status) {
|
|
1634
|
+
if (!els.syncStatus) return;
|
|
1635
|
+
if (!status) {
|
|
1636
|
+
els.syncStatus.textContent = '';
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
const bits = [status.cloud ? 'cloud sync on' : 'cloud sync off (no key)', `${status.pending} pending`];
|
|
1640
|
+
if (status.lastSyncAt) {
|
|
1641
|
+
bits.push(`last synced ${new Date(status.lastSyncAt).toLocaleTimeString()}`);
|
|
1642
|
+
}
|
|
1643
|
+
els.syncStatus.textContent = bits.join(' · ');
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
async function refreshSyncStatus() {
|
|
1647
|
+
try {
|
|
1648
|
+
renderSyncStatus(await sync.status());
|
|
1649
|
+
} catch {
|
|
1650
|
+
renderSyncStatus(null);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
/** Explicit "Sync now" — push pending sessions, then pull remote ones.
|
|
1655
|
+
* Offline-first: sync.push()/sync.pull() resolve `{ ok:false, reason }`
|
|
1656
|
+
* rather than throwing, so this only hits the catch on an unexpected error. */
|
|
1657
|
+
async function syncNow() {
|
|
1658
|
+
els.syncNow.disabled = true;
|
|
1659
|
+
els.sessionsHint.textContent = 'syncing…';
|
|
1660
|
+
try {
|
|
1661
|
+
const pushResult = await sync.push();
|
|
1662
|
+
const pullResult = await sync.pull();
|
|
1663
|
+
if (pushResult.upgrade || pullResult.upgrade) {
|
|
1664
|
+
// The queued memory-save flush hit the free-plan cap. main.js stops the
|
|
1665
|
+
// flush at that point rather than reporting a clean "synced" while every
|
|
1666
|
+
// entry silently stays queued — point at the plan page instead.
|
|
1667
|
+
capNotice(
|
|
1668
|
+
els.sessionsHint,
|
|
1669
|
+
pushResult.upgrade || pullResult.upgrade,
|
|
1670
|
+
'queued memory is waiting on the free-plan cap',
|
|
1671
|
+
'Upgrade to sync it →'
|
|
1672
|
+
);
|
|
1673
|
+
} else if (!pushResult.ok && !pullResult.ok) {
|
|
1674
|
+
els.sessionsHint.textContent =
|
|
1675
|
+
`sync failed: ${pushResult.reason || pullResult.reason || 'unknown error'}`;
|
|
1676
|
+
} else {
|
|
1677
|
+
els.sessionsHint.textContent =
|
|
1678
|
+
`synced (pushed ${pushResult.pushed || 0}, pulled ${pullResult.merged || 0})`;
|
|
1679
|
+
}
|
|
1680
|
+
} catch (err) {
|
|
1681
|
+
els.sessionsHint.textContent = `sync failed: ${err && err.message ? err.message : err}`;
|
|
1682
|
+
} finally {
|
|
1683
|
+
els.syncNow.disabled = false;
|
|
1684
|
+
await refreshSyncStatus();
|
|
1685
|
+
await loadSessions();
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
function openSession(id) {
|
|
1690
|
+
sync.open(id)
|
|
1691
|
+
.then((s) => {
|
|
1692
|
+
if (!s) {
|
|
1693
|
+
els.sessionsHint.textContent = 'session not found';
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
els.messages.innerHTML = '';
|
|
1697
|
+
const msgs = Array.isArray(s.messages) ? s.messages : [];
|
|
1698
|
+
if (!msgs.length) renderWelcome();
|
|
1699
|
+
for (const m of msgs) {
|
|
1700
|
+
const role =
|
|
1701
|
+
m.role === 'assistant' ? 'assistant'
|
|
1702
|
+
: m.role === 'system' ? 'system'
|
|
1703
|
+
: 'user';
|
|
1704
|
+
addMessage(role, m.content || m.text || '', undefined, role === 'assistant' ? s.id : undefined);
|
|
1705
|
+
}
|
|
1706
|
+
els.sessionsHint.textContent = `opened ${s.id.slice(0, 8)}…`;
|
|
1707
|
+
})
|
|
1708
|
+
.catch((err) => {
|
|
1709
|
+
els.sessionsHint.textContent =
|
|
1710
|
+
`open failed: ${err && err.message ? err.message : err}`;
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
function newChat() {
|
|
1715
|
+
abortBranches();
|
|
1716
|
+
renderWelcome();
|
|
1717
|
+
els.sessionsHint.textContent = '';
|
|
1718
|
+
pendingEl = null;
|
|
1719
|
+
pendingSessionId = null;
|
|
1720
|
+
flowCount = 0;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// ------------------------------------------------------------------ actions
|
|
1724
|
+
|
|
1725
|
+
async function send() {
|
|
1726
|
+
const prompt = els.prompt.value.trim();
|
|
1727
|
+
if (!prompt || els.send.disabled) return;
|
|
1728
|
+
|
|
1729
|
+
const cls = els.classSelect.value;
|
|
1730
|
+
const model = CUSTOM_CLASSES.has(cls)
|
|
1731
|
+
? els.modelInput.value.trim()
|
|
1732
|
+
: els.modelSelect.value;
|
|
1733
|
+
// A custom endpoint has no default model, and a blank id reaches the
|
|
1734
|
+
// provider as `model: undefined` (defect B). Ask for it instead of sending.
|
|
1735
|
+
if (CUSTOM_CLASSES.has(cls) && !model) {
|
|
1736
|
+
els.modelHint.textContent =
|
|
1737
|
+
MODEL_ID_PLACEHOLDER[cls] || 'type a model id before sending.';
|
|
1738
|
+
els.modelInput.focus();
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
els.prompt.value = '';
|
|
1743
|
+
addMessage('user', prompt);
|
|
1744
|
+
|
|
1745
|
+
const ceiling = applyMaxTokensClamp(model);
|
|
1746
|
+
const maxTokens = maxTokensAdaptive() ? ceiling : parseInt(els.maxTokens.value, 10) || 4096;
|
|
1747
|
+
const autonomous = cls === AUTONOMOUS_CLASS && autonomousEnabled();
|
|
1748
|
+
// Only meaningful (and only sent) alongside `autonomous` — see
|
|
1749
|
+
// aegis1 services/pool_brain.py parse_brain_request for the effort/workers
|
|
1750
|
+
// clamping this feeds.
|
|
1751
|
+
const effort = autonomous ? els.autonomousEffort.value : undefined;
|
|
1752
|
+
const workers = autonomous ? parseInt(els.autonomousWorkers.value, 10) || undefined : undefined;
|
|
1753
|
+
const sessionId = newSessionId();
|
|
1754
|
+
pendingSessionId = sessionId;
|
|
1755
|
+
|
|
1756
|
+
setBusy(true, { cancellable: true });
|
|
1757
|
+
|
|
1758
|
+
// Persist the user turn locally (best-effort — never blocks chat).
|
|
1759
|
+
try {
|
|
1760
|
+
await sync.append(sessionId, { role: 'user', content: prompt });
|
|
1761
|
+
} catch {
|
|
1762
|
+
/* persistence is non-fatal */
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
let streamedText = '';
|
|
1766
|
+
const toolLog = [];
|
|
1767
|
+
const onDelta = (chunk) => {
|
|
1768
|
+
if (chunk && chunk.tool) {
|
|
1769
|
+
toolLog.push(chunk.tool);
|
|
1770
|
+
if (pendingEl) {
|
|
1771
|
+
pendingEl.classList.remove('pending');
|
|
1772
|
+
appendToolActivity(pendingEl, chunk.tool, 'tool-activity', '.body');
|
|
1773
|
+
els.messages.scrollTop = els.messages.scrollHeight;
|
|
1774
|
+
}
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
const delta =
|
|
1778
|
+
chunk && (typeof chunk.delta === 'string' ? chunk.delta : chunk.content);
|
|
1779
|
+
if (!delta) return;
|
|
1780
|
+
streamedText += delta;
|
|
1781
|
+
if (!pendingEl) return;
|
|
1782
|
+
pendingEl.classList.remove('pending');
|
|
1783
|
+
const bodyEl = pendingEl.querySelector('.body');
|
|
1784
|
+
if (bodyEl) bodyEl.textContent = streamedText;
|
|
1785
|
+
els.messages.scrollTop = els.messages.scrollHeight;
|
|
1786
|
+
};
|
|
1787
|
+
|
|
1788
|
+
try {
|
|
1789
|
+
// All four classes route through the model: surface (the main process
|
|
1790
|
+
// decides transport — cloud client, ollama, or a direct provider).
|
|
1791
|
+
const data = await models.chat(
|
|
1792
|
+
{ class: cls, prompt, model, maxTokens, sessionId, autonomous, effort, workers },
|
|
1793
|
+
onDelta
|
|
1794
|
+
);
|
|
1795
|
+
|
|
1796
|
+
const choice = (data && data.choices && data.choices[0]) || {};
|
|
1797
|
+
const text =
|
|
1798
|
+
(choice.message && choice.message.content) ||
|
|
1799
|
+
streamedText ||
|
|
1800
|
+
'(empty response)';
|
|
1801
|
+
const bits = [];
|
|
1802
|
+
if (data && data.model) bits.push(`model: ${data.model}`);
|
|
1803
|
+
else if (model) bits.push(`model: ${model}`);
|
|
1804
|
+
bits.push(classLabel(cls));
|
|
1805
|
+
if (autonomous) bits.push(`autonomous (${effort}, ${workers || 3}w)`);
|
|
1806
|
+
if (data && data.usage && data.usage.total_tokens != null) {
|
|
1807
|
+
bits.push(`tokens: ${data.usage.total_tokens}`);
|
|
1808
|
+
}
|
|
1809
|
+
addMessage('assistant', text, bits.join(' · ') || undefined, sessionId, toolLog);
|
|
1810
|
+
|
|
1811
|
+
try {
|
|
1812
|
+
await sync.append(sessionId, { role: 'assistant', content: text });
|
|
1813
|
+
await sync.save({ id: sessionId, title: prompt.slice(0, 60) });
|
|
1814
|
+
} catch {
|
|
1815
|
+
/* persistence is non-fatal */
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// The AI's second path: not awaited — the lane streams beside the thread
|
|
1819
|
+
// while the composer goes straight back to the user (chat flow D2.2).
|
|
1820
|
+
if (exploreEnabled() && text !== '(empty response)') {
|
|
1821
|
+
addFlowLane({ prompt, cls, model, maxTokens, parentSessionId: sessionId });
|
|
1822
|
+
}
|
|
1823
|
+
} catch (err) {
|
|
1824
|
+
addMessage(
|
|
1825
|
+
'assistant',
|
|
1826
|
+
`Error: ${err && err.message ? err.message : err}`,
|
|
1827
|
+
'request failed'
|
|
1828
|
+
);
|
|
1829
|
+
} finally {
|
|
1830
|
+
setBusy(false);
|
|
1831
|
+
pendingSessionId = null;
|
|
1832
|
+
loadSessions();
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
// --------------------------------------------------------------------- boot
|
|
1837
|
+
|
|
1838
|
+
async function init() {
|
|
1839
|
+
try {
|
|
1840
|
+
renderStatus(await aegis.status());
|
|
1841
|
+
} catch {
|
|
1842
|
+
renderStatus(null);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
const savedMax = localStorage.getItem(MAX_TOKENS_KEY);
|
|
1846
|
+
if (savedMax) els.maxTokens.value = savedMax;
|
|
1847
|
+
|
|
1848
|
+
els.maxTokens.addEventListener('change', () => {
|
|
1849
|
+
localStorage.setItem(MAX_TOKENS_KEY, els.maxTokens.value);
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
// Adaptive max tokens is opt-in per machine, remembered across restarts.
|
|
1853
|
+
const savedAdaptive = localStorage.getItem(MAX_TOKENS_ADAPTIVE_KEY);
|
|
1854
|
+
if (savedAdaptive === 'on') els.maxTokensAdaptive.checked = true;
|
|
1855
|
+
els.maxTokensAdaptive.addEventListener('change', () => {
|
|
1856
|
+
localStorage.setItem(MAX_TOKENS_ADAPTIVE_KEY, els.maxTokensAdaptive.checked ? 'on' : 'off');
|
|
1857
|
+
const cls = els.classSelect.value;
|
|
1858
|
+
const modelId = CUSTOM_CLASSES.has(cls) ? els.modelInput.value.trim() : els.modelSelect.value;
|
|
1859
|
+
applyMaxTokensClamp(modelId);
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
// Work-autonomously is opt-in per machine, remembered across restarts;
|
|
1863
|
+
// only ever sent when the active class is AEGIS Cloud (see AUTONOMOUS_CLASS).
|
|
1864
|
+
const savedAutonomous = localStorage.getItem(AUTONOMOUS_KEY);
|
|
1865
|
+
if (savedAutonomous === 'on') els.autonomousToggle.checked = true;
|
|
1866
|
+
els.autonomousToggle.addEventListener('change', () => {
|
|
1867
|
+
localStorage.setItem(AUTONOMOUS_KEY, els.autonomousToggle.checked ? 'on' : 'off');
|
|
1868
|
+
updateAutonomousControlsVisibility();
|
|
1869
|
+
});
|
|
1870
|
+
|
|
1871
|
+
// Effort/worker count for the pool_brain fan-out (aegis1
|
|
1872
|
+
// services/pool_brain.py parse_brain_request reads `effort`/`workers` off
|
|
1873
|
+
// the request body) — opt-in per machine, remembered across restarts.
|
|
1874
|
+
const savedEffort = localStorage.getItem(AUTONOMOUS_EFFORT_KEY);
|
|
1875
|
+
if (savedEffort) els.autonomousEffort.value = savedEffort;
|
|
1876
|
+
els.autonomousEffort.addEventListener('change', () => {
|
|
1877
|
+
localStorage.setItem(AUTONOMOUS_EFFORT_KEY, els.autonomousEffort.value);
|
|
1878
|
+
});
|
|
1879
|
+
const savedWorkers = localStorage.getItem(AUTONOMOUS_WORKERS_KEY);
|
|
1880
|
+
if (savedWorkers) els.autonomousWorkers.value = savedWorkers;
|
|
1881
|
+
els.autonomousWorkers.addEventListener('change', () => {
|
|
1882
|
+
localStorage.setItem(AUTONOMOUS_WORKERS_KEY, els.autonomousWorkers.value);
|
|
1883
|
+
});
|
|
1884
|
+
updateAutonomousControlsVisibility();
|
|
1885
|
+
|
|
1886
|
+
// The discovery lane is opt-in per machine, remembered across restarts.
|
|
1887
|
+
const savedExplore = localStorage.getItem(EXPLORE_KEY);
|
|
1888
|
+
if (savedExplore === 'off') els.exploreToggle.checked = false;
|
|
1889
|
+
els.exploreToggle.addEventListener('change', () => {
|
|
1890
|
+
localStorage.setItem(EXPLORE_KEY, els.exploreToggle.checked ? 'on' : 'off');
|
|
1891
|
+
if (!els.exploreToggle.checked) abortBranches();
|
|
1892
|
+
});
|
|
1893
|
+
|
|
1894
|
+
els.classSelect.addEventListener('change', () => {
|
|
1895
|
+
localStorage.setItem(CLASS_KEY, els.classSelect.value);
|
|
1896
|
+
loadModels(els.classSelect.value);
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
els.modelSelect.addEventListener('change', () => {
|
|
1900
|
+
const ceiling = applyMaxTokensClamp(els.modelSelect.value);
|
|
1901
|
+
const base = els.modelHint.textContent.replace(/ · max output: [\d,]+$/, '');
|
|
1902
|
+
els.modelHint.textContent =
|
|
1903
|
+
ceiling < FLAT_CEILING ? `${base} · max output: ${ceiling.toLocaleString()}` : base;
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
els.modelPreset.addEventListener('change', () => {
|
|
1907
|
+
applyCustomPreset(els.classSelect.value, els.modelPreset.value);
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
els.newChat.addEventListener('click', newChat);
|
|
1911
|
+
els.sessionsRefresh.addEventListener('click', loadSessions);
|
|
1912
|
+
els.syncNow.addEventListener('click', syncNow);
|
|
1913
|
+
|
|
1914
|
+
els.apiKeySave.addEventListener('click', saveApiKey);
|
|
1915
|
+
els.apiKeyVerify.addEventListener('click', verifyAegisKey);
|
|
1916
|
+
els.apiKeyInput.addEventListener('keydown', (e) => {
|
|
1917
|
+
if (e.key === 'Enter') {
|
|
1918
|
+
e.preventDefault();
|
|
1919
|
+
saveApiKey();
|
|
1920
|
+
}
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
els.composer.addEventListener('submit', (e) => {
|
|
1924
|
+
e.preventDefault();
|
|
1925
|
+
send();
|
|
1926
|
+
});
|
|
1927
|
+
|
|
1928
|
+
els.prompt.addEventListener('keydown', (e) => {
|
|
1929
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1930
|
+
e.preventDefault();
|
|
1931
|
+
els.composer.requestSubmit();
|
|
1932
|
+
}
|
|
1933
|
+
});
|
|
1934
|
+
|
|
1935
|
+
els.memorySearchForm.addEventListener('submit', (e) => {
|
|
1936
|
+
e.preventDefault();
|
|
1937
|
+
searchMemory(els.memoryQuery.value.trim());
|
|
1938
|
+
});
|
|
1939
|
+
|
|
1940
|
+
els.memorySaveBtn.addEventListener('click', saveMemory);
|
|
1941
|
+
els.memoryImportBtn.addEventListener('click', importMemory);
|
|
1942
|
+
|
|
1943
|
+
// Memory inspector. `?`-guarded: the overlay markup is optional, and a
|
|
1944
|
+
// missing node must degrade to the plain sidebar card, not a boot crash.
|
|
1945
|
+
if (els.memoryOpen) els.memoryOpen.addEventListener('click', openMemoryOverlay);
|
|
1946
|
+
if (els.memoryClose) els.memoryClose.addEventListener('click', closeMemoryOverlay);
|
|
1947
|
+
if (els.memoryBackdrop) els.memoryBackdrop.addEventListener('click', closeMemoryOverlay);
|
|
1948
|
+
if (els.memoryOverlaySave) els.memoryOverlaySave.addEventListener('click', saveMemory);
|
|
1949
|
+
if (els.memoryFilters) {
|
|
1950
|
+
els.memoryFilters.addEventListener('submit', (e) => {
|
|
1951
|
+
e.preventDefault();
|
|
1952
|
+
memoryView.query = els.memoryOverlayQuery.value.trim();
|
|
1953
|
+
loadMemoryOverlay(memoryView.query);
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
if (els.memoryFiltersClear) {
|
|
1957
|
+
els.memoryFiltersClear.addEventListener('click', () => {
|
|
1958
|
+
els.memoryOverlayQuery.value = '';
|
|
1959
|
+
memoryView.query = '';
|
|
1960
|
+
memoryView.source = '';
|
|
1961
|
+
memoryView.role = '';
|
|
1962
|
+
renderMemoryOverlay();
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
document.addEventListener('keydown', (e) => {
|
|
1966
|
+
if (e.key === 'Escape' && overlayOpen()) closeMemoryOverlay();
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
// First paint: show the welcome panel unless a session already rendered rows.
|
|
1970
|
+
if (!els.messages.querySelector('.msg, .chatflow')) renderWelcome();
|
|
1971
|
+
|
|
1972
|
+
await loadClasses();
|
|
1973
|
+
await loadSettings();
|
|
1974
|
+
await loadSessions();
|
|
1975
|
+
await refreshSyncStatus();
|
|
1976
|
+
loadAccountInfo();
|
|
1977
|
+
searchMemory('');
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// Boot only after the DOM is parsed so every control this script queries
|
|
1981
|
+
// actually exists. If the script is already running post-DOM this is a no-op;
|
|
1982
|
+
// if it was loaded early (e.g. <head> without defer) this prevents the
|
|
1983
|
+
// null-element boot crash that left Save/Verify unbound and dropdowns empty.
|
|
1984
|
+
if (document.readyState === 'loading') {
|
|
1985
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
1986
|
+
} else {
|
|
1987
|
+
init();
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
})();
|