aegis-desktop 1.0.10 → 1.0.13
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/lib/auto.js +11 -7
- package/lib/fingerprint.js +76 -17
- package/lib/google-connect.js +171 -0
- package/lib/local/claude-pro.js +134 -11
- package/lib/local/engine.js +111 -26
- package/lib/local/prompt.js +67 -37
- package/lib/local/tools.js +49 -13
- package/lib/settings.js +65 -24
- package/main.js +270 -36
- package/package.json +5 -2
- package/preload.js +45 -12
- package/renderer/app.js +691 -22
- package/renderer/budget.js +18 -9
- package/renderer/companion.js +225 -0
- package/renderer/fingerprint.css +16 -0
- package/renderer/fingerprint.js +136 -2
- package/renderer/index.html +302 -121
- package/renderer/provider-toolbar.js +136 -0
- package/renderer/providers.js +261 -0
- package/renderer/style.css +547 -171
- package/vendor/aegis.js +169 -3
- package/vendor/credentials.js +9 -2
- package/vendor/drive-connect.js +87 -0
- package/vendor/drive-upload.js +176 -0
- package/vendor/google-signin.js +127 -0
- package/vendor/record-vault.js +431 -113
- package/vendor/update.js +366 -8
package/renderer/app.js
CHANGED
|
@@ -96,6 +96,14 @@ const ELEMENT_IDS = {
|
|
|
96
96
|
apiKeyHint: 'api-key-hint',
|
|
97
97
|
classSelect: 'class-select',
|
|
98
98
|
modelSelect: 'model-select',
|
|
99
|
+
ptSearch: 'pt-search',
|
|
100
|
+
ptRefresh: 'pt-refresh',
|
|
101
|
+
ptList: 'pt-list',
|
|
102
|
+
ptCount: 'pt-count',
|
|
103
|
+
ptHint: 'pt-hint',
|
|
104
|
+
companionCard: 'companion-card',
|
|
105
|
+
companionText: 'companion-text',
|
|
106
|
+
companionDismiss: 'companion-dismiss',
|
|
99
107
|
modelFree: 'model-input',
|
|
100
108
|
budgetHint: 'budget-hint',
|
|
101
109
|
autonomousToggle: 'autonomous-toggle',
|
|
@@ -106,6 +114,15 @@ const ELEMENT_IDS = {
|
|
|
106
114
|
modelHint: 'model-hint',
|
|
107
115
|
settingsList: 'settings-list',
|
|
108
116
|
settingsHint: 'settings-hint',
|
|
117
|
+
// The custom-provider card (index.html #custom-provider-card): an address, a
|
|
118
|
+
// key and a model id, saved as `byok:custom`. Separate from settingsList
|
|
119
|
+
// because its row carries a base URL the catalog rows do not have.
|
|
120
|
+
customBaseUrl: 'custom-base-url',
|
|
121
|
+
customKey: 'custom-key',
|
|
122
|
+
customModel: 'custom-model',
|
|
123
|
+
customSave: 'custom-save',
|
|
124
|
+
customRemove: 'custom-remove',
|
|
125
|
+
customStatus: 'custom-status',
|
|
109
126
|
quickLauncherEnabled: 'quick-launcher-enabled',
|
|
110
127
|
quickLauncherShortcut: 'quick-launcher-shortcut',
|
|
111
128
|
quickLauncherSave: 'quick-launcher-save',
|
|
@@ -239,6 +256,20 @@ const RESERVED_PROVIDERS = new Set(['__aegis', 'aegis']);
|
|
|
239
256
|
// (desktop/lib/settings.js) and the engine re-checks before every call, so the
|
|
240
257
|
// value typed here can only ever address this machine or this LAN.
|
|
241
258
|
const LOCAL_PROVIDER = 'local';
|
|
259
|
+
// The one catalog provider whose configuration is not a key alone.
|
|
260
|
+
//
|
|
261
|
+
// aegis1 lists `custom` in GET /api/v1/byok/providers (services/byok_service.py
|
|
262
|
+
// `byok_provider_catalog`) even though its descriptor pins no credential env and
|
|
263
|
+
// no base URL, because the address travels per request instead. That makes it
|
|
264
|
+
// the only row where "paste your key" is not enough: aegis1's
|
|
265
|
+
// `_byok_custom_base_url` refuses a custom call that carries no `base_url`, so a
|
|
266
|
+
// key saved with no address is a credential nothing can spend.
|
|
267
|
+
//
|
|
268
|
+
// So the catalog loop below SKIPS this id and the card under #custom-provider-card
|
|
269
|
+
// owns it end to end (address + key + model id, one Save). Kept as one constant
|
|
270
|
+
// because two surfaces must agree on the spelling — the row that is skipped here
|
|
271
|
+
// and the row that is written there.
|
|
272
|
+
const CUSTOM_PROVIDER = 'custom';
|
|
242
273
|
// Ollama's stock port: the address a freshly installed daemon actually answers
|
|
243
274
|
// on, so the field's placeholder is also the value a blank save falls back to.
|
|
244
275
|
const LOCAL_DEFAULT_BASE = 'http://localhost:11434';
|
|
@@ -1102,6 +1133,12 @@ function renderStatus(s) {
|
|
|
1102
1133
|
// from the CLI) retires the connect block; with no key it stays. No-ops when
|
|
1103
1134
|
// the panel is not mounted.
|
|
1104
1135
|
applyWelcomeConnect();
|
|
1136
|
+
// The personalized line reads the same facts (a key that just landed, an
|
|
1137
|
+
// update that just downloaded), and the panel is painted BEFORE this read
|
|
1138
|
+
// returns on a cold start — so without this repaint the line would keep the
|
|
1139
|
+
// tip it chose while the key state was still unknown. No-ops when the panel
|
|
1140
|
+
// is not mounted.
|
|
1141
|
+
applyWelcomePersonal();
|
|
1105
1142
|
// And the two-key pop-up's gate: a status read is the only thing that knows
|
|
1106
1143
|
// either fact, so this is where the sheet is opened (a key still missing) or
|
|
1107
1144
|
// retired (both present). No-ops while the markup is absent.
|
|
@@ -1123,6 +1160,10 @@ let updateDismissedFor = null;
|
|
|
1123
1160
|
function renderUpdateBanner(state) {
|
|
1124
1161
|
if (!els.updateBanner) return;
|
|
1125
1162
|
lastUpdateState = state;
|
|
1163
|
+
// A downloaded update is the single most actionable fact the daily line can
|
|
1164
|
+
// report, and it arrives AFTER boot. Re-rendering here is what keeps the two
|
|
1165
|
+
// from disagreeing — the dismissal rule inside renderCompanion still wins.
|
|
1166
|
+
try { renderCompanion(); } catch { /* the banner must not fail on the card */ }
|
|
1126
1167
|
const status = state && state.status;
|
|
1127
1168
|
// 'unavailable' is the npm channel's silent verdict after a background check
|
|
1128
1169
|
// that failed for a transient reason (no network yet at login, a VPN coming
|
|
@@ -1142,12 +1183,15 @@ function renderUpdateBanner(state) {
|
|
|
1142
1183
|
let showRetry = false;
|
|
1143
1184
|
const version = state.version ? `v${state.version} ` : '';
|
|
1144
1185
|
if (status === 'available') {
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1186
|
+
// A channel the main process can install for (npm, pip) gets a real
|
|
1187
|
+
// Download button — `canDownload` is true only when it has an argv to run.
|
|
1188
|
+
// The rest (npx, a source checkout) have nothing to install, so the banner
|
|
1189
|
+
// names the command instead of offering a button that would do nothing.
|
|
1147
1190
|
text = state.command
|
|
1148
1191
|
? `Update ${version}available — run: ${state.command}`
|
|
1149
1192
|
: `Update ${version}available.`;
|
|
1150
1193
|
showDownload = !!state.canDownload;
|
|
1194
|
+
if (showDownload) text = `Update ${version}available.`;
|
|
1151
1195
|
} else if (status === 'downloading') {
|
|
1152
1196
|
const pct = typeof state.progress === 'number' ? ` (${Math.round(state.progress)}%)` : '';
|
|
1153
1197
|
text = `Downloading update${pct}…`;
|
|
@@ -2045,7 +2089,7 @@ function handleQuickLauncherPush(payload) {
|
|
|
2045
2089
|
//
|
|
2046
2090
|
// Field set per entry (aegis1/app.py:9349 `_memory_row_to_dict`): id,
|
|
2047
2091
|
// timestamp, createdAt (epoch ms), source, role, tags[], content, session,
|
|
2048
|
-
// importance, summary(bool),
|
|
2092
|
+
// importance, summary(bool), entities[], sentiment, tokenCount,
|
|
2049
2093
|
// embedding[]. There is no `tier` — L0–L3 is the local CLI engine's concept
|
|
2050
2094
|
// (the upstream engine's memory module) and does not exist on the cloud rows, so
|
|
2051
2095
|
// source/role are the real provenance axes here.
|
|
@@ -2125,6 +2169,166 @@ function keysOverlayOpen() {
|
|
|
2125
2169
|
* above can ask it whether it is open without reaching into its internals. */
|
|
2126
2170
|
let fpPanel = null;
|
|
2127
2171
|
|
|
2172
|
+
// ------------------------------------------------- providers & models toolbar
|
|
2173
|
+
//
|
|
2174
|
+
// The list is built from the SAME calls the Model card uses (models.listClasses
|
|
2175
|
+
// then models.listModels per class), so a provider that appears in one place
|
|
2176
|
+
// cannot be missing from the other. It is deliberately lazy: the per-class
|
|
2177
|
+
// catalog calls cost a network round trip each, and this card is a reference,
|
|
2178
|
+
// not the control the user is working in — so it loads on first sight (the
|
|
2179
|
+
// Refresh button, or the moment the card is actually opened) rather than
|
|
2180
|
+
// delaying boot behind four catalog fetches.
|
|
2181
|
+
let ptToolbar = null;
|
|
2182
|
+
let ptLoaded = false;
|
|
2183
|
+
|
|
2184
|
+
/** Collect every class's models payload, in class order. Never throws. */
|
|
2185
|
+
async function collectProviderRows() {
|
|
2186
|
+
let classes = [];
|
|
2187
|
+
try {
|
|
2188
|
+
classes = (await models.listClasses()) || [];
|
|
2189
|
+
} catch (err) {
|
|
2190
|
+
els.ptHint.textContent =
|
|
2191
|
+
`listClasses failed: ${err && err.message ? err.message : err}`;
|
|
2192
|
+
return [];
|
|
2193
|
+
}
|
|
2194
|
+
const rows = [];
|
|
2195
|
+
for (const c of classes) {
|
|
2196
|
+
if (!c || !c.class) continue;
|
|
2197
|
+
let list = null;
|
|
2198
|
+
try {
|
|
2199
|
+
list = await models.listModels(c.class);
|
|
2200
|
+
} catch (err) {
|
|
2201
|
+
// A class that failed is reported AS a class that failed, not dropped:
|
|
2202
|
+
// an absent row would read as "this build cannot address it", which is a
|
|
2203
|
+
// different and much worse claim than "its catalog call errored".
|
|
2204
|
+
list = { models: [], error: (err && err.message) || String(err) };
|
|
2205
|
+
}
|
|
2206
|
+
rows.push({ class: c.class, classLabel: c.label, classConfigured: Boolean(c.configured), list });
|
|
2207
|
+
}
|
|
2208
|
+
return rows;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
async function loadProviderToolbar(force) {
|
|
2212
|
+
if (!ptToolbar) return;
|
|
2213
|
+
if (ptLoaded && !force) return;
|
|
2214
|
+
if (els.ptHint) els.ptHint.textContent = 'listing providers…';
|
|
2215
|
+
const rows = await collectProviderRows();
|
|
2216
|
+
ptLoaded = true;
|
|
2217
|
+
ptToolbar.set(rows);
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
/** Apply a toolbar pick to the Model card's own controls. */
|
|
2221
|
+
function applyProviderPick(sel) {
|
|
2222
|
+
if (!sel || !sel.class) return;
|
|
2223
|
+
if (!Array.from(els.classSelect.options).some((o) => o.value === sel.class)) return;
|
|
2224
|
+
els.classSelect.value = sel.class;
|
|
2225
|
+
localStorage.setItem(CLASS_KEY, sel.class);
|
|
2226
|
+
updateBudgetControls(sel.class);
|
|
2227
|
+
loadModels(sel.class).then(() => {
|
|
2228
|
+
// The model list is async, so the model can only be applied once the
|
|
2229
|
+
// options exist. Setting a value the picker does not offer would leave the
|
|
2230
|
+
// select on its first entry while the toolbar claims a different row.
|
|
2231
|
+
if (sel.model && Array.from(els.modelSelect.options).some((o) => o.value === sel.model)) {
|
|
2232
|
+
els.modelSelect.value = sel.model;
|
|
2233
|
+
els.modelSelect.dispatchEvent(new Event('change'));
|
|
2234
|
+
}
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
function mountProviderToolbar() {
|
|
2239
|
+
if (!window.aegisProviderToolbar || !els.ptList) return;
|
|
2240
|
+
ptToolbar = window.aegisProviderToolbar.mount({
|
|
2241
|
+
list: els.ptList,
|
|
2242
|
+
count: els.ptCount,
|
|
2243
|
+
search: els.ptSearch,
|
|
2244
|
+
hint: els.ptHint,
|
|
2245
|
+
onSelect: applyProviderPick,
|
|
2246
|
+
});
|
|
2247
|
+
if (els.ptRefresh) {
|
|
2248
|
+
els.ptRefresh.addEventListener('click', () => { loadProviderToolbar(true); });
|
|
2249
|
+
}
|
|
2250
|
+
// Lazy first load: fires once, when the card is first scrolled into view.
|
|
2251
|
+
if (typeof IntersectionObserver === 'function' && els.ptList.closest('section')) {
|
|
2252
|
+
const io = new IntersectionObserver((hits) => {
|
|
2253
|
+
if (hits.some((h) => h.isIntersecting)) {
|
|
2254
|
+
io.disconnect();
|
|
2255
|
+
loadProviderToolbar(false);
|
|
2256
|
+
}
|
|
2257
|
+
});
|
|
2258
|
+
io.observe(els.ptList.closest('section'));
|
|
2259
|
+
} else {
|
|
2260
|
+
loadProviderToolbar(false);
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
// ------------------------------------------------------- the daily line
|
|
2265
|
+
//
|
|
2266
|
+
// One message per local day, from facts this process already holds (see
|
|
2267
|
+
// companion.js for why every branch is fact-gated). Stored as `{day, id}` so a
|
|
2268
|
+
// dismissal survives a restart and a NEW day re-opens the card.
|
|
2269
|
+
const COMPANION_KEY = 'aegis.companion.dismissed';
|
|
2270
|
+
|
|
2271
|
+
function companionFacts() {
|
|
2272
|
+
const facts = { keyConfigured: keyConfigured === null ? undefined : keyConfigured };
|
|
2273
|
+
// Only counts the renderer can actually see are passed; everything else is
|
|
2274
|
+
// left undefined so companion.js drops that branch instead of guessing.
|
|
2275
|
+
//
|
|
2276
|
+
// The memory count comes from `memoryView.entries` — the inspector's own
|
|
2277
|
+
// fetched page — NOT from `fpPanel.entries()`, which this used to call and
|
|
2278
|
+
// which does not exist: renderer/fingerprint.js's mount() returns open/close/
|
|
2279
|
+
// isOpen/refresh/loadAutoMint/state/actions and nothing else, so that branch
|
|
2280
|
+
// was dead code and `memoryCount` was never populated, which in turn made the
|
|
2281
|
+
// memory branch in companion.js unreachable in the app. `memoryView.entries`
|
|
2282
|
+
// is empty until the inspector has been opened once (loadMemoryOverlay is
|
|
2283
|
+
// what fills it), so this reports a count only when the process has really
|
|
2284
|
+
// seen one — the same "no guess in the blank" rule as every other fact here.
|
|
2285
|
+
if (Array.isArray(memoryView.entries) && memoryView.entries.length) {
|
|
2286
|
+
facts.memoryCount = memoryView.entries.length;
|
|
2287
|
+
}
|
|
2288
|
+
if (lastUpdateState && lastUpdateState.status === 'downloaded' && lastUpdateState.version) {
|
|
2289
|
+
facts.updatePending = String(lastUpdateState.version);
|
|
2290
|
+
}
|
|
2291
|
+
if (classOptions.length && els.modelSelect && els.modelSelect.options.length > 1) {
|
|
2292
|
+
facts.modelCount = els.modelSelect.options.length;
|
|
2293
|
+
}
|
|
2294
|
+
return facts;
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
function renderCompanion() {
|
|
2298
|
+
if (!window.aegisCompanion || !els.companionCard || !els.companionText) return;
|
|
2299
|
+
const now = new Date();
|
|
2300
|
+
let dismissed = null;
|
|
2301
|
+
try {
|
|
2302
|
+
dismissed = JSON.parse(localStorage.getItem(COMPANION_KEY) || 'null');
|
|
2303
|
+
} catch {
|
|
2304
|
+
dismissed = null;
|
|
2305
|
+
}
|
|
2306
|
+
if (window.aegisCompanion.dismissedToday(dismissed, now)) {
|
|
2307
|
+
els.companionCard.hidden = true;
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
const msg = window.aegisCompanion.pickMessage(companionFacts(), now);
|
|
2311
|
+
if (!msg || !msg.text) {
|
|
2312
|
+
els.companionCard.hidden = true;
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
els.companionText.textContent = msg.text;
|
|
2316
|
+
els.companionCard.dataset.kind = msg.kind;
|
|
2317
|
+
els.companionCard.dataset.messageId = msg.id;
|
|
2318
|
+
els.companionCard.hidden = false;
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
function wireCompanion() {
|
|
2322
|
+
if (els.companionDismiss) {
|
|
2323
|
+
els.companionDismiss.addEventListener('click', () => {
|
|
2324
|
+
localStorage.setItem(COMPANION_KEY, JSON.stringify({ day: window.aegisCompanion
|
|
2325
|
+
? window.aegisCompanion.dayKey(new Date()) : '', id: els.companionCard.dataset.messageId || '' }));
|
|
2326
|
+
els.companionCard.hidden = true;
|
|
2327
|
+
});
|
|
2328
|
+
}
|
|
2329
|
+
renderCompanion();
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2128
2332
|
/** "3m ago" / "2d ago" from epoch ms. Empty string when unknown. */
|
|
2129
2333
|
function relTime(ms) {
|
|
2130
2334
|
if (!Number.isFinite(ms)) return '';
|
|
@@ -2156,7 +2360,6 @@ function normalizeMemoryEntry(raw) {
|
|
|
2156
2360
|
source: asString(r.source) || '(unknown source)',
|
|
2157
2361
|
role: asString(r.role),
|
|
2158
2362
|
tags: asArray(r.tags),
|
|
2159
|
-
topics: asArray(r.topics),
|
|
2160
2363
|
entities: asArray(r.entities),
|
|
2161
2364
|
session: asString(r.session),
|
|
2162
2365
|
sentiment: asString(r.sentiment),
|
|
@@ -2307,7 +2510,7 @@ async function searchMemory(query) {
|
|
|
2307
2510
|
|
|
2308
2511
|
// ------------------------------------------------- memory inspector overlay
|
|
2309
2512
|
|
|
2310
|
-
/** Small coloured label used for source / role / tag /
|
|
2513
|
+
/** Small coloured label used for source / role / tag / entity. */
|
|
2311
2514
|
function memoryChip(text, cls) {
|
|
2312
2515
|
const span = document.createElement('span');
|
|
2313
2516
|
span.className = `mem-chip${cls ? ` ${cls}` : ''}`;
|
|
@@ -2345,7 +2548,7 @@ function memoryCard(e) {
|
|
|
2345
2548
|
body.textContent = e.content || '(empty)';
|
|
2346
2549
|
li.appendChild(body);
|
|
2347
2550
|
|
|
2348
|
-
const pills = [...e.tags, ...e.
|
|
2551
|
+
const pills = [...e.tags, ...e.entities];
|
|
2349
2552
|
if (pills.length) {
|
|
2350
2553
|
const row = document.createElement('div');
|
|
2351
2554
|
row.className = 'mem-card-pills';
|
|
@@ -2381,7 +2584,6 @@ function memoryMatches(e, q) {
|
|
|
2381
2584
|
e.session,
|
|
2382
2585
|
e.sentiment,
|
|
2383
2586
|
...e.tags,
|
|
2384
|
-
...e.topics,
|
|
2385
2587
|
...e.entities,
|
|
2386
2588
|
].join('\n').toLowerCase();
|
|
2387
2589
|
return hay.includes(String(q == null ? '' : q).toLowerCase());
|
|
@@ -2644,16 +2846,262 @@ function applyGreeting() {
|
|
|
2644
2846
|
: 'Working late?';
|
|
2645
2847
|
}
|
|
2646
2848
|
|
|
2647
|
-
/**
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2849
|
+
/** Pack the facts above into the shape renderer/companion.js consumes.
|
|
2850
|
+
*
|
|
2851
|
+
* The welcome line and the sidebar's daily message answer the same question —
|
|
2852
|
+
* "what does this install actually know that is worth saying?" — so they read
|
|
2853
|
+
* the SAME fact bag rather than growing a second, drifting opinion of what is
|
|
2854
|
+
* true. Everything absent is left undefined on purpose: companion.js drops a
|
|
2855
|
+
* branch whose facts are missing instead of filling the gap with a guess.
|
|
2856
|
+
*/
|
|
2857
|
+
function personalizeFacts() {
|
|
2858
|
+
const facts = companionFacts();
|
|
2859
|
+
// companionFacts() only returns `keyConfigured` when the status read has
|
|
2860
|
+
// landed (null means "not read yet"). The welcome panel is painted before
|
|
2861
|
+
// that read on a cold start, and the panel is the one place where "paste your
|
|
2862
|
+
// key" is the useful thing to say — so an unread status, not a known keyless
|
|
2863
|
+
// machine, must NOT be reported as keyConfigured === false.
|
|
2864
|
+
if (facts.keyConfigured === undefined) delete facts.keyConfigured;
|
|
2865
|
+
/*
|
|
2866
|
+
* The provider-class-with-models-but-no-key case is deliberately NOT passed
|
|
2867
|
+
* here. It is a sidebar diagnosis (it names the Provider settings card the
|
|
2868
|
+
* user was already looking at); in the welcome panel the byok route below is
|
|
2869
|
+
* the same information written as an invitation, and showing both in one
|
|
2870
|
+
* column reads as two warnings about one thing.
|
|
2871
|
+
*/
|
|
2872
|
+
return facts;
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
/**
|
|
2876
|
+
* The one personalized line in the welcome panel.
|
|
2877
|
+
*
|
|
2878
|
+
* This replaced five generic topic pills ("Write code", "Explain a concept", …)
|
|
2879
|
+
* that every install showed identically. A topic menu asks the user to invent
|
|
2880
|
+
* an intent that fits a canned frame; a line built from a fact about THEIR
|
|
2881
|
+
* install either saves them a step (a downloaded update, a key they have not
|
|
2882
|
+
* pasted yet) or tells them something true that they already own (memory
|
|
2883
|
+
* entries, sessions). Where no such fact exists it rotates one true tip — and
|
|
2884
|
+
* where even that is unavailable, the node stays hidden rather than printing an
|
|
2885
|
+
* empty box or a guess.
|
|
2886
|
+
*
|
|
2887
|
+
* Returns `{ text, kind }` or null. `kind` is 'action' (a concrete step) or
|
|
2888
|
+
* 'tip' (a true statement about the build); it is written to the dataset so CSS
|
|
2889
|
+
* can style an action line a little louder than a rotation.
|
|
2890
|
+
*/
|
|
2891
|
+
function welcomePersonalize(now) {
|
|
2892
|
+
const panel = window.aegisCompanion;
|
|
2893
|
+
if (!panel || typeof panel.pickMessage !== 'function') return null;
|
|
2894
|
+
const msg = panel.pickMessage(personalizeFacts(), now || new Date());
|
|
2895
|
+
if (!msg || !msg.text) return null;
|
|
2896
|
+
return { text: msg.text, kind: msg.kind || 'tip' };
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
/** Paint the personalized line into the cloned panel. */
|
|
2900
|
+
function applyWelcomePersonal() {
|
|
2901
|
+
const el = document.getElementById('welcome-personal');
|
|
2902
|
+
if (!el) return;
|
|
2903
|
+
const line = welcomePersonalize();
|
|
2904
|
+
if (!line) {
|
|
2905
|
+
// Nothing true to say: hidden, and empty, so a stale line can never be left
|
|
2906
|
+
// behind by a second render in the same session.
|
|
2907
|
+
el.textContent = '';
|
|
2908
|
+
el.hidden = true;
|
|
2909
|
+
return;
|
|
2910
|
+
}
|
|
2911
|
+
el.textContent = line.text;
|
|
2912
|
+
el.dataset.kind = line.kind;
|
|
2913
|
+
el.hidden = false;
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
// ------------------------------------------------ welcome: the AI's own line
|
|
2917
|
+
//
|
|
2918
|
+
// The line above is written by this app, from facts it holds. It is always true
|
|
2919
|
+
// and it is always the same sentence, which is exactly why it stops being read
|
|
2920
|
+
// after the third launch: the same install gets the same words every day.
|
|
2921
|
+
//
|
|
2922
|
+
// So the panel asks the model instead — one short turn, once per local day,
|
|
2923
|
+
// given the facts (the local time of day, how many memory entries there are,
|
|
2924
|
+
// which model is selected, whether a key is present) and told to write ONE
|
|
2925
|
+
// sentence for this user. It is cached per day, so a New chat re-render costs
|
|
2926
|
+
// nothing, and the app's own line is what paints first: the panel never waits
|
|
2927
|
+
// on a network round trip to have something to say.
|
|
2928
|
+
//
|
|
2929
|
+
// WHAT THIS DELIBERATELY IS NOT. It is not a free-form agent turn. It carries
|
|
2930
|
+
// no tools, no history and no user text; `singlePass` keeps a pooled model id
|
|
2931
|
+
// from silently fanning out to (workers + 1) calls, and the budget is the small
|
|
2932
|
+
// fixed one below. A greeting that bills a multi-worker dispatch on every cold
|
|
2933
|
+
// start would be the most expensive sentence in the app.
|
|
2934
|
+
//
|
|
2935
|
+
// AND IT IS GATED, not assumed: a turn is only sent when something can answer
|
|
2936
|
+
// it — an AEGIS key for the pooled class, or a local model list with a model in
|
|
2937
|
+
// it. With neither, no request is made at all and the fact line stands alone.
|
|
2938
|
+
// Nothing is invented when the model cannot be reached; a failed call leaves the
|
|
2939
|
+
// fact line exactly as it was.
|
|
2940
|
+
|
|
2941
|
+
const AI_GREETING_KEY = 'aegis.welcomeAiLine';
|
|
2942
|
+
/** One sentence. Long enough for a model to finish a thought, short enough that
|
|
2943
|
+
* a truncated answer cannot fill the panel — the renderer trims to the last
|
|
2944
|
+
* sentence boundary below rather than showing half a clause. */
|
|
2945
|
+
const AI_GREETING_MAX_TOKENS = 120;
|
|
2946
|
+
/** Hard cap on what is put on screen, characters. A model that ignores the
|
|
2947
|
+
* length instruction must not be able to push the composer off the panel. */
|
|
2948
|
+
const AI_GREETING_MAX_CHARS = 200;
|
|
2949
|
+
|
|
2950
|
+
/** Local calendar day, YYYY-MM-DD. Local, for the same reason companion.js is:
|
|
2951
|
+
* "today" means the user's today, not UTC's. */
|
|
2952
|
+
function aiGreetingDay(now) {
|
|
2953
|
+
const d = now instanceof Date ? now : new Date(now == null ? Date.now() : now);
|
|
2954
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
2955
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
2956
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
/** Can a turn actually be sent right now? Read from what the process knows. */
|
|
2960
|
+
function aiGreetingReachable() {
|
|
2961
|
+
if (keyConfigured === true) return true;
|
|
2962
|
+
// Local needs no account. A listed model means the daemon answered, which is
|
|
2963
|
+
// the only evidence this renderer has that anything is listening.
|
|
2964
|
+
return !!(els.modelSelect && els.modelSelect.options && els.modelSelect.options.length > 1);
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
/** The cached line for today, or null. Cache is `{ day, text }`. */
|
|
2968
|
+
function aiGreetingCached(now) {
|
|
2969
|
+
let rec = null;
|
|
2653
2970
|
try {
|
|
2654
|
-
|
|
2655
|
-
} catch
|
|
2656
|
-
|
|
2971
|
+
rec = JSON.parse(localStorage.getItem(AI_GREETING_KEY) || 'null');
|
|
2972
|
+
} catch {
|
|
2973
|
+
rec = null;
|
|
2974
|
+
}
|
|
2975
|
+
if (!rec || typeof rec.text !== 'string' || !rec.text) return null;
|
|
2976
|
+
if (rec.day !== aiGreetingDay(now)) return null;
|
|
2977
|
+
return rec.text;
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
/** Trim a model's answer to one presentable sentence.
|
|
2981
|
+
*
|
|
2982
|
+
* Models wrap greetings in quotes, prefix them with "Sure!" or emit three
|
|
2983
|
+
* sentences when asked for one. Rather than trusting the instruction, this
|
|
2984
|
+
* takes the first sentence and drops the scaffolding — and returns '' when
|
|
2985
|
+
* what is left is too short to be a greeting, so the caller keeps the fact
|
|
2986
|
+
* line instead of printing a fragment. */
|
|
2987
|
+
function trimAiGreeting(raw) {
|
|
2988
|
+
let t = String(raw == null ? '' : raw).trim();
|
|
2989
|
+
if (!t) return '';
|
|
2990
|
+
// Reasoning models sometimes lead with a labelled preamble line.
|
|
2991
|
+
t = t.replace(/^(assistant|greeting|response)\s*:\s*/i, '');
|
|
2992
|
+
// ...and chat models lead with agreement ("Sure!", "Of course.") or an
|
|
2993
|
+
// announcement of what follows ("Here's your greeting:"). Neither is the
|
|
2994
|
+
// sentence that was asked for, and both read as a machine clearing its
|
|
2995
|
+
// throat at the top of the panel. Stripped repeatedly, so "Sure! Here you
|
|
2996
|
+
// go:" composed of two such segments goes too.
|
|
2997
|
+
let prev;
|
|
2998
|
+
do {
|
|
2999
|
+
prev = t;
|
|
3000
|
+
t = t.replace(
|
|
3001
|
+
/^(?:sure|certainly|of course|absolutely|okay|ok|got it|no problem|here(?:'s| is)\b[^.!?:]*)\s*[.!?:,—–-]\s*/i,
|
|
3002
|
+
'',
|
|
3003
|
+
);
|
|
3004
|
+
} while (t !== prev && t);
|
|
3005
|
+
// A quoted one-liner: keep what is inside the outermost quotes.
|
|
3006
|
+
const q = t.match(/^["'“”'']([\s\S]+)["'“”'']$/);
|
|
3007
|
+
if (q) t = q[1].trim();
|
|
3008
|
+
t = t.replace(/\s+/g, ' ');
|
|
3009
|
+
// First sentence, on a boundary that is followed by a space or end of string,
|
|
3010
|
+
// so "llama3.2:3b" and "a.m." are not cut in half.
|
|
3011
|
+
const m = t.match(/^[\s\S]*?[.!?](?=\s|$)/);
|
|
3012
|
+
if (m) t = m[0].trim();
|
|
3013
|
+
if (t.length > AI_GREETING_MAX_CHARS) {
|
|
3014
|
+
const cut = t.slice(0, AI_GREETING_MAX_CHARS);
|
|
3015
|
+
const at = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));
|
|
3016
|
+
t = at > 40 ? cut.slice(0, at + 1) : `${cut.replace(/[,;:\s]+$/, '')}…`;
|
|
3017
|
+
}
|
|
3018
|
+
return t.length >= 12 ? t : '';
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
/** The one sentence the model is asked for. Facts only — no user text, no
|
|
3022
|
+
* history, nothing that could carry a conversation in. */
|
|
3023
|
+
function aiGreetingPrompt(facts) {
|
|
3024
|
+
const h = new Date().getHours();
|
|
3025
|
+
const when =
|
|
3026
|
+
h >= 5 && h < 12 ? 'morning'
|
|
3027
|
+
: h >= 12 && h < 17 ? 'afternoon'
|
|
3028
|
+
: h >= 17 && h < 22 ? 'evening'
|
|
3029
|
+
: 'late night';
|
|
3030
|
+
const known = [];
|
|
3031
|
+
if (typeof facts.memoryCount === 'number') {
|
|
3032
|
+
known.push(`${facts.memoryCount} entries saved in the user's memory`);
|
|
3033
|
+
}
|
|
3034
|
+
if (typeof facts.modelCount === 'number') known.push(`${facts.modelCount} models available`);
|
|
3035
|
+
if (typeof facts.updatePending === 'string') {
|
|
3036
|
+
known.push(`update ${facts.updatePending} is downloaded and installs on quit`);
|
|
3037
|
+
}
|
|
3038
|
+
if (facts.keyConfigured === true) known.push('the account key is connected');
|
|
3039
|
+
if (facts.keyConfigured === false) known.push('no account key is connected yet');
|
|
3040
|
+
return [
|
|
3041
|
+
'Write the one sentence shown at the top of a coding assistant\'s empty',
|
|
3042
|
+
`chat panel. It is ${when} for the person reading it.`,
|
|
3043
|
+
known.length ? `What you know about their setup: ${known.join('; ')}.` : '',
|
|
3044
|
+
'Rules: one sentence, at most 25 words, second person, no greeting cliché',
|
|
3045
|
+
'like "Welcome back", no exclamation marks, no quotes, no preamble, no',
|
|
3046
|
+
'markdown. Say something specific and useful about their work right now, or',
|
|
3047
|
+
'ask one short question that helps them start. Reply with the sentence only.',
|
|
3048
|
+
].filter(Boolean).join(' ');
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
/** Ask the model for the line and paint it. Never throws, never blocks paint. */
|
|
3052
|
+
async function applyWelcomeAiLine() {
|
|
3053
|
+
const el = document.getElementById('welcome-personal');
|
|
3054
|
+
if (!el) return;
|
|
3055
|
+
if (typeof models === 'undefined' || !models || typeof models.chat !== 'function') return;
|
|
3056
|
+
if (!aiGreetingReachable()) return;
|
|
3057
|
+
|
|
3058
|
+
const now = new Date();
|
|
3059
|
+
const cached = aiGreetingCached(now);
|
|
3060
|
+
if (cached) {
|
|
3061
|
+
el.textContent = cached;
|
|
3062
|
+
el.dataset.kind = 'ai';
|
|
3063
|
+
el.hidden = false;
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
const facts = personalizeFacts();
|
|
3068
|
+
const classId = els.classSelect && els.classSelect.value ? els.classSelect.value : 'aegis';
|
|
3069
|
+
const modelId = els.modelSelect && els.modelSelect.value ? els.modelSelect.value : '';
|
|
3070
|
+
const payload = {
|
|
3071
|
+
class: classId,
|
|
3072
|
+
prompt: aiGreetingPrompt(facts),
|
|
3073
|
+
maxTokens: AI_GREETING_MAX_TOKENS,
|
|
3074
|
+
// One cheap pass: no tools, no history, no fan-out. See the block comment.
|
|
3075
|
+
tools: false,
|
|
3076
|
+
singlePass: true,
|
|
3077
|
+
effort: 'low',
|
|
3078
|
+
};
|
|
3079
|
+
if (modelId) payload.model = modelId;
|
|
3080
|
+
|
|
3081
|
+
let data = null;
|
|
3082
|
+
try {
|
|
3083
|
+
data = await models.chat(payload);
|
|
3084
|
+
} catch {
|
|
3085
|
+
// Unreachable, refused, out of balance: the fact line already on screen
|
|
3086
|
+
// stands. Saying "could not write your greeting" would be noise about a
|
|
3087
|
+
// sentence the user never asked for.
|
|
3088
|
+
return;
|
|
3089
|
+
}
|
|
3090
|
+
const choice = (data && data.choices && data.choices[0]) || {};
|
|
3091
|
+
const text = trimAiGreeting((choice.message && choice.message.content) || '');
|
|
3092
|
+
if (!text) return;
|
|
3093
|
+
|
|
3094
|
+
// The panel may have been replaced (New chat, another session opened) while
|
|
3095
|
+
// the turn was in flight. Only paint into a node still in the document.
|
|
3096
|
+
const live = document.getElementById('welcome-personal');
|
|
3097
|
+
if (!live) return;
|
|
3098
|
+
live.textContent = text;
|
|
3099
|
+
live.dataset.kind = 'ai';
|
|
3100
|
+
live.hidden = false;
|
|
3101
|
+
try {
|
|
3102
|
+
localStorage.setItem(AI_GREETING_KEY, JSON.stringify({ day: aiGreetingDay(now), text }));
|
|
3103
|
+
} catch {
|
|
3104
|
+
/* storage disabled — the line is regenerated next launch, nothing breaks */
|
|
2657
3105
|
}
|
|
2658
3106
|
}
|
|
2659
3107
|
|
|
@@ -2664,9 +3112,11 @@ function renderWelcome() {
|
|
|
2664
3112
|
els.messages.innerHTML = '';
|
|
2665
3113
|
els.messages.appendChild(tpl.content.cloneNode(true));
|
|
2666
3114
|
applyGreeting();
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
3115
|
+
applyWelcomePersonal();
|
|
3116
|
+
// Then, if a model can be reached, let it write the line (cached per day).
|
|
3117
|
+
// Deliberately not awaited: the panel is already complete without it, and a
|
|
3118
|
+
// slow or dead backend must not hold up the first paint.
|
|
3119
|
+
applyWelcomeAiLine();
|
|
2670
3120
|
// The connect block's three controls. Bound here (not inline, not at boot)
|
|
2671
3121
|
// because they only exist in the cloned panel; CSP `script-src 'self'` would
|
|
2672
3122
|
// drop an inline handler anyway.
|
|
@@ -4304,7 +4754,14 @@ async function loadSettings() {
|
|
|
4304
4754
|
const byokProviders = Array.isArray(byokData && byokData.providers) ? byokData.providers : [];
|
|
4305
4755
|
for (const p of byokProviders) {
|
|
4306
4756
|
if (!p || !p.id) continue;
|
|
4757
|
+
// Offered to the "your own models" datalist either way: `custom` is a
|
|
4758
|
+
// provider id the relay takes a key for, and a hand-declared model under
|
|
4759
|
+
// it is legitimate.
|
|
4307
4760
|
byokProviderIds.push({ id: p.id, label: p.label || p.id });
|
|
4761
|
+
// ...but not rendered as a key row here. Its own card (below) collects the
|
|
4762
|
+
// base URL that makes the key spendable; a row here would save a key with
|
|
4763
|
+
// nowhere to send it, which is exactly the state this split removes.
|
|
4764
|
+
if (p.id === CUSTOM_PROVIDER) continue;
|
|
4308
4765
|
const provider = `byok:${p.id}`;
|
|
4309
4766
|
const local = settings.find((s) => s.provider === provider) || {
|
|
4310
4767
|
provider, baseURL: '', configured: false, keyMask: null,
|
|
@@ -4355,6 +4812,19 @@ async function loadSettings() {
|
|
|
4355
4812
|
els.settingsHint.textContent =
|
|
4356
4813
|
`your own models: ${err && err.message ? err.message : err}`;
|
|
4357
4814
|
}
|
|
4815
|
+
|
|
4816
|
+
// The custom endpoint's own card (#custom-provider-card) is painted from the
|
|
4817
|
+
// same two stores this function just read — the settings row it owns and the
|
|
4818
|
+
// declarations the block above lists — so the two surfaces cannot disagree
|
|
4819
|
+
// about whether `custom` is configured.
|
|
4820
|
+
try {
|
|
4821
|
+
await loadCustomProvider();
|
|
4822
|
+
} catch (err) {
|
|
4823
|
+
if (els.customStatus) {
|
|
4824
|
+
els.customStatus.textContent =
|
|
4825
|
+
`custom provider: ${err && err.message ? err.message : err}`;
|
|
4826
|
+
}
|
|
4827
|
+
}
|
|
4358
4828
|
}
|
|
4359
4829
|
|
|
4360
4830
|
/**
|
|
@@ -4638,6 +5108,166 @@ async function renderUserModelRows(byokProviderIds) {
|
|
|
4638
5108
|
els.settingsList.appendChild(block);
|
|
4639
5109
|
}
|
|
4640
5110
|
|
|
5111
|
+
/**
|
|
5112
|
+
* The custom-provider card: address + key + model id, one Save.
|
|
5113
|
+
*
|
|
5114
|
+
* WHY A CARD OF ITS OWN. Every other BYOK provider is reachable by key alone —
|
|
5115
|
+
* its upstream host is a constant aegis1 already knows (services/nexus_provider/
|
|
5116
|
+
* catalog.py), so the client's only input is the credential. `custom` is the
|
|
5117
|
+
* inverse: the HOST is the user's to name, and the server validates it per call
|
|
5118
|
+
* (`_byok_custom_base_url`: https, and no loopback/private/link-local target,
|
|
5119
|
+
* because that route is otherwise an SSRF primitive). So the row has a field no
|
|
5120
|
+
* other provider has, and it is not the same decision as "paste a key" — hence
|
|
5121
|
+
* its own card instead of a special case inside the catalog list.
|
|
5122
|
+
*
|
|
5123
|
+
* The model id is part of the same save because aegis1's `custom` descriptor
|
|
5124
|
+
* pins no `default_model`, so the relay catalog contributes no custom model row:
|
|
5125
|
+
* the picker can only offer what the user-models file declares. Saving the key
|
|
5126
|
+
* without declaring a model would leave a configured provider with nothing
|
|
5127
|
+
* selectable — half a setup that looks finished.
|
|
5128
|
+
*
|
|
5129
|
+
* Order of writes, and why: the settings row first (that is what holds the
|
|
5130
|
+
* address), the model declaration second, the re-render last. A failure between
|
|
5131
|
+
* them leaves a saved endpoint and no model, which the status line says outright
|
|
5132
|
+
* rather than reporting a clean save.
|
|
5133
|
+
*/
|
|
5134
|
+
function paintCustomRow(cfg, declared) {
|
|
5135
|
+
if (!els.customBaseUrl) return;
|
|
5136
|
+
const saved = cfg || {};
|
|
5137
|
+
const declaredModels = Array.isArray(declared) ? declared : [];
|
|
5138
|
+
if (!els.customBaseUrl.value && saved.baseURL) els.customBaseUrl.value = saved.baseURL;
|
|
5139
|
+
if (els.customKey) {
|
|
5140
|
+
els.customKey.placeholder = saved.configured
|
|
5141
|
+
? `key ${saved.keyMask || '••••'} (blank = keep)`
|
|
5142
|
+
: 'sk-…';
|
|
5143
|
+
els.customKey.value = '';
|
|
5144
|
+
}
|
|
5145
|
+
if (els.customRemove) els.customRemove.disabled = !saved.configured && !saved.baseURL;
|
|
5146
|
+
if (!els.customStatus) return;
|
|
5147
|
+
const parts = [];
|
|
5148
|
+
if (saved.baseURL) parts.push(`endpoint ${saved.baseURL}`);
|
|
5149
|
+
else parts.push('no endpoint saved');
|
|
5150
|
+
parts.push(saved.configured ? `key ${saved.keyMask || 'saved'}` : 'no key');
|
|
5151
|
+
parts.push(declaredModels.length
|
|
5152
|
+
? `model${declaredModels.length === 1 ? '' : 's'} ${declaredModels.join(', ')}`
|
|
5153
|
+
: 'no model declared');
|
|
5154
|
+
els.customStatus.textContent = parts.join(' · ');
|
|
5155
|
+
}
|
|
5156
|
+
|
|
5157
|
+
/** Read the custom provider's saved state back (settings row + declarations). */
|
|
5158
|
+
async function loadCustomProvider() {
|
|
5159
|
+
if (!els.customBaseUrl) return;
|
|
5160
|
+
let cfg = null;
|
|
5161
|
+
let declared = [];
|
|
5162
|
+
try {
|
|
5163
|
+
const rows = (await models.settings.get()) || [];
|
|
5164
|
+
const found = (Array.isArray(rows) ? rows : []).find(
|
|
5165
|
+
(s) => s && s.provider === `byok:${CUSTOM_PROVIDER}`
|
|
5166
|
+
);
|
|
5167
|
+
if (found) {
|
|
5168
|
+
// `baseURL` IS part of the row `settings.get()` returns (settings.js
|
|
5169
|
+
// `get()`: provider, baseURL, configured, keyMask) — only the key itself
|
|
5170
|
+
// is masked. So the address is read, never re-written to be read back.
|
|
5171
|
+
cfg = {
|
|
5172
|
+
baseURL: found.baseURL || '',
|
|
5173
|
+
configured: Boolean(found.configured),
|
|
5174
|
+
keyMask: found.keyMask,
|
|
5175
|
+
};
|
|
5176
|
+
}
|
|
5177
|
+
} catch {
|
|
5178
|
+
/* store unreadable: the status line below says what is missing */
|
|
5179
|
+
}
|
|
5180
|
+
try {
|
|
5181
|
+
const report = await models.userModels.get();
|
|
5182
|
+
const map = (report && report.map) || {};
|
|
5183
|
+
declared = Array.isArray(map[CUSTOM_PROVIDER]) ? map[CUSTOM_PROVIDER] : [];
|
|
5184
|
+
} catch {
|
|
5185
|
+
/* no declarations readable: treat as none */
|
|
5186
|
+
}
|
|
5187
|
+
paintCustomRow(cfg, declared);
|
|
5188
|
+
}
|
|
5189
|
+
|
|
5190
|
+
/** Save the custom endpoint: settings row (address + key), then the model id. */
|
|
5191
|
+
async function saveCustomProvider() {
|
|
5192
|
+
if (!els.customBaseUrl) return;
|
|
5193
|
+
const baseURL = String(els.customBaseUrl.value || '').trim();
|
|
5194
|
+
const key = els.customKey ? String(els.customKey.value || '').trim() : '';
|
|
5195
|
+
const model = els.customModel ? String(els.customModel.value || '').trim() : '';
|
|
5196
|
+
const say = (t) => { if (els.customStatus) els.customStatus.textContent = t; };
|
|
5197
|
+
|
|
5198
|
+
if (!baseURL) {
|
|
5199
|
+
say('an address is required — aegis1 dials the endpoint you name, and refuses a custom call that names none.');
|
|
5200
|
+
return;
|
|
5201
|
+
}
|
|
5202
|
+
// Checked here as well as server-side so the refusal is a sentence next to the
|
|
5203
|
+
// field instead of a 400 that arrives after the model was declared. aegis1
|
|
5204
|
+
// refuses http:// and every non-public target for this provider; saying so up
|
|
5205
|
+
// front is the difference between a fixable typo and "custom is broken".
|
|
5206
|
+
if (!/^https:\/\//i.test(baseURL)) {
|
|
5207
|
+
say('refused: only an https:// endpoint is dialed — aegis1 rejects a plain-http or scheme-less base URL for this provider.');
|
|
5208
|
+
return;
|
|
5209
|
+
}
|
|
5210
|
+
if (!model) {
|
|
5211
|
+
say('a model id is required — the `custom` provider pins no default, so nothing would be selectable in the picker.');
|
|
5212
|
+
return;
|
|
5213
|
+
}
|
|
5214
|
+
|
|
5215
|
+
say('saving…');
|
|
5216
|
+
const provider = `byok:${CUSTOM_PROVIDER}`;
|
|
5217
|
+
try {
|
|
5218
|
+
const cfg = {};
|
|
5219
|
+
cfg.baseURL = baseURL;
|
|
5220
|
+
if (key) cfg.key = key;
|
|
5221
|
+
await models.settings.set(provider, cfg);
|
|
5222
|
+
} catch (err) {
|
|
5223
|
+
say(`endpoint not saved: ${err && err.message ? err.message : err}`);
|
|
5224
|
+
return;
|
|
5225
|
+
}
|
|
5226
|
+
try {
|
|
5227
|
+
await models.userModels.add(CUSTOM_PROVIDER, model);
|
|
5228
|
+
} catch (err) {
|
|
5229
|
+
say(`endpoint saved, model NOT declared: ${err && err.message ? err.message : err}`);
|
|
5230
|
+
await loadCustomProvider();
|
|
5231
|
+
await loadModels(els.classSelect.value).catch(() => {});
|
|
5232
|
+
return;
|
|
5233
|
+
}
|
|
5234
|
+
if (els.customModel) els.customModel.value = '';
|
|
5235
|
+
say(`saved ${baseURL} with model ${model} — pick it under provider "byok", then the model.`);
|
|
5236
|
+
await loadCustomProvider();
|
|
5237
|
+
await loadSettings();
|
|
5238
|
+
await loadModels(els.classSelect.value).catch(() => {});
|
|
5239
|
+
}
|
|
5240
|
+
|
|
5241
|
+
/** Forget the custom endpoint: the key row, then every declared custom model. */
|
|
5242
|
+
async function removeCustomProvider() {
|
|
5243
|
+
if (!els.customBaseUrl) return;
|
|
5244
|
+
const say = (t) => { if (els.customStatus) els.customStatus.textContent = t; };
|
|
5245
|
+
say('removing…');
|
|
5246
|
+
let removedModels = 0;
|
|
5247
|
+
try {
|
|
5248
|
+
const report = await models.userModels.get();
|
|
5249
|
+
const map = (report && report.map) || {};
|
|
5250
|
+
const declared = Array.isArray(map[CUSTOM_PROVIDER]) ? map[CUSTOM_PROVIDER] : [];
|
|
5251
|
+
for (const model of declared) {
|
|
5252
|
+
await models.userModels.remove(CUSTOM_PROVIDER, model);
|
|
5253
|
+
removedModels += 1;
|
|
5254
|
+
}
|
|
5255
|
+
} catch {
|
|
5256
|
+
/* declarations already gone, or unreadable — the key removal still runs */
|
|
5257
|
+
}
|
|
5258
|
+
try {
|
|
5259
|
+
await models.settings.remove(`byok:${CUSTOM_PROVIDER}`);
|
|
5260
|
+
} catch (err) {
|
|
5261
|
+
say(`remove failed: ${err && err.message ? err.message : err}`);
|
|
5262
|
+
return;
|
|
5263
|
+
}
|
|
5264
|
+
if (els.customBaseUrl) els.customBaseUrl.value = '';
|
|
5265
|
+
say(`removed the custom endpoint${removedModels ? ` and ${removedModels} declared model(s)` : ''}.`);
|
|
5266
|
+
await loadCustomProvider();
|
|
5267
|
+
await loadSettings();
|
|
5268
|
+
await loadModels(els.classSelect.value).catch(() => {});
|
|
5269
|
+
}
|
|
5270
|
+
|
|
4641
5271
|
async function saveSetting(provider, key) {
|
|
4642
5272
|
els.settingsHint.textContent = 'saving…';
|
|
4643
5273
|
try {
|
|
@@ -5540,6 +6170,14 @@ async function init() {
|
|
|
5540
6170
|
// File > Save as… / Export Session… — same "menu pings, renderer acts"
|
|
5541
6171
|
// pattern as New Chat/Search above; both call exportSession() with the
|
|
5542
6172
|
// format the menu label promised (see main.js buildAppMenu).
|
|
6173
|
+
// View > Authorship Panel… — the sidebar card that used to open the φ(α)
|
|
6174
|
+
// sheet is gone (sealing runs in the background), so the menu is the entry
|
|
6175
|
+
// point. `fpPanel` is mount()'s handle, assigned below in init().
|
|
6176
|
+
if (aegis.onMenuFingerprint) {
|
|
6177
|
+
aegis.onMenuFingerprint(() => {
|
|
6178
|
+
if (fpPanel && typeof fpPanel.open === 'function') fpPanel.open();
|
|
6179
|
+
});
|
|
6180
|
+
}
|
|
5543
6181
|
if (aegis.onMenuExportMarkdown) aegis.onMenuExportMarkdown(() => exportSession('markdown'));
|
|
5544
6182
|
if (aegis.onMenuExportJson) aegis.onMenuExportJson(() => exportSession('json'));
|
|
5545
6183
|
// aegis:// deep link (main.js sendDeepLinkToWindow) — delivered once the
|
|
@@ -5562,6 +6200,20 @@ async function init() {
|
|
|
5562
6200
|
}
|
|
5563
6201
|
});
|
|
5564
6202
|
|
|
6203
|
+
// The custom-provider card (#custom-provider-card). Its three fields are one
|
|
6204
|
+
// decision, so Enter in any of them runs the same Save the button does.
|
|
6205
|
+
if (els.customSave) els.customSave.addEventListener('click', saveCustomProvider);
|
|
6206
|
+
if (els.customRemove) els.customRemove.addEventListener('click', removeCustomProvider);
|
|
6207
|
+
for (const box of [els.customBaseUrl, els.customKey, els.customModel]) {
|
|
6208
|
+
if (!box) continue;
|
|
6209
|
+
box.addEventListener('keydown', (e) => {
|
|
6210
|
+
if (e.key === 'Enter') {
|
|
6211
|
+
e.preventDefault();
|
|
6212
|
+
saveCustomProvider();
|
|
6213
|
+
}
|
|
6214
|
+
});
|
|
6215
|
+
}
|
|
6216
|
+
|
|
5565
6217
|
// Billing actions in the Status card — see startBilling()/renderBillingError().
|
|
5566
6218
|
els.upgradePlan.addEventListener('click', () => startBilling('upgrade'));
|
|
5567
6219
|
els.topupBtn.addEventListener('click', () => startBilling('topup'));
|
|
@@ -5818,9 +6470,16 @@ async function init() {
|
|
|
5818
6470
|
|
|
5819
6471
|
els.updateDownloadBtn.addEventListener('click', () => {
|
|
5820
6472
|
els.updateDownloadBtn.disabled = true;
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
6473
|
+
// The resolved value is the post-install state: render it as well as
|
|
6474
|
+
// waiting for the push, so a channel whose installer finishes inside the
|
|
6475
|
+
// invoke still repaints (and so a failure surfaces even if the push
|
|
6476
|
+
// channel is not wired yet).
|
|
6477
|
+
Promise.resolve(aegis.downloadUpdate())
|
|
6478
|
+
.then((state) => { if (state && state.status) renderUpdateBanner(state); })
|
|
6479
|
+
.catch(() => {})
|
|
6480
|
+
.finally(() => {
|
|
6481
|
+
els.updateDownloadBtn.disabled = false;
|
|
6482
|
+
});
|
|
5824
6483
|
});
|
|
5825
6484
|
|
|
5826
6485
|
// The only place quitAndInstallUpdate is ever called — an explicit user
|
|
@@ -5869,6 +6528,16 @@ async function init() {
|
|
|
5869
6528
|
// (a host that predates the IPC handlers) leaves the card on "checking…"
|
|
5870
6529
|
// instead of crashing boot. Not awaited: the panel's first status read is a
|
|
5871
6530
|
// filesystem stat, and the welcome panel must not wait on it.
|
|
6531
|
+
// The provider reference card and the daily line. Both are `?`-guarded by
|
|
6532
|
+
// their own module presence, so a window whose build predates these scripts
|
|
6533
|
+
// boots normally instead of throwing on a missing global.
|
|
6534
|
+
try {
|
|
6535
|
+
mountProviderToolbar();
|
|
6536
|
+
} catch (e) {
|
|
6537
|
+
if (els.ptHint) els.ptHint.textContent = `provider list unavailable: ${(e && e.message) || e}`;
|
|
6538
|
+
}
|
|
6539
|
+
wireCompanion();
|
|
6540
|
+
|
|
5872
6541
|
if (window.aegisFingerprint) {
|
|
5873
6542
|
try {
|
|
5874
6543
|
fpPanel = window.aegisFingerprint.mount({ api: aegis });
|