aegis-desktop 1.0.13 → 1.0.15
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/SOURCE-OFFER.txt +9 -0
- package/bin/aegis.js +1 -32
- package/lib/account-key.js +1 -56
- package/lib/auto.js +1 -430
- package/lib/avatar/companion.js +1 -698
- package/lib/avatar/cosmetics.js +1 -532
- package/lib/avatar/events.js +1 -344
- package/lib/avatar/holder-turn.js +1 -350
- package/lib/avatar/holders.js +1 -612
- package/lib/avatar/identity.js +1 -641
- package/lib/avatar/level.js +1 -368
- package/lib/avatar/mirror.js +1 -524
- package/lib/avatar/persona.js +1 -388
- package/lib/avatar/profile.js +1 -977
- package/lib/avatar/register.js +1 -307
- package/lib/avatar/store.js +1 -472
- package/lib/avatar/tiers.js +1 -38
- package/lib/avatar/transfer.js +1 -415
- package/lib/avatar/turn.js +1 -216
- package/lib/avatar/voice.js +1 -328
- package/lib/avatar/xp.js +1 -404
- package/lib/deep-link.js +1 -120
- package/lib/fingerprint.js +2 -347
- package/lib/google-connect.js +1 -169
- package/lib/local/agents.js +1 -105
- package/lib/local/autonomous.js +1 -1045
- package/lib/local/claude-pro.js +1 -993
- package/lib/local/context.js +1 -95
- package/lib/local/engine.js +1 -3228
- package/lib/local/entitlement.js +1 -654
- package/lib/local/git-scope.js +1 -228
- package/lib/local/local.js +1 -2135
- package/lib/local/prompt.js +1 -275
- package/lib/local/queue.js +1 -560
- package/lib/local/session-rounds.js +1 -208
- package/lib/local/shell.js +1 -680
- package/lib/local/telemetry.js +1 -470
- package/lib/local/tools.js +1 -1238
- package/lib/local/turn-guard.js +1 -300
- package/lib/local/worktree-lock.js +1 -235
- package/lib/provider-key-mirror.js +1 -252
- package/lib/quick-launcher.js +1 -84
- package/lib/settings.js +1 -890
- package/lib/sync/memory-queue.js +1 -60
- package/lib/sync/persist-gate.js +1 -148
- package/lib/sync/sessions.js +1 -106
- package/lib/window-state.js +1 -66
- package/main.js +3 -4390
- package/package.json +3 -5
- package/preload.js +3 -628
- package/renderer/app.js +1 -6560
- package/renderer/avatar/assemble.js +1 -335
- package/renderer/avatar/avatar.js +1 -380
- package/renderer/avatar/cards.js +1 -141
- package/renderer/avatar/hud.js +1 -206
- package/renderer/avatar/machine.js +1 -213
- package/renderer/avatar/pane.js +1 -439
- package/renderer/avatar/parts.js +1 -437
- package/renderer/budget.js +1 -168
- package/renderer/companion.js +1 -223
- package/renderer/diff.js +1 -359
- package/renderer/diffview.js +1 -336
- package/renderer/fingerprint.js +3 -720
- package/renderer/markdown.js +1 -168
- package/renderer/provider-toolbar.js +1 -134
- package/renderer/providers.js +1 -259
- package/renderer/quick.js +1 -191
- package/renderer/stream-policy.js +1 -306
- package/renderer/transcript-view.js +1 -323
- package/renderer/usage.js +1 -1239
- package/vendor/aegis.js +1 -2054
- package/vendor/attest.js +1 -442
- package/vendor/authorship-key.js +1 -410
- package/vendor/auto-provision.js +1 -134
- package/vendor/brain-catalog.js +1 -268
- package/vendor/byok-catalog.js +1 -230
- package/vendor/credentials.js +1 -494
- package/vendor/device-fingerprint.js +1 -141
- package/vendor/drive-connect.js +1 -85
- package/vendor/drive-upload.js +1 -174
- package/vendor/env-file.js +1 -1273
- package/vendor/fingerprint.js +1 -1064
- package/vendor/foreign-memory.js +1 -669
- package/vendor/google-signin.js +1 -125
- package/vendor/hooks.js +1 -430
- package/vendor/model-discovery.js +1 -330
- package/vendor/platform.js +1 -937
- package/vendor/prefs.js +1 -351
- package/vendor/presence-constants.js +1 -107
- package/vendor/presence.js +1 -438
- package/vendor/record-vault.js +3 -1055
- package/vendor/session-store.js +1 -548
- package/vendor/topup.js +1 -297
- package/vendor/touch.js +1 -458
- package/vendor/update.js +1 -704
- package/vendor/user-models.js +1 -381
package/renderer/quick.js
CHANGED
|
@@ -1,193 +1,3 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
// Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
|
|
3
|
-
|
|
4
|
-
// Conveying a modified copy, or running one as a network service, owes the
|
|
5
|
-
// corresponding source under this licence (§5/§13) — see LICENSING.md.
|
|
6
|
-
'use strict';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* AEGIS Quick Launcher renderer — a one-shot prompt over window.models.chat
|
|
10
|
-
* (the same LocalEngine transport the main window's chat uses), with the
|
|
11
|
-
* agent loop's tool calling turned off (`tools: false`): a small always-
|
|
12
|
-
* on-top popup is the wrong place for a tool-approval card to pop up in.
|
|
13
|
-
*
|
|
14
|
-
* Same contextIsolation/sandbox posture as renderer/app.js — talks only to
|
|
15
|
-
* window.models / window.quickLauncher, both whitelisted by preload.js.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
(function () {
|
|
19
|
-
if (!window.models || !window.quickLauncher) {
|
|
20
|
-
document.body.textContent =
|
|
21
|
-
'This page must run inside the AEGIS Electron host (window.models/window.quickLauncher missing).';
|
|
22
|
-
throw new Error('window.models/window.quickLauncher unavailable — not running under Electron');
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const models = window.models;
|
|
26
|
-
const quickLauncher = window.quickLauncher;
|
|
27
|
-
|
|
28
|
-
const CLASS = 'aegis';
|
|
29
|
-
const MODEL_KEY = 'quick-launcher-model';
|
|
30
|
-
const IDLE_HINT = 'Enter to ask · Esc to close';
|
|
31
|
-
|
|
32
|
-
const els = {
|
|
33
|
-
form: document.getElementById('ql-form'),
|
|
34
|
-
prompt: document.getElementById('ql-prompt'),
|
|
35
|
-
response: document.getElementById('ql-response'),
|
|
36
|
-
modelLabel: document.getElementById('ql-model-label'),
|
|
37
|
-
modelSelect: document.getElementById('ql-model-select'),
|
|
38
|
-
stopBtn: document.getElementById('ql-stop'),
|
|
39
|
-
pushBtn: document.getElementById('ql-push'),
|
|
40
|
-
status: document.getElementById('ql-status'),
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
let busy = false;
|
|
44
|
-
let sessionId = null;
|
|
45
|
-
let lastPrompt = '';
|
|
46
|
-
let lastResponse = '';
|
|
47
|
-
let hasResult = false;
|
|
48
|
-
|
|
49
|
-
function setStatus(text) {
|
|
50
|
-
els.status.textContent = text;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function setBusy(next) {
|
|
54
|
-
busy = next;
|
|
55
|
-
els.stopBtn.hidden = !next;
|
|
56
|
-
els.prompt.disabled = next;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Populate the model picker for the fixed 'aegis' class. Degrades to a
|
|
60
|
-
* plain label (no picker) when there is no key configured or the catalog
|
|
61
|
-
* call fails — the launcher still opens, it just can't send anything. */
|
|
62
|
-
async function loadModel() {
|
|
63
|
-
try {
|
|
64
|
-
const classes = (await models.listClasses()) || [];
|
|
65
|
-
const aegisClass = classes.find((c) => c.class === CLASS);
|
|
66
|
-
if (!aegisClass || !aegisClass.configured) {
|
|
67
|
-
els.modelLabel.textContent = 'Aegis Cloud — no key configured';
|
|
68
|
-
els.modelSelect.hidden = true;
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
const data = await models.listModels(CLASS);
|
|
72
|
-
const list = (data && data.models) || [];
|
|
73
|
-
els.modelSelect.innerHTML = '';
|
|
74
|
-
for (const m of list) {
|
|
75
|
-
const opt = document.createElement('option');
|
|
76
|
-
opt.value = m.id;
|
|
77
|
-
opt.textContent = m.label || m.id;
|
|
78
|
-
els.modelSelect.appendChild(opt);
|
|
79
|
-
}
|
|
80
|
-
const saved = localStorage.getItem(MODEL_KEY);
|
|
81
|
-
if (saved && list.some((m) => m.id === saved)) {
|
|
82
|
-
els.modelSelect.value = saved;
|
|
83
|
-
}
|
|
84
|
-
els.modelSelect.hidden = list.length <= 1;
|
|
85
|
-
els.modelLabel.textContent = 'Aegis Cloud';
|
|
86
|
-
} catch {
|
|
87
|
-
els.modelLabel.textContent = 'Aegis Cloud — unavailable';
|
|
88
|
-
els.modelSelect.hidden = true;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
els.modelSelect.addEventListener('change', () => {
|
|
93
|
-
localStorage.setItem(MODEL_KEY, els.modelSelect.value);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
async function ask(prompt) {
|
|
97
|
-
if (!prompt || busy) return;
|
|
98
|
-
hasResult = false;
|
|
99
|
-
els.pushBtn.hidden = true;
|
|
100
|
-
els.response.textContent = '';
|
|
101
|
-
setBusy(true);
|
|
102
|
-
setStatus('thinking…');
|
|
103
|
-
sessionId = `quick-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
104
|
-
let text = '';
|
|
105
|
-
try {
|
|
106
|
-
const res = await models.chat(
|
|
107
|
-
{
|
|
108
|
-
class: CLASS,
|
|
109
|
-
model: els.modelSelect.value || undefined,
|
|
110
|
-
prompt,
|
|
111
|
-
tools: false,
|
|
112
|
-
sessionId,
|
|
113
|
-
},
|
|
114
|
-
(chunk) => {
|
|
115
|
-
if (chunk && typeof chunk.delta === 'string' && chunk.delta) {
|
|
116
|
-
text += chunk.delta;
|
|
117
|
-
els.response.textContent = text;
|
|
118
|
-
els.response.scrollTop = els.response.scrollHeight;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
);
|
|
122
|
-
const msg = res && res.choices && res.choices[0] && res.choices[0].message;
|
|
123
|
-
const finalText = text || (msg && msg.content) || '';
|
|
124
|
-
els.response.textContent = finalText;
|
|
125
|
-
lastPrompt = prompt;
|
|
126
|
-
lastResponse = finalText;
|
|
127
|
-
hasResult = Boolean(finalText);
|
|
128
|
-
els.pushBtn.hidden = !hasResult;
|
|
129
|
-
setStatus(hasResult ? '⌘/Ctrl+⏎ to add this to the main chat' : 'no answer');
|
|
130
|
-
} catch (err) {
|
|
131
|
-
els.response.textContent = `Error: ${err && err.message ? err.message : err}`;
|
|
132
|
-
setStatus('failed');
|
|
133
|
-
} finally {
|
|
134
|
-
setBusy(false);
|
|
135
|
-
sessionId = null;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function stop() {
|
|
140
|
-
if (sessionId) models.cancel(sessionId).catch(() => {});
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function pushToMain() {
|
|
144
|
-
if (!hasResult) return;
|
|
145
|
-
quickLauncher
|
|
146
|
-
.pushToMain({
|
|
147
|
-
prompt: lastPrompt,
|
|
148
|
-
response: lastResponse,
|
|
149
|
-
model: els.modelSelect.value || null,
|
|
150
|
-
})
|
|
151
|
-
.catch(() => {});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
els.form.addEventListener('submit', (e) => {
|
|
155
|
-
e.preventDefault();
|
|
156
|
-
ask(els.prompt.value.trim());
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
els.prompt.addEventListener('keydown', (e) => {
|
|
160
|
-
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
161
|
-
e.preventDefault();
|
|
162
|
-
pushToMain();
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
166
|
-
e.preventDefault();
|
|
167
|
-
ask(els.prompt.value.trim());
|
|
168
|
-
}
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
els.stopBtn.addEventListener('click', stop);
|
|
172
|
-
els.pushBtn.addEventListener('click', pushToMain);
|
|
173
|
-
|
|
174
|
-
// The window is shown/hidden, not reloaded, on every shortcut press (see
|
|
175
|
-
// main.js toggleQuickLauncher) — so each time it regains focus is a fresh
|
|
176
|
-
// "session" from the user's point of view and should start clean, unless a
|
|
177
|
-
// stream from the previous session is still running in the background.
|
|
178
|
-
window.addEventListener('focus', () => {
|
|
179
|
-
if (busy) {
|
|
180
|
-
els.prompt.focus();
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
els.prompt.value = '';
|
|
184
|
-
els.response.textContent = '';
|
|
185
|
-
els.pushBtn.hidden = true;
|
|
186
|
-
hasResult = false;
|
|
187
|
-
setStatus(IDLE_HINT);
|
|
188
|
-
els.prompt.focus();
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
loadModel();
|
|
192
|
-
els.prompt.focus();
|
|
193
|
-
})();
|
|
3
|
+
"use strict";!function(){if(!window.models||!window.quickLauncher)throw document.body.textContent="This page must run inside the AEGIS Electron host (window.models/window.quickLauncher missing).",new Error("window.models/window.quickLauncher unavailable — not running under Electron");const e=window.models,t=window.quickLauncher,n="aegis",o="quick-launcher-model",l={form:document.getElementById("ql-form"),prompt:document.getElementById("ql-prompt"),response:document.getElementById("ql-response"),modelLabel:document.getElementById("ql-model-label"),modelSelect:document.getElementById("ql-model-select"),stopBtn:document.getElementById("ql-stop"),pushBtn:document.getElementById("ql-push"),status:document.getElementById("ql-status")};let s=!1,d=null,c="",i="",a=!1;function r(e){l.status.textContent=e}function m(e){s=e,l.stopBtn.hidden=!e,l.prompt.disabled=e}async function u(t){if(!t||s)return;a=!1,l.pushBtn.hidden=!0,l.response.textContent="",m(!0),r("thinking…"),d=`quick-${Date.now()}-${Math.random().toString(36).slice(2)}`;let o="";try{const s=await e.chat({class:n,model:l.modelSelect.value||void 0,prompt:t,tools:!1,sessionId:d},(e=>{e&&"string"==typeof e.delta&&e.delta&&(o+=e.delta,l.response.textContent=o,l.response.scrollTop=l.response.scrollHeight)})),m=s&&s.choices&&s.choices[0]&&s.choices[0].message,u=o||m&&m.content||"";l.response.textContent=u,c=t,i=u,a=Boolean(u),l.pushBtn.hidden=!a,r(a?"⌘/Ctrl+⏎ to add this to the main chat":"no answer")}catch(e){l.response.textContent=`Error: ${e&&e.message?e.message:e}`,r("failed")}finally{m(!1),d=null}}function p(){a&&t.pushToMain({prompt:c,response:i,model:l.modelSelect.value||null}).catch((()=>{}))}l.modelSelect.addEventListener("change",(()=>{localStorage.setItem(o,l.modelSelect.value)})),l.form.addEventListener("submit",(e=>{e.preventDefault(),u(l.prompt.value.trim())})),l.prompt.addEventListener("keydown",(e=>{if("Enter"===e.key&&(e.metaKey||e.ctrlKey))return e.preventDefault(),void p();"Enter"!==e.key||e.shiftKey||(e.preventDefault(),u(l.prompt.value.trim()))})),l.stopBtn.addEventListener("click",(function(){d&&e.cancel(d).catch((()=>{}))})),l.pushBtn.addEventListener("click",p),window.addEventListener("focus",(()=>{s||(l.prompt.value="",l.response.textContent="",l.pushBtn.hidden=!0,a=!1,r("Enter to ask · Esc to close")),l.prompt.focus()})),async function(){try{const t=(await e.listClasses()||[]).find((e=>e.class===n));if(!t||!t.configured)return l.modelLabel.textContent="Aegis Cloud — no key configured",void(l.modelSelect.hidden=!0);const s=await e.listModels(n),d=s&&s.models||[];l.modelSelect.innerHTML="";for(const e of d){const t=document.createElement("option");t.value=e.id,t.textContent=e.label||e.id,l.modelSelect.appendChild(t)}const c=localStorage.getItem(o);c&&d.some((e=>e.id===c))&&(l.modelSelect.value=c),l.modelSelect.hidden=d.length<=1,l.modelLabel.textContent="Aegis Cloud"}catch{l.modelLabel.textContent="Aegis Cloud — unavailable",l.modelSelect.hidden=!0}}(),l.prompt.focus()}();
|
|
@@ -1,308 +1,3 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
// Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
|
|
3
|
-
|
|
4
|
-
// Conveying a modified copy, or running one as a network service, owes the
|
|
5
|
-
// corresponding source under this licence (§5/§13) — see LICENSING.md.
|
|
6
|
-
'use strict';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Pure decisions behind a live streaming turn: whether the transcript is
|
|
10
|
-
* allowed to follow the stream, and whether a rejection means the user asked
|
|
11
|
-
* to stop. Kept out of app.js — like budget.js — so both rules are
|
|
12
|
-
* unit-testable from plain Node without a DOM or window.aegis. app.js only
|
|
13
|
-
* calls into this.
|
|
14
|
-
*
|
|
15
|
-
* Both rules exist because a running turn used to own the transcript: it
|
|
16
|
-
* re-pinned the view on every chunk (so earlier turns could not be read) and
|
|
17
|
-
* it reported a deliberate stop as a failure (so interrupting lost the answer
|
|
18
|
-
* already on screen).
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
/** Distance from the bottom still counted as "following the stream". */
|
|
22
|
-
const STICK_SLOP_PX = 48;
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Is the view at (or effectively at) the tail? A few pixels of slack absorbs
|
|
26
|
-
* sub-pixel layout and a scrollbar that appears mid-stream; without it the
|
|
27
|
-
* follow silently stops a fraction short.
|
|
28
|
-
*/
|
|
29
|
-
function nearBottom(metrics, slop) {
|
|
30
|
-
const m = metrics || {};
|
|
31
|
-
const budget = Number.isFinite(slop) ? slop : STICK_SLOP_PX;
|
|
32
|
-
const scrollHeight = Number(m.scrollHeight) || 0;
|
|
33
|
-
const scrollTop = Number(m.scrollTop) || 0;
|
|
34
|
-
const clientHeight = Number(m.clientHeight) || 0;
|
|
35
|
-
return scrollHeight - scrollTop - clientHeight <= budget;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* May the transcript be scrolled to the bottom right now? `force` is for the
|
|
40
|
-
* cases where the view genuinely must move (the user just sent, or a card is
|
|
41
|
-
* blocking the turn); otherwise the reader's explicit scroll-away wins.
|
|
42
|
-
*
|
|
43
|
-
* The flag is checked before the measurement on purpose: a mid-stream reflow
|
|
44
|
-
* can momentarily measure as "at the bottom" while the reader is nowhere near
|
|
45
|
-
* it, which is exactly the case where re-pinning feels like a hijack.
|
|
46
|
-
*/
|
|
47
|
-
function shouldFollow(metrics, opts) {
|
|
48
|
-
const o = opts || {};
|
|
49
|
-
if (o.force) return true;
|
|
50
|
-
if (o.userScrolledUp) return false;
|
|
51
|
-
return nearBottom(metrics, o.slop);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Signatures of a real abort, as each layer spells it: Chromium's fetch
|
|
56
|
-
* ("The user aborted a request."), undici/Node ("This operation was aborted"),
|
|
57
|
-
* and an AbortSignal's own reason ("signal is aborted without reason").
|
|
58
|
-
*
|
|
59
|
-
* Deliberately NOT a bare /abort/i. A dropped socket surfaces as
|
|
60
|
-
* "ECONNABORTED: connection aborted by peer" or "socket hang up", which
|
|
61
|
-
* contains the same word but is a genuine transport failure — matching it
|
|
62
|
-
* would relabel a real error as a deliberate stop and strand the user with a
|
|
63
|
-
* silently truncated answer.
|
|
64
|
-
*/
|
|
65
|
-
const ABORT_SIGNATURES = [
|
|
66
|
-
/AbortError/,
|
|
67
|
-
/\boperation was aborted\b/i,
|
|
68
|
-
/\buser aborted\b/i,
|
|
69
|
-
/\brequest\s+aborted\b/i,
|
|
70
|
-
/\bsignal is aborted\b/i,
|
|
71
|
-
/\baborted without reason\b/i,
|
|
72
|
-
];
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Did the user ask for this turn to stop? The abort travels back over IPC,
|
|
76
|
-
* which rebuilds the Error object and drops `name`/`code` — the renderer only
|
|
77
|
-
* sees a wrapped message. So the caller's own flag is the primary signal, and
|
|
78
|
-
* the signatures above are a backstop for an abort this renderer did not
|
|
79
|
-
* initiate.
|
|
80
|
-
*/
|
|
81
|
-
function isCancellation(err, opts) {
|
|
82
|
-
const o = opts || {};
|
|
83
|
-
if (o.userStopped) return true;
|
|
84
|
-
if (!err) return false;
|
|
85
|
-
if (err.name === 'AbortError' || err.code === 'ABORT_ERR') return true;
|
|
86
|
-
const msg = typeof err === 'string' ? err : err.message;
|
|
87
|
-
if (typeof msg !== 'string') return false;
|
|
88
|
-
return ABORT_SIGNATURES.some((re) => re.test(msg));
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Should a stop request reach the turn running right now?
|
|
93
|
-
*
|
|
94
|
-
* `runningTurn` is the token of the turn in progress (null when idle) and
|
|
95
|
-
* `stoppedTurn` is the token already asked to stop. The abort is not
|
|
96
|
-
* instantaneous — the transport has to unwind before `send()`'s catch runs — so
|
|
97
|
-
* a second press lands on a turn that is still nominally running. That second
|
|
98
|
-
* press is the *same* intent and must not re-enter.
|
|
99
|
-
*
|
|
100
|
-
* The two writers keep the pair consistent: app.js only ever assigns
|
|
101
|
-
* `stoppedTurn = runningTurn`, and claiming a new turn sets `stoppedTurn = null`
|
|
102
|
-
* alongside `runningTurn = myTurn`. So `stoppedTurn` is never some *other*
|
|
103
|
-
* turn's number, and `runningTurn !== stoppedTurn` means exactly "not already
|
|
104
|
-
* stopping". What keeps a finished turn's stop off its successor is that
|
|
105
|
-
* invariant plus the catch's `stoppedTurn === myTurn` — not a refusal here. A
|
|
106
|
-
* press on a live successor is legitimate and does reach it.
|
|
107
|
-
*/
|
|
108
|
-
function stopAppliesTo(runningTurn, stoppedTurn) {
|
|
109
|
-
if (runningTurn == null) return false;
|
|
110
|
-
return runningTurn !== stoppedTurn;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* What a cancelled turn leaves on screen, and what it honestly is.
|
|
115
|
-
*
|
|
116
|
-
* `streamedText || reasoningText` collapsed two different outcomes into one
|
|
117
|
-
* string: a turn cut off after real answer text, and a turn cut off with
|
|
118
|
-
* deliberation but no answer. The second is not a failure and the user should
|
|
119
|
-
* keep it, but folding it in as if it were the answer loses the distinction —
|
|
120
|
-
* so the case is named here and the caller labels it. With neither, the
|
|
121
|
-
* placeholder is still returned rather than an empty bubble.
|
|
122
|
-
*/
|
|
123
|
-
function salvageTurn(input) {
|
|
124
|
-
const i = input || {};
|
|
125
|
-
const streamed = typeof i.streamedText === 'string' ? i.streamedText : '';
|
|
126
|
-
const reasoning = typeof i.reasoningText === 'string' ? i.reasoningText : '';
|
|
127
|
-
if (streamed) return { text: streamed, reasoning: reasoning, kind: 'answer' };
|
|
128
|
-
if (reasoning) return { text: reasoning, reasoning: '', kind: 'reasoning-only' };
|
|
129
|
-
return { text: '(stopped before any output)', reasoning: '', kind: 'empty' };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* What a salvaged turn may contribute to the history that gets replayed —
|
|
134
|
-
* as opposed to what it shows on screen.
|
|
135
|
-
*
|
|
136
|
-
* These were the same string, and on a cancelled turn that was a loop.
|
|
137
|
-
* `salvageTurn` promotes the deliberation to `text` so the bubble is not
|
|
138
|
-
* empty, which is right for the screen; but that same string was pushed into
|
|
139
|
-
* `threadMessages` and written through `sync.append`, so the next turn fed the
|
|
140
|
-
* model its own truncated chain-of-thought back as its prior reply. A model
|
|
141
|
-
* handed an unfinished sentence finishes it — the same words every time,
|
|
142
|
-
* because the same incomplete prefix went back every time. It survived a
|
|
143
|
-
* reopen, and `openSession` rebuilds `threadMessages` from the stored rows, so
|
|
144
|
-
* the loop outlived the session that started it.
|
|
145
|
-
*
|
|
146
|
-
* So the screen keeps the deliberation (labelled `reasoning only`) and the
|
|
147
|
-
* history gets an annotation instead. The annotation is deliberate rather than
|
|
148
|
-
* a blank: a stopped turn is a real exchange and the row still carries the
|
|
149
|
-
* ledger fields the rolling meter folds — only the prose is replaced, so
|
|
150
|
-
* billing and the token total are unchanged.
|
|
151
|
-
*/
|
|
152
|
-
function replayableContent(kind, text) {
|
|
153
|
-
if (kind === 'reasoning-only') return '(stopped: reasoning only)';
|
|
154
|
-
if (kind === 'empty') return '(stopped before any output)';
|
|
155
|
-
return text;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* The history a turn is actually replayed with: the same rows, minus anything
|
|
160
|
-
* that is not the model's own prose, with runs of one role folded together.
|
|
161
|
-
*
|
|
162
|
-
* Both halves are needed at once, and each one alone breaks the other.
|
|
163
|
-
* Keeping the annotations replayed a label as if the model had said it — the
|
|
164
|
-
* loop `replayableContent` exists to stop. Dropping them left two `user` rows
|
|
165
|
-
* adjacent whenever the turn between them contributed nothing (a cancel, an
|
|
166
|
-
* empty response), and strict providers reject a non-alternating thread
|
|
167
|
-
* outright: the fix for the loop would have turned the next turn into a 400 on
|
|
168
|
-
* exactly the messages that needed it most. So the run is folded instead —
|
|
169
|
-
* nothing that is not prose reaches the wire, and the roles still alternate.
|
|
170
|
-
*
|
|
171
|
-
* A runaway of empty rows disappears for the same reason: a message with no
|
|
172
|
-
* content is not a message, and the provider would reject the empty block.
|
|
173
|
-
* A leading assistant row goes too — there is no user request for it to
|
|
174
|
-
* answer, so replaying it as the start of the conversation is a thread that
|
|
175
|
-
* begins with the model talking to itself. The current prompt travels
|
|
176
|
-
* separately (`prompt`, appended after `messages`), so nothing of the user's
|
|
177
|
-
* is lost by either drop.
|
|
178
|
-
*
|
|
179
|
-
* Display and the ledger are untouched by design: openSession draws the
|
|
180
|
-
* bubbles from `msgs` and folds the meter with `rollMessages(msgs)`, both
|
|
181
|
-
* before this is called, so only what goes back to the model changes.
|
|
182
|
-
*/
|
|
183
|
-
function replayHistory(messages) {
|
|
184
|
-
const out = [];
|
|
185
|
-
for (const m of Array.isArray(messages) ? messages : []) {
|
|
186
|
-
if (!m) continue;
|
|
187
|
-
// Same rows openSession always replayed: user and assistant. A `system`
|
|
188
|
-
// row was never part of the renderer's thread and is not promoted to one
|
|
189
|
-
// here.
|
|
190
|
-
if (m.role !== 'user' && m.role !== 'assistant') continue;
|
|
191
|
-
const content = typeof m.content === 'string' ? m.content
|
|
192
|
-
: typeof m.text === 'string' ? m.text : '';
|
|
193
|
-
if (!content.trim()) continue;
|
|
194
|
-
if (m.role === 'assistant' && isPlaceholderContent(content)) continue;
|
|
195
|
-
const last = out[out.length - 1];
|
|
196
|
-
if (last && last.role === m.role) last.content = `${last.content}\n\n${content}`;
|
|
197
|
-
else out.push({ role: m.role, content });
|
|
198
|
-
}
|
|
199
|
-
while (out.length && out[0].role === 'assistant') out.shift();
|
|
200
|
-
return out;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** True for the annotations above — rows that must never be replayed as prose. */
|
|
204
|
-
function isPlaceholderContent(content) {
|
|
205
|
-
const t = typeof content === 'string' ? content.trim() : '';
|
|
206
|
-
if (!t) return false;
|
|
207
|
-
return (
|
|
208
|
-
t === '(stopped: reasoning only)' ||
|
|
209
|
-
t === '(stopped before any output)' ||
|
|
210
|
-
t === '(empty response)' ||
|
|
211
|
-
t === '(no response)'
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** The smallest stream growth, in characters, that earns a live-meter recompute.
|
|
216
|
-
*
|
|
217
|
-
* It was 256, and that number was the start-of-turn lag this rule exists to
|
|
218
|
-
* avoid: the counter's first paint required a quarter kilobyte of text — ~64
|
|
219
|
-
* tokens, several seconds into a slow turn — so the bubble opened with no
|
|
220
|
-
* figure at all and then jumped to it, while the CLI's own tail
|
|
221
|
-
* (`cli/src/chatflow.js`'s spinner, `↓${fmtTokens(state.streamed)}`) and the
|
|
222
|
-
* reference engine both have a count climbing from the first frame. 32 chars
|
|
223
|
-
* (~8 tokens) is a step the eye reads as continuous at the start of a reply,
|
|
224
|
-
* which is the whole point of a live count.
|
|
225
|
-
*
|
|
226
|
-
* The cost bound is NOT this number — it is the proportional term below. At
|
|
227
|
-
* 100k chars the step is 1/32 of the stream (3125), so lowering this floor
|
|
228
|
-
* leaves the long-reply recompute count unchanged; it only buys paints in the
|
|
229
|
-
* first ~1k characters, where each estimate is over a string too short to
|
|
230
|
-
* matter. */
|
|
231
|
-
const LIVE_METER_MIN_GROWTH = 32;
|
|
232
|
-
|
|
233
|
-
/** …and the fraction of the whole stream that must have arrived since the last
|
|
234
|
-
* one: a recompute comes due once the stream has grown by 1/this. */
|
|
235
|
-
const LIVE_METER_GROWTH_FRACTION = 32;
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Whether the live token meter is due to be recomputed, given how much of the
|
|
239
|
-
* stream it last measured and how much of the stream has arrived now.
|
|
240
|
-
*
|
|
241
|
-
* The meter is paint-driven — `renderRollMeter` is called from inside the rAF
|
|
242
|
-
* painter — and each recompute estimates the WHOLE accumulated reply plus
|
|
243
|
-
* reasoning trace. `estimateTokens` counts code points by spreading the string
|
|
244
|
-
* into an array (`[...String(text)].length`), so one estimate is linear in the
|
|
245
|
-
* stream AND allocates a character-per-entry array. Per frame that is linear
|
|
246
|
-
* per frame, i.e. quadratic over the turn, and it was measured on the thread
|
|
247
|
-
* the user is trying to scroll: 0.37ms a frame on a 20k-char reply (2.2% of a
|
|
248
|
-
* 60fps budget), 1.5ms at 100k (9%), 7.7ms at 400k (46%).
|
|
249
|
-
*
|
|
250
|
-
* The step is proportional rather than a fixed interval so the lag is bounded
|
|
251
|
-
* at both ends. A short reply still updates every `LIVE_METER_MIN_GROWTH`
|
|
252
|
-
* characters, so the number visibly moves; a long one updates once per 1/32 of
|
|
253
|
-
* itself, which at the CLI's 4-chars-per-token rate means the figure can sit at
|
|
254
|
-
* most ~3% below the truth before its next step — invisible on a token counter.
|
|
255
|
-
*
|
|
256
|
-
* Nothing about the settled number depends on this cadence: the turn's
|
|
257
|
-
* committed value comes from `foldRoll`'s single estimate over the finished
|
|
258
|
-
* text, which every exit path writes (success, stop, and error), and a reopened
|
|
259
|
-
* thread is rebuilt by `rollMessages`. This only decides how often the preview
|
|
260
|
-
* is allowed to cost anything.
|
|
261
|
-
*/
|
|
262
|
-
function liveMeterDue(measured, streamChars) {
|
|
263
|
-
const now = Number(streamChars) || 0;
|
|
264
|
-
// Nothing has been painted yet this turn. The cursor is per-turn and starts
|
|
265
|
-
// UNSET (see app.js `let meterChars = null`), and an unset cursor must not be
|
|
266
|
-
// read as "measured an empty stream" — that made the first paint wait out the
|
|
267
|
-
// whole step below, which is the start-of-turn dead zone. The first text to
|
|
268
|
-
// arrive paints, so the figure is on screen from the first chunk rather than
|
|
269
|
-
// from the first 32nd of the reply.
|
|
270
|
-
if (measured === null || measured === undefined) return now > 0;
|
|
271
|
-
const grown = now - (Number(measured) || 0);
|
|
272
|
-
if (grown <= 0) return false;
|
|
273
|
-
const step = Math.max(LIVE_METER_MIN_GROWTH, Math.floor(now / LIVE_METER_GROWTH_FRACTION));
|
|
274
|
-
return grown >= step;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* The status glyph for one tool-activity line.
|
|
279
|
-
*
|
|
280
|
-
* Only an explicit `true` is a ✓. A call with no `ok` at all is a call whose
|
|
281
|
-
* result never arrived — the tool-call-mid-abort case — and drawing it as ✓
|
|
282
|
-
* claimed success for work the turn never finished. It gets its own mark
|
|
283
|
-
* instead of ✗, because interrupted is not the same as failed.
|
|
284
|
-
*/
|
|
285
|
-
function toolMark(tool) {
|
|
286
|
-
const t = tool || {};
|
|
287
|
-
if (t.ok === true) return '✓';
|
|
288
|
-
if (t.ok === false) return '✗';
|
|
289
|
-
return '⊘';
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
if (typeof module !== 'undefined' && module.exports) {
|
|
293
|
-
module.exports = {
|
|
294
|
-
nearBottom,
|
|
295
|
-
shouldFollow,
|
|
296
|
-
isCancellation,
|
|
297
|
-
stopAppliesTo,
|
|
298
|
-
salvageTurn,
|
|
299
|
-
replayableContent,
|
|
300
|
-
isPlaceholderContent,
|
|
301
|
-
replayHistory,
|
|
302
|
-
liveMeterDue,
|
|
303
|
-
toolMark,
|
|
304
|
-
STICK_SLOP_PX,
|
|
305
|
-
LIVE_METER_MIN_GROWTH,
|
|
306
|
-
LIVE_METER_GROWTH_FRACTION,
|
|
307
|
-
};
|
|
308
|
-
}
|
|
3
|
+
"use strict";const STICK_SLOP_PX=48;function nearBottom(e,t){const o=e||{},n=Number.isFinite(t)?t:STICK_SLOP_PX;return(Number(o.scrollHeight)||0)-(Number(o.scrollTop)||0)-(Number(o.clientHeight)||0)<=n}function shouldFollow(e,t){const o=t||{};return!!o.force||!o.userScrolledUp&&nearBottom(e,o.slop)}const ABORT_SIGNATURES=[/AbortError/,/\boperation was aborted\b/i,/\buser aborted\b/i,/\brequest\s+aborted\b/i,/\bsignal is aborted\b/i,/\baborted without reason\b/i];function isCancellation(e,t){if((t||{}).userStopped)return!0;if(!e)return!1;if("AbortError"===e.name||"ABORT_ERR"===e.code)return!0;const o="string"==typeof e?e:e.message;return"string"==typeof o&&ABORT_SIGNATURES.some((e=>e.test(o)))}function stopAppliesTo(e,t){return null!=e&&e!==t}function salvageTurn(e){const t=e||{},o="string"==typeof t.streamedText?t.streamedText:"",n="string"==typeof t.reasoningText?t.reasoningText:"";return o?{text:o,reasoning:n,kind:"answer"}:n?{text:n,reasoning:"",kind:"reasoning-only"}:{text:"(stopped before any output)",reasoning:"",kind:"empty"}}function replayableContent(e,t){return"reasoning-only"===e?"(stopped: reasoning only)":"empty"===e?"(stopped before any output)":t}function replayHistory(e){const t=[];for(const o of Array.isArray(e)?e:[]){if(!o)continue;if("user"!==o.role&&"assistant"!==o.role)continue;const e="string"==typeof o.content?o.content:"string"==typeof o.text?o.text:"";if(!e.trim())continue;if("assistant"===o.role&&isPlaceholderContent(e))continue;const n=t[t.length-1];n&&n.role===o.role?n.content=`${n.content}\n\n${e}`:t.push({role:o.role,content:e})}for(;t.length&&"assistant"===t[0].role;)t.shift();return t}function isPlaceholderContent(e){const t="string"==typeof e?e.trim():"";return!!t&&("(stopped: reasoning only)"===t||"(stopped before any output)"===t||"(empty response)"===t||"(no response)"===t)}const LIVE_METER_MIN_GROWTH=32,LIVE_METER_GROWTH_FRACTION=32;function liveMeterDue(e,t){const o=Number(t)||0;if(null==e)return o>0;const n=o-(Number(e)||0);return!(n<=0)&&n>=Math.max(32,Math.floor(o/32))}function toolMark(e){const t=e||{};return!0===t.ok?"✓":!1===t.ok?"✗":"⊘"}"undefined"!=typeof module&&module.exports&&(module.exports={nearBottom:nearBottom,shouldFollow:shouldFollow,isCancellation:isCancellation,stopAppliesTo:stopAppliesTo,salvageTurn:salvageTurn,replayableContent:replayableContent,isPlaceholderContent:isPlaceholderContent,replayHistory:replayHistory,liveMeterDue:liveMeterDue,toolMark:toolMark,STICK_SLOP_PX:STICK_SLOP_PX,LIVE_METER_MIN_GROWTH:32,LIVE_METER_GROWTH_FRACTION:32});
|