aegis-desktop 0.7.7 → 0.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -17
- package/lib/local/endpoints.js +228 -0
- package/lib/local/engine.js +138 -18
- package/lib/settings.js +42 -1
- package/package.json +4 -3
- package/renderer/app.js +179 -63
- package/renderer/index.html +1 -0
- package/renderer/preset-fill.js +79 -0
- package/renderer/stream-policy.js +65 -1
- package/renderer/transcript-view.js +5 -0
- package/renderer/usage.js +42 -11
- package/vendor/aegis.js +21 -3
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegis-desktop",
|
|
3
3
|
"productName": "AEGIS Desktop",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.8",
|
|
5
5
|
"description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "AEGIS Code",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"icon": "node scripts/generate-icon.mjs",
|
|
30
30
|
"dist": "npm run predist && electron-builder",
|
|
31
31
|
"dist:dir": "npm run predist && electron-builder --dir",
|
|
32
|
-
"check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/budget.js && node --check renderer/usage.js && node --check renderer/stream-policy.js && node --check renderer/transcript-view.js && node --check renderer/diff.js && node --check renderer/diffview.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check lib/local/context.js && node --check lib/local/providers.js && node --check lib/local/ollama.js && node --check lib/local/engine.js && node --check lib/local/session-rounds.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/local/queue.js && node --check lib/local/autonomous.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check bin/aegis.js",
|
|
32
|
+
"check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/budget.js && node --check renderer/usage.js && node --check renderer/stream-policy.js && node --check renderer/transcript-view.js && node --check renderer/preset-fill.js && node --check renderer/diff.js && node --check renderer/diffview.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check lib/local/context.js && node --check lib/local/providers.js && node --check lib/local/ollama.js && node --check lib/local/engine.js && node --check lib/local/endpoints.js && node --check lib/local/session-rounds.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/local/queue.js && node --check lib/local/autonomous.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check bin/aegis.js",
|
|
33
33
|
"test:shell": "node ../test/desktop-shell.mjs",
|
|
34
34
|
"test:model": "node ../test/model-dispatch.mjs",
|
|
35
35
|
"test:budget": "node ../test/budget.test.mjs",
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
"test:preview-read": "node ../test/desktop-preview-read.test.mjs",
|
|
44
44
|
"test:stream-policy": "node ../test/stream-policy.test.mjs",
|
|
45
45
|
"test:renderer-dom": "node ../test/renderer-dom.test.mjs",
|
|
46
|
-
"test:renderer-wiring": "node ../test/renderer-wiring.test.mjs"
|
|
46
|
+
"test:renderer-wiring": "node ../test/renderer-wiring.test.mjs",
|
|
47
|
+
"test:preset-fill": "node ../test/preset-fill.test.mjs"
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
49
50
|
"electron": "^33.0.0",
|
package/renderer/app.js
CHANGED
|
@@ -165,48 +165,53 @@ const EXPLORE_KEY = 'aegis.explore';
|
|
|
165
165
|
// is only billable/routable through the pooled AEGIS Cloud class.
|
|
166
166
|
const AUTONOMOUS_CLASS = 'aegis';
|
|
167
167
|
const CUSTOM_CLASSES = new Set(['openai-compat', 'anthropic']);
|
|
168
|
+
// Custom classes currently holding a base URL the engine refuses (not local).
|
|
169
|
+
// Tracked so the UI can explain the dead class, and so a preset click may
|
|
170
|
+
// REPLACE the refused URL — applyCustomPreset otherwise never clobbers a
|
|
171
|
+
// configured endpoint, which would leave a blocked row unfixable by click.
|
|
172
|
+
const blockedCustomClasses = new Set();
|
|
168
173
|
// Placeholders for the typed model-id field: custom endpoints enumerate
|
|
169
|
-
// nothing, so the field has to say what a valid id looks like.
|
|
174
|
+
// nothing, so the field has to say what a valid id looks like. The examples
|
|
175
|
+
// are local-server ids — a hosted model id here would advertise a route this
|
|
176
|
+
// class no longer has (see the preset note below).
|
|
170
177
|
const MODEL_ID_PLACEHOLDER = {
|
|
171
|
-
'openai-compat': 'type a model id — e.g.
|
|
172
|
-
anthropic: 'type a model id — e.g. claude-
|
|
178
|
+
'openai-compat': 'type a model id — e.g. llama3.2',
|
|
179
|
+
anthropic: 'type a model id — e.g. claude-3-5-sonnet',
|
|
173
180
|
};
|
|
174
181
|
// Quick-fill presets for the two custom-endpoint classes — model id + the
|
|
175
182
|
// base URL it actually lives at, since typing the right model string is only
|
|
176
|
-
// half the problem (the wrong base URL 400s just as hard).
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
183
|
+
// half the problem (the wrong base URL 400s just as hard).
|
|
184
|
+
//
|
|
185
|
+
// EVERY base URL HERE IS LOCAL, AND THAT IS THE POLICY, NOT AN OMISSION.
|
|
186
|
+
// These two classes are the free direct lane: aegiscode dials the base URL
|
|
187
|
+
// itself, so a remote one would be a model nobody bills. desktop/lib/local/
|
|
188
|
+
// endpoints.js refuses a non-local base URL for both of them, at the settings
|
|
189
|
+
// store (so the row cannot be created) and again at dispatch (so a row hand
|
|
190
|
+
// edited onto disk cannot be dialed). The hosted providers that used to be
|
|
191
|
+
// listed here — OpenAI, Anthropic, Gemini, and DeepSeek (whose live ids,
|
|
192
|
+
// served over DeepSeek's Anthropic-Messages transport, are `deepseek-flash`
|
|
193
|
+
// and `deepseek-v4-pro`) — are all in the relay's BYOK catalog, so their
|
|
194
|
+
// correct home is now the **Bring your own key** class, which bills the flat
|
|
195
|
+
// handling fee. Adding one back here would revive a route that is refused on
|
|
196
|
+
// save.
|
|
197
|
+
//
|
|
198
|
+
// The two transports stay distinct because wire format is not inferable from
|
|
199
|
+
// a local URL: `openai-compat` posts /chat/completions, `anthropic` posts the
|
|
200
|
+
// Messages API. A given local server answers one of them, so pick the class
|
|
201
|
+
// that matches what it serves. A preset's model id is what gets pinned, and
|
|
202
|
+
// for a local server that id is whatever *you* loaded — the ids below are the
|
|
203
|
+
// servers' own conventional defaults, and the field still invites your own.
|
|
181
204
|
const CUSTOM_MODEL_PRESETS = {
|
|
182
205
|
'openai-compat': [
|
|
183
|
-
{ label: 'OpenAI
|
|
184
|
-
{ label: '
|
|
185
|
-
{
|
|
186
|
-
|
|
187
|
-
baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai/',
|
|
188
|
-
model: 'gemini-3.5-flash',
|
|
189
|
-
},
|
|
206
|
+
{ label: 'Ollama (OpenAI-compatible shim)', baseURL: 'http://127.0.0.1:11434/v1', model: 'llama3.2' },
|
|
207
|
+
{ label: 'LM Studio', baseURL: 'http://127.0.0.1:1234/v1', model: 'local-model' },
|
|
208
|
+
{ label: 'vLLM', baseURL: 'http://127.0.0.1:8000/v1', model: 'local-model' },
|
|
209
|
+
{ label: 'llama.cpp', baseURL: 'http://127.0.0.1:8080/v1', model: 'local-model' },
|
|
190
210
|
],
|
|
191
211
|
anthropic: [
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
// GET https://api.deepseek.com/v1/models): `deepseek-flash` — the current
|
|
196
|
-
// generation, which DeepSeek calls "Flash 4.1" — and the slow tier
|
|
197
|
-
// `deepseek-v4-pro`, retired 2026-09-14 and now served as 4.1 too. The
|
|
198
|
-
// preset that used to sit here, `deepseek-v4-flash`, is a *legacy alias*
|
|
199
|
-
// DeepSeek keeps alive only for configs already carrying it, so the picker
|
|
200
|
-
// was advertising a previous generation by its dead id. Ids and labels
|
|
201
|
-
// match aegiscodex-dev src/models.js; the base URL is DeepSeek's
|
|
202
|
-
// Anthropic-Messages transport (aegis1 services/nexus_provider/catalog.py
|
|
203
|
-
// DEEPSEEK_DEFAULT_BASE), which is why these two sit under this class and
|
|
204
|
-
// not the OpenAI-compatible one. Both ids are reasoning models: the token
|
|
205
|
-
// budget for them is the Effort rung, never a stated number (they bill
|
|
206
|
-
// hidden chain-of-thought against the same budget as the answer — see
|
|
207
|
-
// budget.js).
|
|
208
|
-
{ label: 'DeepSeek — Flash 4.1', baseURL: 'https://api.deepseek.com/anthropic', model: 'deepseek-flash' },
|
|
209
|
-
{ label: 'DeepSeek — V4 Pro (retired → 4.1)', baseURL: 'https://api.deepseek.com/anthropic', model: 'deepseek-v4-pro' },
|
|
212
|
+
// Anything local that speaks the Anthropic Messages API — a LiteLLM
|
|
213
|
+
// proxy, claude-code-router, or a gateway on your own LAN.
|
|
214
|
+
{ label: 'LiteLLM proxy (Messages API)', baseURL: 'http://127.0.0.1:4000', model: 'local-model' },
|
|
210
215
|
],
|
|
211
216
|
};
|
|
212
217
|
// The in-app AEGIS key is stored in a reserved namespace the main process
|
|
@@ -422,12 +427,17 @@ function abortBranches() {
|
|
|
422
427
|
// instead of only as pure math in stream-policy.js.
|
|
423
428
|
|
|
424
429
|
/**
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
430
|
+
* Turn identity, for the stop path. `turnSeq` is monotonic and never reset;
|
|
431
|
+
* `runningTurn` is the token of the turn in progress (null when idle) and
|
|
432
|
+
* `stoppedTurn` the token already asked to stop. The abort is not
|
|
433
|
+
* instantaneous, so a second Escape lands while the first turn still looks
|
|
434
|
+
* running — comparing tokens (see `stopAppliesTo`) is what keeps that second
|
|
435
|
+
* press from re-entering, and what stops a held key's repeats from reaching the
|
|
436
|
+
* *next* turn once this one has ended.
|
|
429
437
|
*/
|
|
430
|
-
let
|
|
438
|
+
let turnSeq = 0;
|
|
439
|
+
let runningTurn = null;
|
|
440
|
+
let stoppedTurn = null;
|
|
431
441
|
|
|
432
442
|
/**
|
|
433
443
|
* Transcript policy, created by init(): the reader's scroll veto, the
|
|
@@ -461,9 +471,16 @@ function rafPainter(paint) {
|
|
|
461
471
|
*/
|
|
462
472
|
function stopPendingTurn() {
|
|
463
473
|
if (!pendingSessionId) return false;
|
|
474
|
+
// Idempotent, and scoped to the turn that is actually running: a second press
|
|
475
|
+
// during teardown is the same intent (`stopAppliesTo` returns false and
|
|
476
|
+
// nothing is re-entered). A held key's repeats are refused earlier, by the
|
|
477
|
+
// `e.repeat` guard in transcript-view.js.
|
|
478
|
+
if (!stopAppliesTo(runningTurn, stoppedTurn)) return false;
|
|
464
479
|
// Recorded before the abort lands: `send()`'s catch reads it to tell a
|
|
465
|
-
// deliberate stop from a real error.
|
|
466
|
-
|
|
480
|
+
// deliberate stop from a real error. Scoped to this one turn — a global flag
|
|
481
|
+
// could be left `true` by an earlier turn and make an unrelated transport
|
|
482
|
+
// failure look like a stop the user asked for.
|
|
483
|
+
stoppedTurn = runningTurn;
|
|
467
484
|
try {
|
|
468
485
|
models.cancel(pendingSessionId);
|
|
469
486
|
} catch {
|
|
@@ -2319,8 +2336,11 @@ function captureDiffPreview(tool) {
|
|
|
2319
2336
|
* already ran, so this is a retrospective log line, not a live spinner.
|
|
2320
2337
|
*/
|
|
2321
2338
|
function toolActivityLabel(tool) {
|
|
2322
|
-
const { name, args
|
|
2323
|
-
|
|
2339
|
+
const { name, args } = tool || {};
|
|
2340
|
+
// The glyph is `toolMark`'s decision, not `ok === false ? '✗' : '✓'`: a call
|
|
2341
|
+
// whose result never arrived (aborted between call and result) used to draw
|
|
2342
|
+
// as ✓, claiming success for work this turn never finished.
|
|
2343
|
+
const mark = toolMark(tool);
|
|
2324
2344
|
const a = args || {};
|
|
2325
2345
|
if (name === 'task') {
|
|
2326
2346
|
const kind = a.subagent_type && a.subagent_type !== 'general' ? a.subagent_type : 'general';
|
|
@@ -2571,9 +2591,25 @@ async function loadModels(cls) {
|
|
|
2571
2591
|
// `model: "<url>"` and 400'd upstream). `needsModelId` is what turns this
|
|
2572
2592
|
// into an explicit "type a model id" prompt rather than an empty picker.
|
|
2573
2593
|
let needsModelId = true;
|
|
2594
|
+
// A row whose stored base URL is not local is refused by the engine — at
|
|
2595
|
+
// storage, at dispatch, and here. This is the here: without the reason the
|
|
2596
|
+
// class just looks armed and then fails on send, so capture it from the
|
|
2597
|
+
// same call that reports `needsModelId` and show it in place of the
|
|
2598
|
+
// endpoint line. It is recoverable by design — the text says how.
|
|
2599
|
+
let blockedReason = '';
|
|
2574
2600
|
try {
|
|
2575
2601
|
const data = await models.listModels(cls);
|
|
2576
2602
|
if (data && typeof data.needsModelId === 'boolean') needsModelId = data.needsModelId;
|
|
2603
|
+
if (data && typeof data.blockedReason === 'string' && data.blockedReason) {
|
|
2604
|
+
blockedReason = data.blockedReason;
|
|
2605
|
+
}
|
|
2606
|
+
// Track the refusal against the class itself: applyCustomPreset() reads
|
|
2607
|
+
// this set to know that the row's stored endpoint is dead and may be
|
|
2608
|
+
// replaced by a click. A class that lists without a reason is cleared
|
|
2609
|
+
// again, so the replacement exception never widens past rows the engine
|
|
2610
|
+
// is actually refusing.
|
|
2611
|
+
if (blockedReason) blockedCustomClasses.add(cls);
|
|
2612
|
+
else blockedCustomClasses.delete(cls);
|
|
2577
2613
|
if (data && typeof data.baseURL === 'string' && data.baseURL) {
|
|
2578
2614
|
cfg = { ...cfg, baseURL: data.baseURL };
|
|
2579
2615
|
}
|
|
@@ -2622,10 +2658,12 @@ async function loadModels(cls) {
|
|
|
2622
2658
|
els.modelInput.placeholder = needsModelId
|
|
2623
2659
|
? MODEL_ID_PLACEHOLDER[cls] || 'type a model id'
|
|
2624
2660
|
: 'model id';
|
|
2625
|
-
els.modelHint.textContent =
|
|
2626
|
-
?
|
|
2627
|
-
|
|
2628
|
-
|
|
2661
|
+
els.modelHint.textContent = blockedReason
|
|
2662
|
+
? blockedReason
|
|
2663
|
+
: cfg.baseURL
|
|
2664
|
+
? `endpoint: ${cfg.baseURL} · key: ${cfg.configured ? cfg.keyMask : 'not set'}` +
|
|
2665
|
+
(needsModelId ? ' · type a model id above' : '')
|
|
2666
|
+
: 'Set base URL + key in Provider settings, then type a model id.';
|
|
2629
2667
|
return;
|
|
2630
2668
|
}
|
|
2631
2669
|
|
|
@@ -2658,6 +2696,16 @@ async function loadModels(cls) {
|
|
|
2658
2696
|
hint = null; // carries a link, built below
|
|
2659
2697
|
} else if (!list.length) {
|
|
2660
2698
|
hint = cls === 'ollama' ? 'Ollama not running or no models pulled.' : 'No models listed.';
|
|
2699
|
+
} else if (cls === 'byok' && data && data.needsAegisKey) {
|
|
2700
|
+
// Enforced by this CLIENT, not by the server, so it no longer waits on
|
|
2701
|
+
// the server's fee.require_balance flag (off by default): the shared
|
|
2702
|
+
// local engine refuses a keyless BYOK send outright (401), because an
|
|
2703
|
+
// unattributed turn is served and billed to nobody — the fee lands on
|
|
2704
|
+
// user 0 as uncollected. Saying "required" here is now literal, not a
|
|
2705
|
+
// nudge. Checked BEFORE needsProviderKey because the account key is the
|
|
2706
|
+
// blocker and the provider key is not yet the question.
|
|
2707
|
+
hint = `${list.length} model${list.length === 1 ? '' : 's'} available — ` +
|
|
2708
|
+
'an AEGIS account key is required: the BYOK handling fee is billed there.';
|
|
2661
2709
|
} else if (cls === 'byok' && data && data.needsProviderKey) {
|
|
2662
2710
|
// Unlike the pooled 'aegis' class, byok still shows every model here —
|
|
2663
2711
|
// the catalog answers with no key at all — but none of them are
|
|
@@ -2699,6 +2747,12 @@ async function loadModels(cls) {
|
|
|
2699
2747
|
* already-configured base URL is left alone (never silently overwritten) —
|
|
2700
2748
|
* if it doesn't match what the preset expects, the hint says so instead, so
|
|
2701
2749
|
* the user's own custom endpoint can't be clobbered by a stray click.
|
|
2750
|
+
*
|
|
2751
|
+
* The one exception is a BLOCKED row: when the engine refuses the stored
|
|
2752
|
+
* endpoint (non-local — see blockedCustomClasses), there is nothing left to
|
|
2753
|
+
* preserve, so the preset URL overwrites the field unconditionally. Without
|
|
2754
|
+
* that, the local-only policy would leave the row unfixable by click; the
|
|
2755
|
+
* hint then tells the user to Save so the replacement is actually stored.
|
|
2702
2756
|
*/
|
|
2703
2757
|
function applyCustomPreset(cls, modelId) {
|
|
2704
2758
|
const preset = (CUSTOM_MODEL_PRESETS[cls] || []).find((p) => p.model === modelId);
|
|
@@ -2712,14 +2766,17 @@ function applyCustomPreset(cls, modelId) {
|
|
|
2712
2766
|
const row = els.settingsList.querySelector(`.setting-row[data-provider="${cls}"]`);
|
|
2713
2767
|
const baseInput = row && row.querySelector('.setting-base');
|
|
2714
2768
|
if (!baseInput) return;
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2769
|
+
|
|
2770
|
+
// Which branch applies (fill / refuse-to-clobber / repair a blocked row) is
|
|
2771
|
+
// preset-fill.js's decision, unit-tested there. This function only writes
|
|
2772
|
+
// what it returns — `null` on either field means leave it as it is.
|
|
2773
|
+
const plan = planPresetFill({
|
|
2774
|
+
preset,
|
|
2775
|
+
current: baseInput.value.trim(),
|
|
2776
|
+
blocked: blockedCustomClasses.has(cls),
|
|
2777
|
+
});
|
|
2778
|
+
if (plan.baseURL !== null) baseInput.value = plan.baseURL;
|
|
2779
|
+
if (plan.hint !== null) els.modelHint.textContent = plan.hint;
|
|
2723
2780
|
}
|
|
2724
2781
|
|
|
2725
2782
|
// -------------------------------------------------------------- settings pane
|
|
@@ -2843,6 +2900,29 @@ async function loadSettings() {
|
|
|
2843
2900
|
onRemove: () => removeSetting(provider),
|
|
2844
2901
|
}));
|
|
2845
2902
|
}
|
|
2903
|
+
// What AEGIS charges for relaying the turn, stated outright. The figure is
|
|
2904
|
+
// the server's own (`fee` on GET /api/v1/byok/providers); nothing is shown
|
|
2905
|
+
// when it publishes none, because a hardcoded client-side fee is one that
|
|
2906
|
+
// can drift from the ledger that actually bills. Until this line existed
|
|
2907
|
+
// the only place the fee was ever disclosed was the CLI's `/class byok`
|
|
2908
|
+
// output — a desktop user's per-turn cost figure was the vendor's rate with
|
|
2909
|
+
// no mention that AEGIS also collects.
|
|
2910
|
+
const fee = byokData && byokData.fee;
|
|
2911
|
+
if (fee && !fee.disabled &&
|
|
2912
|
+
(Number(fee.in_usd_per_1k) > 0 || Number(fee.out_usd_per_1k) > 0)) {
|
|
2913
|
+
const note = document.createElement('div');
|
|
2914
|
+
note.className = 'setting-row';
|
|
2915
|
+
note.id = 'byok-fee-note';
|
|
2916
|
+
const text = document.createElement('div');
|
|
2917
|
+
text.className = 'setting-name';
|
|
2918
|
+
const per1k = (n) => `${Number(n).toFixed(4)}`;
|
|
2919
|
+
text.textContent =
|
|
2920
|
+
`BYOK handling fee: ${per1k(fee.in_usd_per_1k)} / 1k in, ` +
|
|
2921
|
+
`${per1k(fee.out_usd_per_1k)} / 1k out — billed to your AEGIS account ` +
|
|
2922
|
+
'on top of your own provider bill.';
|
|
2923
|
+
note.appendChild(text);
|
|
2924
|
+
els.settingsList.appendChild(note);
|
|
2925
|
+
}
|
|
2846
2926
|
} catch {
|
|
2847
2927
|
/* catalog unreachable (offline, server down) — the two custom rows above still work */
|
|
2848
2928
|
}
|
|
@@ -2854,6 +2934,10 @@ async function saveSetting(provider, baseURL, key) {
|
|
|
2854
2934
|
const cfg = { baseURL };
|
|
2855
2935
|
if (key) cfg.key = key;
|
|
2856
2936
|
await models.settings.set(provider, cfg);
|
|
2937
|
+
// Stored: whatever the engine refused is gone, so drop the blocked mark
|
|
2938
|
+
// and let loadModels() below re-establish it from the fresh refusal (if
|
|
2939
|
+
// any). Replacing a refused endpoint is exactly what unblocks the row.
|
|
2940
|
+
blockedCustomClasses.delete(provider);
|
|
2857
2941
|
els.settingsHint.textContent = 'saved.';
|
|
2858
2942
|
await loadSettings();
|
|
2859
2943
|
await loadModels(els.classSelect.value);
|
|
@@ -2867,6 +2951,8 @@ async function removeSetting(provider) {
|
|
|
2867
2951
|
els.settingsHint.textContent = 'removing…';
|
|
2868
2952
|
try {
|
|
2869
2953
|
await models.settings.remove(provider);
|
|
2954
|
+
// Nothing stored means nothing to refuse; clear the blocked mark too.
|
|
2955
|
+
blockedCustomClasses.delete(provider);
|
|
2870
2956
|
els.settingsHint.textContent = 'removed.';
|
|
2871
2957
|
await loadSettings();
|
|
2872
2958
|
await loadModels(els.classSelect.value);
|
|
@@ -3102,6 +3188,11 @@ function newChat() {
|
|
|
3102
3188
|
els.sessionsHint.textContent = '';
|
|
3103
3189
|
pendingEl = null;
|
|
3104
3190
|
pendingSessionId = null;
|
|
3191
|
+
// A new thread owns no turn: dropping the running token here means any
|
|
3192
|
+
// in-flight `send()` whose teardown has not run yet can no longer match
|
|
3193
|
+
// itself (see the `finally` guard) and cannot clear this thread's state.
|
|
3194
|
+
runningTurn = null;
|
|
3195
|
+
stoppedTurn = null;
|
|
3105
3196
|
currentSessionId = null;
|
|
3106
3197
|
threadMessages = [];
|
|
3107
3198
|
flowCount = 0;
|
|
@@ -3155,9 +3246,12 @@ async function send() {
|
|
|
3155
3246
|
}
|
|
3156
3247
|
|
|
3157
3248
|
els.prompt.value = '';
|
|
3158
|
-
//
|
|
3159
|
-
//
|
|
3160
|
-
|
|
3249
|
+
// This turn's identity. Claiming it here — and clearing `stoppedTurn` —
|
|
3250
|
+
// replaces the old global "userStopped" flag, which any previous turn could
|
|
3251
|
+
// leave set and which this send could only hope to clear in time.
|
|
3252
|
+
const myTurn = ++turnSeq;
|
|
3253
|
+
runningTurn = myTurn;
|
|
3254
|
+
stoppedTurn = null;
|
|
3161
3255
|
addMessage('user', prompt);
|
|
3162
3256
|
|
|
3163
3257
|
// What this request travels with, resolved from ONE authority by budgetFor:
|
|
@@ -3306,6 +3400,15 @@ async function send() {
|
|
|
3306
3400
|
if (est) bits.push(`~${est.input + est.output} tokens`);
|
|
3307
3401
|
}
|
|
3308
3402
|
if (turn.cost != null) bits.push(fmtCost(turn.cost, turn.real));
|
|
3403
|
+
// …and on the byok class, whose money that figure is. The relay never
|
|
3404
|
+
// returns a settled charge (nothing on that route emits one), so this is
|
|
3405
|
+
// always the rate table's estimate of the caller's OWN provider bill — and
|
|
3406
|
+
// AEGIS additionally collects a handling fee on the same turn, which this
|
|
3407
|
+
// response cannot price. One bit, and the number stops reading as the whole
|
|
3408
|
+
// cost of a BYOK turn.
|
|
3409
|
+
if (cls === 'byok' && turn.cost != null) {
|
|
3410
|
+
bits.push('your provider bill (est.) + AEGIS handling fee');
|
|
3411
|
+
}
|
|
3309
3412
|
// …AND the running session total beside it, which is the number the CLI
|
|
3310
3413
|
// prints. The per-turn figure answers "what did that call cost"; only the
|
|
3311
3414
|
// rolling one answers "what has this conversation cost", and it was the
|
|
@@ -3366,8 +3469,13 @@ async function send() {
|
|
|
3366
3469
|
// everything already streamed and answer with a red "aborted" error,
|
|
3367
3470
|
// destroying the partial reply at the exact moment the user asked to keep
|
|
3368
3471
|
// it. Salvage the partial turn and label it honestly instead.
|
|
3369
|
-
if (isCancellation(err, { userStopped })) {
|
|
3370
|
-
|
|
3472
|
+
if (isCancellation(err, { userStopped: stoppedTurn === myTurn })) {
|
|
3473
|
+
// Which of the three outcomes this is — answer text, deliberation only,
|
|
3474
|
+
// or nothing — is `salvageTurn`'s decision, so the labelling cannot
|
|
3475
|
+
// drift from the salvage.
|
|
3476
|
+
const salvage = salvageTurn({ streamedText, reasoningText });
|
|
3477
|
+
const text = salvage.text;
|
|
3478
|
+
const salvageReasoning = salvage.reasoning;
|
|
3371
3479
|
threadMessages.push({ role: 'assistant', content: text });
|
|
3372
3480
|
// A stopped turn is a real exchange and the CLI records one: its
|
|
3373
3481
|
// `appendHistory` writes a `status: 'stopped'` entry for every stopped
|
|
@@ -3382,12 +3490,13 @@ async function send() {
|
|
|
3382
3490
|
model,
|
|
3383
3491
|
prompt,
|
|
3384
3492
|
reply: text,
|
|
3385
|
-
reasoning:
|
|
3493
|
+
reasoning: salvageReasoning,
|
|
3386
3494
|
});
|
|
3387
3495
|
const stopBits = ['stopped by you'];
|
|
3496
|
+
if (salvage.kind === 'reasoning-only') stopBits.push('reasoning only');
|
|
3388
3497
|
if (turn.tokens != null) stopBits.push(`tokens: ${turn.tokens}`);
|
|
3389
3498
|
else {
|
|
3390
|
-
const est = estimatedBuckets(prompt, text,
|
|
3499
|
+
const est = estimatedBuckets(prompt, text, salvageReasoning);
|
|
3391
3500
|
if (est) stopBits.push(`~${est.input + est.output} tokens`);
|
|
3392
3501
|
}
|
|
3393
3502
|
const stopRollLine = fmtRoll(roll);
|
|
@@ -3400,7 +3509,7 @@ async function send() {
|
|
|
3400
3509
|
...ledgerFields(undefined, model, turn, {
|
|
3401
3510
|
prompt,
|
|
3402
3511
|
reply: text,
|
|
3403
|
-
reasoning:
|
|
3512
|
+
reasoning: salvageReasoning,
|
|
3404
3513
|
}),
|
|
3405
3514
|
});
|
|
3406
3515
|
} catch {
|
|
@@ -3419,7 +3528,14 @@ async function send() {
|
|
|
3419
3528
|
}
|
|
3420
3529
|
} finally {
|
|
3421
3530
|
setBusy(false);
|
|
3422
|
-
|
|
3531
|
+
// Only this turn may clear the pending state, and only if it is still the
|
|
3532
|
+
// running one. Unconditionally nulling it here is what let an outgoing
|
|
3533
|
+
// turn's teardown disarm its successor — after which Escape during that
|
|
3534
|
+
// successor did nothing at all.
|
|
3535
|
+
if (runningTurn === myTurn) {
|
|
3536
|
+
runningTurn = null;
|
|
3537
|
+
pendingSessionId = null;
|
|
3538
|
+
}
|
|
3423
3539
|
loadSessions();
|
|
3424
3540
|
}
|
|
3425
3541
|
}
|
package/renderer/index.html
CHANGED
|
@@ -444,6 +444,7 @@
|
|
|
444
444
|
<script src="usage.js"></script>
|
|
445
445
|
<script src="stream-policy.js"></script>
|
|
446
446
|
<script src="transcript-view.js"></script>
|
|
447
|
+
<script src="preset-fill.js"></script>
|
|
447
448
|
<script src="diff.js"></script>
|
|
448
449
|
<script src="diffview.js"></script>
|
|
449
450
|
<script src="vendor/marked.umd.js"></script>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The decision behind clicking a Model-card preset: what to type into the
|
|
5
|
+
* model-id field, whether to touch the base-URL field, and what the hint
|
|
6
|
+
* should say. Kept out of app.js — like stream-policy.js and budget.js — so
|
|
7
|
+
* the rule is testable from plain Node without the Electron host. app.js only
|
|
8
|
+
* calls into this and performs the DOM writes.
|
|
9
|
+
*
|
|
10
|
+
* Why it exists: after the local-only endpoint policy, one branch here is
|
|
11
|
+
* load-bearing rather than cosmetic. A row whose stored endpoint the engine
|
|
12
|
+
* REFUSES (non-local — see lib/local/endpoints.js) must be repairable by
|
|
13
|
+
* click. The general rule is "never silently overwrite a configured base URL":
|
|
14
|
+
* the user's own custom endpoint must not be clobbered by a stray preset
|
|
15
|
+
* click, so when the field disagrees with the preset the hint says so instead
|
|
16
|
+
* of writing. That rule would leave a refused row unfixable forever — there is
|
|
17
|
+
* nothing left to preserve in a URL the engine will not dial — so `blocked`
|
|
18
|
+
* is the one exception and overwrites outright.
|
|
19
|
+
*
|
|
20
|
+
* The return value is a plan, not a mutation: `baseURL: null` and `hint: null`
|
|
21
|
+
* both mean "leave that alone", which keeps a caller from being able to write
|
|
22
|
+
* an unintended value by ignoring a field.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** What the hint says when the preset's endpoint replaces a refused one. */
|
|
26
|
+
function blockedFillHint(baseURL) {
|
|
27
|
+
return (
|
|
28
|
+
`stored endpoint refused — replaced with ${baseURL}; ` +
|
|
29
|
+
'click Save in Provider settings to replace the refused endpoint.'
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Plan one preset click.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} input
|
|
37
|
+
* @param {object} input.preset a CUSTOM_MODEL_PRESETS entry ({model,baseURL,label})
|
|
38
|
+
* @param {string} input.current the base-URL field's current value, trimmed
|
|
39
|
+
* @param {boolean} input.blocked the engine refused this class's stored endpoint
|
|
40
|
+
* @returns {{model: string, baseURL: string|null, hint: string|null}|null}
|
|
41
|
+
* null when there is no preset to apply (the caller does nothing).
|
|
42
|
+
*/
|
|
43
|
+
function planPresetFill(input) {
|
|
44
|
+
const args = input || {};
|
|
45
|
+
const preset = args.preset;
|
|
46
|
+
// No preset means no click to act on. Guarded on the model id specifically:
|
|
47
|
+
// the model field is what every branch is required to write.
|
|
48
|
+
if (!preset || !preset.model) return null;
|
|
49
|
+
const current = typeof args.current === 'string' ? args.current : '';
|
|
50
|
+
|
|
51
|
+
// The one case that overwrites a configured endpoint. Checked first so it
|
|
52
|
+
// cannot be shadowed by the !current / mismatch branches below.
|
|
53
|
+
if (args.blocked) {
|
|
54
|
+
return { model: preset.model, baseURL: preset.baseURL, hint: blockedFillHint(preset.baseURL) };
|
|
55
|
+
}
|
|
56
|
+
if (!current) {
|
|
57
|
+
return {
|
|
58
|
+
model: preset.model,
|
|
59
|
+
baseURL: preset.baseURL,
|
|
60
|
+
hint: `filled in — click Save in Provider settings below to store the ${preset.label} endpoint.`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (current !== preset.baseURL) {
|
|
64
|
+
return {
|
|
65
|
+
model: preset.model,
|
|
66
|
+
baseURL: null, // never clobber a configured endpoint
|
|
67
|
+
hint: `${preset.label} needs base URL ${preset.baseURL} — Provider settings below has ${current}. Update it there too.`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// Already pointing at the preset's endpoint: nothing to write and nothing to
|
|
71
|
+
// say. `hint: null` rather than '' — the hint element also carries the
|
|
72
|
+
// endpoint/key line and the max-output suffix, so this branch must not clear
|
|
73
|
+
// text it did not set.
|
|
74
|
+
return { model: preset.model, baseURL: null, hint: null };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
78
|
+
module.exports = { planPresetFill, blockedFillHint };
|
|
79
|
+
}
|
|
@@ -83,6 +83,70 @@ function isCancellation(err, opts) {
|
|
|
83
83
|
return ABORT_SIGNATURES.some((re) => re.test(msg));
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Should a stop request reach the turn running right now?
|
|
88
|
+
*
|
|
89
|
+
* `runningTurn` is the token of the turn in progress (null when idle) and
|
|
90
|
+
* `stoppedTurn` is the token already asked to stop. The abort is not
|
|
91
|
+
* instantaneous — the transport has to unwind before `send()`'s catch runs — so
|
|
92
|
+
* a second press lands on a turn that is still nominally running. That second
|
|
93
|
+
* press is the *same* intent and must not re-enter.
|
|
94
|
+
*
|
|
95
|
+
* The two writers keep the pair consistent: app.js only ever assigns
|
|
96
|
+
* `stoppedTurn = runningTurn`, and claiming a new turn sets `stoppedTurn = null`
|
|
97
|
+
* alongside `runningTurn = myTurn`. So `stoppedTurn` is never some *other*
|
|
98
|
+
* turn's number, and `runningTurn !== stoppedTurn` means exactly "not already
|
|
99
|
+
* stopping". What keeps a finished turn's stop off its successor is that
|
|
100
|
+
* invariant plus the catch's `stoppedTurn === myTurn` — not a refusal here. A
|
|
101
|
+
* press on a live successor is legitimate and does reach it.
|
|
102
|
+
*/
|
|
103
|
+
function stopAppliesTo(runningTurn, stoppedTurn) {
|
|
104
|
+
if (runningTurn == null) return false;
|
|
105
|
+
return runningTurn !== stoppedTurn;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* What a cancelled turn leaves on screen, and what it honestly is.
|
|
110
|
+
*
|
|
111
|
+
* `streamedText || reasoningText` collapsed two different outcomes into one
|
|
112
|
+
* string: a turn cut off after real answer text, and a turn cut off with
|
|
113
|
+
* deliberation but no answer. The second is not a failure and the user should
|
|
114
|
+
* keep it, but folding it in as if it were the answer loses the distinction —
|
|
115
|
+
* so the case is named here and the caller labels it. With neither, the
|
|
116
|
+
* placeholder is still returned rather than an empty bubble.
|
|
117
|
+
*/
|
|
118
|
+
function salvageTurn(input) {
|
|
119
|
+
const i = input || {};
|
|
120
|
+
const streamed = typeof i.streamedText === 'string' ? i.streamedText : '';
|
|
121
|
+
const reasoning = typeof i.reasoningText === 'string' ? i.reasoningText : '';
|
|
122
|
+
if (streamed) return { text: streamed, reasoning: reasoning, kind: 'answer' };
|
|
123
|
+
if (reasoning) return { text: reasoning, reasoning: '', kind: 'reasoning-only' };
|
|
124
|
+
return { text: '(stopped before any output)', reasoning: '', kind: 'empty' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The status glyph for one tool-activity line.
|
|
129
|
+
*
|
|
130
|
+
* Only an explicit `true` is a ✓. A call with no `ok` at all is a call whose
|
|
131
|
+
* result never arrived — the tool-call-mid-abort case — and drawing it as ✓
|
|
132
|
+
* claimed success for work the turn never finished. It gets its own mark
|
|
133
|
+
* instead of ✗, because interrupted is not the same as failed.
|
|
134
|
+
*/
|
|
135
|
+
function toolMark(tool) {
|
|
136
|
+
const t = tool || {};
|
|
137
|
+
if (t.ok === true) return '✓';
|
|
138
|
+
if (t.ok === false) return '✗';
|
|
139
|
+
return '⊘';
|
|
140
|
+
}
|
|
141
|
+
|
|
86
142
|
if (typeof module !== 'undefined' && module.exports) {
|
|
87
|
-
module.exports = {
|
|
143
|
+
module.exports = {
|
|
144
|
+
nearBottom,
|
|
145
|
+
shouldFollow,
|
|
146
|
+
isCancellation,
|
|
147
|
+
stopAppliesTo,
|
|
148
|
+
salvageTurn,
|
|
149
|
+
toolMark,
|
|
150
|
+
STICK_SLOP_PX,
|
|
151
|
+
};
|
|
88
152
|
}
|
|
@@ -220,6 +220,11 @@ function bindEscapeInterrupt(deps) {
|
|
|
220
220
|
|
|
221
221
|
function handle(e) {
|
|
222
222
|
if (!e || e.key !== 'Escape') return false;
|
|
223
|
+
// A held key is one intent, not one per repeat. Without this the auto-repeat
|
|
224
|
+
// events keep arriving after the interrupted turn has already ended, and
|
|
225
|
+
// the ones that land once the user has sent the next message stop that turn
|
|
226
|
+
// instead — the keyboard twin of a stuck cancel button.
|
|
227
|
+
if (e.repeat) return false;
|
|
223
228
|
if (typeof d.isOverlayOpen === 'function' && d.isOverlayOpen()) {
|
|
224
229
|
if (typeof d.onOverlayEscape === 'function') d.onOverlayEscape();
|
|
225
230
|
return false;
|