aegis-desktop 0.8.14 → 0.8.16
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/local/local.js +54 -2
- package/main.js +42 -10
- package/package.json +1 -1
- package/renderer/app.js +107 -13
- package/renderer/transcript-view.js +70 -5
- package/vendor/aegis.js +118 -6
package/lib/local/local.js
CHANGED
|
@@ -58,6 +58,45 @@ function baseOf(baseURL) {
|
|
|
58
58
|
return String(baseURL || DEFAULT_BASE).replace(/\/+$/, '');
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* DeepSeek's chat template (also the one a locally-pulled `deepseek-*` Ollama
|
|
63
|
+
* model uses) wraps tool calls and turn boundaries in special tokens —
|
|
64
|
+
* fullwidth pipe (U+FF5C) delimiting an identifier that uses ▁ (U+2581) as its
|
|
65
|
+
* word separator, e.g. |tool▁calls▁begin|, |tool▁call▁end|, |Assistant|,
|
|
66
|
+
* |begin▁of▁sentence|. These are chat-template internals, never user-facing
|
|
67
|
+
* text; when the local daemon's tool-call path leaks them into `delta.content`
|
|
68
|
+
* they used to reach the screen verbatim — reported as the pasted
|
|
69
|
+
* "||DSML||" envelope. Returns a per-turn stripper closure with a short
|
|
70
|
+
* lookback buffer so a token split across two SSE chunks isn't half-rendered
|
|
71
|
+
* before its closing pipe arrives.
|
|
72
|
+
*/
|
|
73
|
+
const DEEPSEEK_ENVELOPE_RE = /|[A-Za-z][A-Za-z0-9]*(?:▁[A-Za-z0-9]+)*|/g;
|
|
74
|
+
function makeDeepSeekEnvelopeStripper() {
|
|
75
|
+
let buf = '';
|
|
76
|
+
const strip = (chunk) => {
|
|
77
|
+
buf += chunk;
|
|
78
|
+
buf = buf.replace(DEEPSEEK_ENVELOPE_RE, '');
|
|
79
|
+
const openIdx = buf.lastIndexOf('|');
|
|
80
|
+
if (openIdx !== -1) {
|
|
81
|
+
const tail = buf.slice(openIdx);
|
|
82
|
+
if (tail.length <= 60 && /^|[A-Za-z0-9▁]*$/.test(tail)) {
|
|
83
|
+
const out = buf.slice(0, openIdx);
|
|
84
|
+
buf = tail;
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const out = buf;
|
|
89
|
+
buf = '';
|
|
90
|
+
return out;
|
|
91
|
+
};
|
|
92
|
+
strip.flush = () => {
|
|
93
|
+
const out = buf;
|
|
94
|
+
buf = '';
|
|
95
|
+
return out;
|
|
96
|
+
};
|
|
97
|
+
return strip;
|
|
98
|
+
}
|
|
99
|
+
|
|
61
100
|
/**
|
|
62
101
|
* Whether a base URL addresses hardware on this machine (or on this private
|
|
63
102
|
* network) — the gate that decides whether the free lane exists at all.
|
|
@@ -369,6 +408,7 @@ async function chat({
|
|
|
369
408
|
let usage = null;
|
|
370
409
|
const toolCalls = [];
|
|
371
410
|
const byIndex = new Map();
|
|
411
|
+
const stripEnvelope = makeDeepSeekEnvelopeStripper();
|
|
372
412
|
|
|
373
413
|
await readSSE(
|
|
374
414
|
res,
|
|
@@ -378,8 +418,11 @@ async function chat({
|
|
|
378
418
|
if (!choice) return;
|
|
379
419
|
const delta = choice.delta || {};
|
|
380
420
|
if (typeof delta.content === 'string' && delta.content) {
|
|
381
|
-
|
|
382
|
-
if (
|
|
421
|
+
const clean = stripEnvelope(delta.content);
|
|
422
|
+
if (clean) {
|
|
423
|
+
content += clean;
|
|
424
|
+
if (typeof onDelta === 'function') onDelta({ text: clean });
|
|
425
|
+
}
|
|
383
426
|
}
|
|
384
427
|
const frags = delta.tool_calls || [];
|
|
385
428
|
for (const f of frags) {
|
|
@@ -399,6 +442,15 @@ async function chat({
|
|
|
399
442
|
{ idleTimeoutMs: SSE_IDLE_TIMEOUT_MS }
|
|
400
443
|
);
|
|
401
444
|
|
|
445
|
+
// A trailing '|…' that never closed (stream ended right after it, or it
|
|
446
|
+
// was real prose all along) is real content, not an unfinished envelope
|
|
447
|
+
// token — release it rather than dropping it silently.
|
|
448
|
+
const strayTail = stripEnvelope.flush();
|
|
449
|
+
if (strayTail) {
|
|
450
|
+
content += strayTail;
|
|
451
|
+
if (typeof onDelta === 'function') onDelta({ text: strayTail });
|
|
452
|
+
}
|
|
453
|
+
|
|
402
454
|
for (const [i, acc] of [...byIndex.entries()].sort((a, b) => a[0] - b[0])) {
|
|
403
455
|
if (!acc.function.name && !acc.function.arguments) continue;
|
|
404
456
|
// Ollama does not always mint an id; the engine's tool loop keys results
|
package/main.js
CHANGED
|
@@ -682,11 +682,28 @@ function providerRouteConfigured(engine) {
|
|
|
682
682
|
* import to stay unit-testable.
|
|
683
683
|
*/
|
|
684
684
|
function createIpcDispatch(aegis, dir, persistApiKey, openExternal, providerConfigured, avatar) {
|
|
685
|
-
// One service for the window's lifetime, bound to the app's data dir
|
|
686
|
-
// created
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
|
|
685
|
+
// One service for the window's lifetime, bound to the app's data dir — but
|
|
686
|
+
// created LAZILY, which is what the comment here always claimed and what the
|
|
687
|
+
// code did not do. `createFingerprintService({ dir })` throws on a missing
|
|
688
|
+
// dir, and a test or a call site that has no userData yet (every
|
|
689
|
+
// `createIpcDispatch(client)` in `test/desktop-shell.mjs`, and any bootstrap
|
|
690
|
+
// where `resolveUserDataDir` came back empty) died at construction — taking
|
|
691
|
+
// the whole dispatch surface with it, not just the fingerprint panel. The
|
|
692
|
+
// proxy defers construction to the first fingerprint call, so a broken shared
|
|
693
|
+
// module surfaces as an error in that panel instead of preventing the window
|
|
694
|
+
// from opening at all, exactly as documented below.
|
|
695
|
+
let fingerprintService = null;
|
|
696
|
+
const fingerprint = new Proxy({}, {
|
|
697
|
+
get(_target, prop) {
|
|
698
|
+
if (!fingerprintService) fingerprintService = createFingerprintService({ dir });
|
|
699
|
+
const value = fingerprintService[prop];
|
|
700
|
+
return typeof value === 'function' ? value.bind(fingerprintService) : value;
|
|
701
|
+
},
|
|
702
|
+
has(_target, prop) {
|
|
703
|
+
if (!fingerprintService) fingerprintService = createFingerprintService({ dir });
|
|
704
|
+
return prop in fingerprintService;
|
|
705
|
+
},
|
|
706
|
+
});
|
|
690
707
|
// `providerConfigured` is an injected thunk (bootstrap() passes one that reads
|
|
691
708
|
// the settings store) so this module keeps its no-Electron, unit-testable
|
|
692
709
|
// shape; absent in tests and older call sites, where it reads as "no".
|
|
@@ -716,14 +733,29 @@ function createIpcDispatch(aegis, dir, persistApiKey, openExternal, providerConf
|
|
|
716
733
|
// In-app API key entry (plan rebuild): replace the live client key and
|
|
717
734
|
// persist it encrypted at rest via the settings store. The renderer only
|
|
718
735
|
// ever sees the masked preview back — never the raw key.
|
|
736
|
+
// Resolves `{ ok, ... }` / `{ ok: false, error }` rather than throwing —
|
|
737
|
+
// same reasoning as the fingerprint handlers below: an IPC rejection
|
|
738
|
+
// reaches the renderer as an opaque "Error invoking remote method" with
|
|
739
|
+
// the real cause buried in main-process logs, and key entry is the one
|
|
740
|
+
// flow every user hits on first run.
|
|
719
741
|
setApiKey: (payload) => {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
742
|
+
try {
|
|
743
|
+
const key = payload && payload.key;
|
|
744
|
+
aegis.setApiKey(key || '');
|
|
745
|
+
if (persistApiKey) persistApiKey(aegis.apiKey);
|
|
746
|
+
return { ok: true, keyConfigured: Boolean(aegis.apiKey), keyMask: maskKey(aegis.apiKey) };
|
|
747
|
+
} catch (e) {
|
|
748
|
+
return { ok: false, error: (e && e.message) || String(e) };
|
|
749
|
+
}
|
|
724
750
|
},
|
|
725
751
|
|
|
726
|
-
verifyApiKey: () =>
|
|
752
|
+
verifyApiKey: async () => {
|
|
753
|
+
try {
|
|
754
|
+
return { ok: true, ...(await aegis.verifyApiKey()) };
|
|
755
|
+
} catch (e) {
|
|
756
|
+
return { ok: false, error: (e && e.message) || String(e) };
|
|
757
|
+
}
|
|
758
|
+
},
|
|
727
759
|
tokenBankBalance: () => aegis.tokenBankBalance(),
|
|
728
760
|
|
|
729
761
|
// Billing (plan upgrade + token-bank top-up). aegis1 creates a Stripe
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegis-desktop",
|
|
3
3
|
"productName": "AEGIS Desktop",
|
|
4
|
-
"version": "0.8.
|
|
4
|
+
"version": "0.8.16",
|
|
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",
|
package/renderer/app.js
CHANGED
|
@@ -664,6 +664,21 @@ function rafPainter(paint) {
|
|
|
664
664
|
return transcript ? transcript.paint(paint) : paint;
|
|
665
665
|
}
|
|
666
666
|
|
|
667
|
+
/**
|
|
668
|
+
* What the last stop attempt actually achieved.
|
|
669
|
+
*
|
|
670
|
+
* `models.cancel()` travels over IPC and can be REFUSED — the engine has no
|
|
671
|
+
* AbortController for that session id (`{ ok: false }`), the channel is not
|
|
672
|
+
* registered (the promise rejects), or the call throws. The old code wrapped
|
|
673
|
+
* this in a bare `catch` and ignored the returned promise, so every one of
|
|
674
|
+
* those became silence: the button said "stopping…", the turn kept streaming,
|
|
675
|
+
* and the only visible evidence was a salvage bubble that never appeared —
|
|
676
|
+
* which reads as a salvage bug several layers away from the real one. Recorded
|
|
677
|
+
* here so the smoke run can fail on it, and the rejection is handled so it
|
|
678
|
+
* cannot vanish into an unhandled-rejection warning nobody reads.
|
|
679
|
+
*/
|
|
680
|
+
let lastStopOutcome = null;
|
|
681
|
+
|
|
667
682
|
/**
|
|
668
683
|
* Stop the turn running right now. The transport already honours the abort all
|
|
669
684
|
* the way down (engine.cancel -> AbortController -> the cloud client's fetch),
|
|
@@ -682,10 +697,43 @@ function stopPendingTurn() {
|
|
|
682
697
|
// could be left `true` by an earlier turn and make an unrelated transport
|
|
683
698
|
// failure look like a stop the user asked for.
|
|
684
699
|
stoppedTurn = runningTurn;
|
|
700
|
+
const stopSessionId = pendingSessionId;
|
|
701
|
+
lastStopOutcome = { sessionId: stopSessionId, at: Date.now(), state: 'requested' };
|
|
685
702
|
try {
|
|
686
|
-
models.cancel(
|
|
687
|
-
|
|
688
|
-
|
|
703
|
+
const pending = models.cancel(stopSessionId);
|
|
704
|
+
if (pending && typeof pending.then === 'function') {
|
|
705
|
+
pending.then(
|
|
706
|
+
(r) => {
|
|
707
|
+
lastStopOutcome = {
|
|
708
|
+
sessionId: stopSessionId,
|
|
709
|
+
at: Date.now(),
|
|
710
|
+
state: 'settled',
|
|
711
|
+
// The engine reports `{ ok: Boolean(controller) }` — a false here
|
|
712
|
+
// means it had nothing to abort, i.e. the stop was refused. Not an
|
|
713
|
+
// error, but never a success either.
|
|
714
|
+
accepted: !r || r.ok !== false,
|
|
715
|
+
result: r === undefined ? null : r,
|
|
716
|
+
};
|
|
717
|
+
},
|
|
718
|
+
(e) => {
|
|
719
|
+
lastStopOutcome = {
|
|
720
|
+
sessionId: stopSessionId,
|
|
721
|
+
at: Date.now(),
|
|
722
|
+
state: 'rejected',
|
|
723
|
+
accepted: false,
|
|
724
|
+
error: (e && e.message) || String(e),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
} catch (e) {
|
|
730
|
+
lastStopOutcome = {
|
|
731
|
+
sessionId: stopSessionId,
|
|
732
|
+
at: Date.now(),
|
|
733
|
+
state: 'threw',
|
|
734
|
+
accepted: false,
|
|
735
|
+
error: (e && e.message) || String(e),
|
|
736
|
+
};
|
|
689
737
|
}
|
|
690
738
|
// The abort is not instantaneous. Marking the button is all the feedback that
|
|
691
739
|
// survives the trip: `setBusy(false)` deletes the entire pending bubble on
|
|
@@ -919,9 +967,11 @@ function renderUpdateBanner(state) {
|
|
|
919
967
|
async function loadAccountInfo() {
|
|
920
968
|
try {
|
|
921
969
|
const verify = await aegis.verifyApiKey();
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
970
|
+
if (!verify || !verify.ok) {
|
|
971
|
+
els.plan.textContent = 'unavailable';
|
|
972
|
+
} else {
|
|
973
|
+
els.plan.textContent = verify.valid ? (verify.plan || 'active') : 'invalid key';
|
|
974
|
+
}
|
|
925
975
|
} catch {
|
|
926
976
|
els.plan.textContent = 'unavailable';
|
|
927
977
|
}
|
|
@@ -1050,11 +1100,14 @@ async function saveApiKey() {
|
|
|
1050
1100
|
els.apiKeyHint.textContent = 'saving…';
|
|
1051
1101
|
try {
|
|
1052
1102
|
const res = await aegis.setApiKey(key);
|
|
1103
|
+
if (!res || !res.ok) {
|
|
1104
|
+
throw new Error((res && res.error) || 'save failed');
|
|
1105
|
+
}
|
|
1053
1106
|
// Never retain the raw key in the DOM once saved — show only the masked
|
|
1054
1107
|
// preview the main process returned.
|
|
1055
1108
|
els.apiKeyInput.value = '';
|
|
1056
1109
|
els.apiKeyHint.textContent = key
|
|
1057
|
-
? `saved (${res
|
|
1110
|
+
? `saved (${res.keyMask ? res.keyMask : 'configured'})`
|
|
1058
1111
|
: 'key cleared';
|
|
1059
1112
|
await refreshAfterKeyChange();
|
|
1060
1113
|
} catch (err) {
|
|
@@ -1071,7 +1124,10 @@ async function verifyAegisKey() {
|
|
|
1071
1124
|
els.apiKeyHint.textContent = 'verifying…';
|
|
1072
1125
|
try {
|
|
1073
1126
|
const verify = await aegis.verifyApiKey();
|
|
1074
|
-
|
|
1127
|
+
if (!verify || !verify.ok) {
|
|
1128
|
+
throw new Error((verify && verify.error) || 'verify failed');
|
|
1129
|
+
}
|
|
1130
|
+
els.apiKeyHint.textContent = verify.valid
|
|
1075
1131
|
? `valid (${verify.plan || 'active'})`
|
|
1076
1132
|
: 'invalid key';
|
|
1077
1133
|
} catch (err) {
|
|
@@ -1772,7 +1828,27 @@ const memoryView = {
|
|
|
1772
1828
|
* instead. Confusing the two is how a save would read a hidden textarea. */
|
|
1773
1829
|
function overlayOpen() {
|
|
1774
1830
|
if (memoryOverlayOpen()) return true;
|
|
1775
|
-
|
|
1831
|
+
// Ask the PANEL, not the module.
|
|
1832
|
+
//
|
|
1833
|
+
// `window.aegisFingerprint` is desktop/renderer/fingerprint.js, whose exports
|
|
1834
|
+
// are `mount` plus pure helpers — it has no `isOpen`. The thing that knows
|
|
1835
|
+
// whether the sheet is up is the handle `mount()` returned, kept in `fpPanel`.
|
|
1836
|
+
// Reaching for `window.aegisFingerprint.isOpen()` threw inside the Escape
|
|
1837
|
+
// listener, and a listener that throws is reported as an uncaught error
|
|
1838
|
+
// rather than propagated to the dispatcher — so `defaultPrevented` stayed
|
|
1839
|
+
// false, Escape stopped interrupting turns, and the visible symptom was a
|
|
1840
|
+
// salvage failure several layers away. A source-shape change must not be able
|
|
1841
|
+
// to do that again: an unusable handle reads as "not open" here, so a broken
|
|
1842
|
+
// φ(α) panel can never disarm the keyboard interrupt (the smoke run fails
|
|
1843
|
+
// loudly instead, on the recorded reason).
|
|
1844
|
+
if (fpPanel && typeof fpPanel.isOpen === 'function') {
|
|
1845
|
+
try {
|
|
1846
|
+
return !!fpPanel.isOpen();
|
|
1847
|
+
} catch (e) {
|
|
1848
|
+
return false;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
return false;
|
|
1776
1852
|
}
|
|
1777
1853
|
|
|
1778
1854
|
/** The memory inspector alone. Split out of overlayOpen() when the authorship
|
|
@@ -4483,11 +4559,20 @@ async function init() {
|
|
|
4483
4559
|
// which made its veto assertion unfalsifiable. Exposes state only: no
|
|
4484
4560
|
// setters, nothing that can drive the UI. Frozen so a stray write in a test
|
|
4485
4561
|
// cannot fake a passing run.
|
|
4562
|
+
// Bound below, read by the diagnostic just above. `defaultPrevented === false`
|
|
4563
|
+
// on its own cannot say whether Escape was declined by policy or never
|
|
4564
|
+
// handled at all, and that ambiguity is what makes an unwired interrupt look
|
|
4565
|
+
// like a renderer regression.
|
|
4566
|
+
let escapeBind = null;
|
|
4486
4567
|
window.__aegisSmoke = Object.freeze({
|
|
4487
4568
|
isScrolledUp: () => transcript.isScrolledUp(),
|
|
4488
4569
|
metrics: () => transcript.metrics(),
|
|
4570
|
+
escapeDecision: () => (escapeBind && typeof escapeBind.lastDecision === 'function'
|
|
4571
|
+
? escapeBind.lastDecision()
|
|
4572
|
+
: null),
|
|
4573
|
+
stopOutcome: () => lastStopOutcome,
|
|
4489
4574
|
});
|
|
4490
|
-
bindEscapeInterrupt({
|
|
4575
|
+
escapeBind = bindEscapeInterrupt({
|
|
4491
4576
|
doc: document,
|
|
4492
4577
|
// The memory overlay wins: while it is open, Escape closes it rather than
|
|
4493
4578
|
// reaching past it to cancel a turn the user may not be looking at.
|
|
@@ -4499,9 +4584,18 @@ async function init() {
|
|
|
4499
4584
|
// both are somehow up.
|
|
4500
4585
|
isOverlayOpen: overlayOpen,
|
|
4501
4586
|
onOverlayEscape: () => {
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
4587
|
+
// Same handle, same reason as overlayOpen(): the module has no `close`,
|
|
4588
|
+
// the mounted panel does. Guessed at the module here too, which threw for
|
|
4589
|
+
// the same reason — and an overlay can only be closed by its own sheet's
|
|
4590
|
+
// handle, so there is nothing to fall back to but the memory overlay.
|
|
4591
|
+
if (fpPanel && typeof fpPanel.close === 'function') {
|
|
4592
|
+
try {
|
|
4593
|
+
fpPanel.close();
|
|
4594
|
+
return;
|
|
4595
|
+
} catch (e) {
|
|
4596
|
+
// fall through to the memory overlay rather than rethrowing inside a
|
|
4597
|
+
// keydown listener
|
|
4598
|
+
}
|
|
4505
4599
|
}
|
|
4506
4600
|
closeMemoryOverlay();
|
|
4507
4601
|
},
|
|
@@ -218,21 +218,85 @@ function attachScrollLift(deps) {
|
|
|
218
218
|
function bindEscapeInterrupt(deps) {
|
|
219
219
|
const d = deps || {};
|
|
220
220
|
|
|
221
|
+
// Why the last Escape did what it did.
|
|
222
|
+
//
|
|
223
|
+
// This exists because the only externally visible signal an ignored Escape
|
|
224
|
+
// leaves behind is `defaultPrevented === false`, and that single bit cannot
|
|
225
|
+
// tell four legitimate refusals (overlay open, no pending turn, no stopTurn,
|
|
226
|
+
// auto-repeat) apart from the one genuinely broken case: a dependency threw
|
|
227
|
+
// inside the listener, and `dispatchEvent` reported the listener error as
|
|
228
|
+
// uncaught instead of propagating it to the caller. Both look identical from
|
|
229
|
+
// outside — the turn keeps running and `defaultPrevented` is false — so a
|
|
230
|
+
// wiring fault is indistinguishable from a policy decision without this.
|
|
231
|
+
let lastDecision = null;
|
|
232
|
+
|
|
221
233
|
function handle(e) {
|
|
234
|
+
const decision = {
|
|
235
|
+
at: Date.now(),
|
|
236
|
+
key: e && e.key,
|
|
237
|
+
repeat: Boolean(e && e.repeat),
|
|
238
|
+
overlayOpen: null,
|
|
239
|
+
hasPendingTurn: null,
|
|
240
|
+
consumed: false,
|
|
241
|
+
threw: null,
|
|
242
|
+
};
|
|
243
|
+
lastDecision = decision;
|
|
244
|
+
|
|
222
245
|
if (!e || e.key !== 'Escape') return false;
|
|
223
246
|
// A held key is one intent, not one per repeat. Without this the auto-repeat
|
|
224
247
|
// events keep arriving after the interrupted turn has already ended, and
|
|
225
248
|
// the ones that land once the user has sent the next message stop that turn
|
|
226
249
|
// instead — the keyboard twin of a stuck cancel button.
|
|
227
250
|
if (e.repeat) return false;
|
|
228
|
-
if (typeof d.isOverlayOpen === 'function'
|
|
229
|
-
|
|
230
|
-
|
|
251
|
+
if (typeof d.isOverlayOpen === 'function') {
|
|
252
|
+
try {
|
|
253
|
+
decision.overlayOpen = Boolean(d.isOverlayOpen());
|
|
254
|
+
} catch (err) {
|
|
255
|
+
// A probe that breaks must not disarm the interrupt: the user's only
|
|
256
|
+
// keyboard route to stopping a runaway turn is this handler, and
|
|
257
|
+
// "unknown" is not "open". The reason is recorded rather than raised,
|
|
258
|
+
// so the smoke run fails on it while the user still gets their Escape.
|
|
259
|
+
decision.threw = 'isOverlayOpen: ' + ((err && err.message) || err);
|
|
260
|
+
decision.overlayOpen = false;
|
|
261
|
+
}
|
|
262
|
+
if (decision.overlayOpen) {
|
|
263
|
+
if (typeof d.onOverlayEscape === 'function') {
|
|
264
|
+
try {
|
|
265
|
+
d.onOverlayEscape();
|
|
266
|
+
} catch (err) {
|
|
267
|
+
decision.threw = 'onOverlayEscape: ' + ((err && err.message) || err);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (typeof d.hasPendingTurn === 'function') {
|
|
274
|
+
try {
|
|
275
|
+
decision.hasPendingTurn = Boolean(d.hasPendingTurn());
|
|
276
|
+
} catch (err) {
|
|
277
|
+
decision.threw = 'hasPendingTurn: ' + ((err && err.message) || err);
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
if (!decision.hasPendingTurn) return false;
|
|
231
281
|
}
|
|
232
|
-
if (typeof d.hasPendingTurn === 'function' && !d.hasPendingTurn()) return false;
|
|
233
282
|
if (typeof d.stopTurn !== 'function') return false;
|
|
234
283
|
if (typeof e.preventDefault === 'function') e.preventDefault();
|
|
235
|
-
|
|
284
|
+
decision.consumed = true;
|
|
285
|
+
// The key is consumed either way — the interrupt has been asked for and the
|
|
286
|
+
// turn must not survive a throw from the stop itself. What the stop
|
|
287
|
+
// ANSWERED is recorded, because a consumed key with a refused stop is the
|
|
288
|
+
// one combination that looks like success from outside while nothing has
|
|
289
|
+
// happened: `defaultPrevented` is true, the bubble never appears, and the
|
|
290
|
+
// turn runs to completion.
|
|
291
|
+
let stopResult = null;
|
|
292
|
+
try {
|
|
293
|
+
const r = d.stopTurn();
|
|
294
|
+
stopResult = r === undefined ? null : Boolean(r);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
stopResult = false;
|
|
297
|
+
decision.threw = 'stopTurn: ' + ((err && err.message) || err);
|
|
298
|
+
}
|
|
299
|
+
decision.stopAccepted = stopResult;
|
|
236
300
|
return true;
|
|
237
301
|
}
|
|
238
302
|
|
|
@@ -242,6 +306,7 @@ function bindEscapeInterrupt(deps) {
|
|
|
242
306
|
|
|
243
307
|
return {
|
|
244
308
|
handle: handle,
|
|
309
|
+
lastDecision: () => lastDecision,
|
|
245
310
|
unbind: () => {
|
|
246
311
|
if (d.doc && typeof d.doc.removeEventListener === 'function') {
|
|
247
312
|
d.doc.removeEventListener('keydown', handle);
|
package/vendor/aegis.js
CHANGED
|
@@ -65,6 +65,50 @@ function envIdleMs(name) {
|
|
|
65
65
|
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* DeepSeek's chat template wraps tool calls and turn boundaries in special
|
|
70
|
+
* tokens — fullwidth pipe (U+FF5C) delimiting an identifier that uses ▁
|
|
71
|
+
* (U+2581) as its word separator, e.g. |tool▁calls▁begin|, |tool▁call▁end|,
|
|
72
|
+
* |Assistant|, |begin▁of▁sentence|. These are chat-template internals, never
|
|
73
|
+
* user-facing text; when the provider's streaming tool-call path leaks them
|
|
74
|
+
* into `delta.content` they used to reach the screen verbatim — reported as
|
|
75
|
+
* the pasted "||DSML||" envelope on Windows/macOS. Returns a per-stream
|
|
76
|
+
* stripper closure with a short lookback buffer so a token split across two
|
|
77
|
+
* SSE chunks isn't half-rendered before its closing pipe arrives.
|
|
78
|
+
*/
|
|
79
|
+
const DEEPSEEK_ENVELOPE_RE = /|[A-Za-z][A-Za-z0-9]*(?:▁[A-Za-z0-9]+)*|/g;
|
|
80
|
+
function makeDeepSeekEnvelopeStripper() {
|
|
81
|
+
let buf = '';
|
|
82
|
+
const strip = (chunk) => {
|
|
83
|
+
buf += chunk;
|
|
84
|
+
buf = buf.replace(DEEPSEEK_ENVELOPE_RE, '');
|
|
85
|
+
const openIdx = buf.lastIndexOf('|');
|
|
86
|
+
if (openIdx !== -1) {
|
|
87
|
+
const tail = buf.slice(openIdx);
|
|
88
|
+
// Held back only while the tail still looks like an in-progress token
|
|
89
|
+
// (no closing pipe yet); a bare '|' or genuine prose flushes normally.
|
|
90
|
+
if (tail.length <= 60 && /^|[A-Za-z0-9▁]*$/.test(tail)) {
|
|
91
|
+
const out = buf.slice(0, openIdx);
|
|
92
|
+
buf = tail;
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const out = buf;
|
|
97
|
+
buf = '';
|
|
98
|
+
return out;
|
|
99
|
+
};
|
|
100
|
+
// Unconditional release of whatever is left buffered — called once the
|
|
101
|
+
// stream is fully drained, when a held-back '|…' is known to never close
|
|
102
|
+
// (dropped connection, or it was real prose all along) rather than an
|
|
103
|
+
// in-progress token.
|
|
104
|
+
strip.flush = () => {
|
|
105
|
+
const out = buf;
|
|
106
|
+
buf = '';
|
|
107
|
+
return out;
|
|
108
|
+
};
|
|
109
|
+
return strip;
|
|
110
|
+
}
|
|
111
|
+
|
|
68
112
|
/**
|
|
69
113
|
* UUID v4 that works everywhere: Web Crypto first (browsers, Node ≥ 19),
|
|
70
114
|
* then Node's CJS crypto module (Node < 19), then a Math.random fallback for
|
|
@@ -221,10 +265,26 @@ function createClient(opts = {}) {
|
|
|
221
265
|
}
|
|
222
266
|
throw err;
|
|
223
267
|
} finally {
|
|
268
|
+
// The timeout is time-to-*headers* only, so it is cleared the moment the
|
|
269
|
+
// response resolves: a long generation must never be cut off by it.
|
|
224
270
|
if (timer) clearTimeout(timer);
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
271
|
+
// …but the caller's abort bridge is deliberately NOT removed here.
|
|
272
|
+
//
|
|
273
|
+
// `fetch` resolves at the status line, while the RESPONSE BODY is still
|
|
274
|
+
// bound to `controller.signal` and keeps being read long after this
|
|
275
|
+
// function has returned. Unhooking the caller's signal at that point
|
|
276
|
+
// detached Stop/Escape from the stream it was supposed to stop: the
|
|
277
|
+
// engine's own controller really did abort — `engine.cancel()` answered
|
|
278
|
+
// `{ ok: true }`, so the right controller was found and `abort()` was
|
|
279
|
+
// really called — and nothing happened, because the only thing wired to
|
|
280
|
+
// the live body had already been told to stop listening. The turn then
|
|
281
|
+
// streamed to completion while the UI sat on "stopping…", never settled,
|
|
282
|
+
// and never produced the salvaged bubble, because no error ever reached
|
|
283
|
+
// the caller's catch.
|
|
284
|
+
//
|
|
285
|
+
// `once: true` plus one signal per turn bounds the cost of leaving it
|
|
286
|
+
// attached to a single listener per turn — a far smaller price than a
|
|
287
|
+
// cancel button that does nothing.
|
|
228
288
|
}
|
|
229
289
|
}
|
|
230
290
|
|
|
@@ -838,6 +898,15 @@ function createClient(opts = {}) {
|
|
|
838
898
|
// resolve with text only and the caller could never see the calls.
|
|
839
899
|
const toolCallsByIndex = new Map();
|
|
840
900
|
let finishReason = null;
|
|
901
|
+
// `fullText` above stays RAW (it is also the `seen` cursor the snapshot
|
|
902
|
+
// reconciliation below compares against the provider's own un-stripped
|
|
903
|
+
// snapshots); `cleanText` is what the caller actually receives — the same
|
|
904
|
+
// stream, with DeepSeek's chat-template envelope removed — and is what the
|
|
905
|
+
// final `message.content` below is built from, so a non-streaming caller
|
|
906
|
+
// reading the resolved result sees the same clean text a streaming caller
|
|
907
|
+
// already saw via `onStream`.
|
|
908
|
+
let cleanText = '';
|
|
909
|
+
const stripEnvelope = makeDeepSeekEnvelopeStripper();
|
|
841
910
|
|
|
842
911
|
// Idle watchdog: if the server holds the connection open without ever
|
|
843
912
|
// sending another byte (a stuck upstream call, a proxy that swallows the
|
|
@@ -863,6 +932,7 @@ function createClient(opts = {}) {
|
|
|
863
932
|
let keepAlives = 0;
|
|
864
933
|
async function readWithIdleTimeout() {
|
|
865
934
|
let timer;
|
|
935
|
+
let onAbort = null;
|
|
866
936
|
const remaining = Math.max(0, idleMs - (Date.now() - lastPayloadAt));
|
|
867
937
|
const timeout = new Promise((_, reject) => {
|
|
868
938
|
timer = setTimeout(() => {
|
|
@@ -873,10 +943,37 @@ function createClient(opts = {}) {
|
|
|
873
943
|
));
|
|
874
944
|
}, remaining);
|
|
875
945
|
});
|
|
946
|
+
// The caller's abort, raced against the read directly rather than trusted
|
|
947
|
+
// to propagate through the body stream.
|
|
948
|
+
//
|
|
949
|
+
// Whether an abort of the caller's signal reaches a response body that is
|
|
950
|
+
// already being read is a property of `boundedFetch`, not of this loop —
|
|
951
|
+
// and it is not a property this loop can observe. Racing here makes "the
|
|
952
|
+
// caller stopped" a fact the read loop establishes for itself, so Stop
|
|
953
|
+
// and Escape end the turn at the next chunk boundary regardless of how
|
|
954
|
+
// the request's signal was assembled. Without it an aborted turn kept
|
|
955
|
+
// consuming a stream nobody wanted and `chat()` never settled at all.
|
|
956
|
+
const aborted = signal && typeof signal.addEventListener === 'function'
|
|
957
|
+
? new Promise((_, reject) => {
|
|
958
|
+
onAbort = () => {
|
|
959
|
+
const e = new Error('The turn was stopped.');
|
|
960
|
+
e.name = 'AbortError';
|
|
961
|
+
e.code = 'ABORT_ERR';
|
|
962
|
+
reject(e);
|
|
963
|
+
};
|
|
964
|
+
if (signal.aborted) onAbort();
|
|
965
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
966
|
+
})
|
|
967
|
+
: null;
|
|
876
968
|
try {
|
|
877
|
-
return await
|
|
969
|
+
return await (aborted
|
|
970
|
+
? Promise.race([reader.read(), timeout, aborted])
|
|
971
|
+
: Promise.race([reader.read(), timeout]));
|
|
878
972
|
} finally {
|
|
879
973
|
clearTimeout(timer);
|
|
974
|
+
if (signal && onAbort && typeof signal.removeEventListener === 'function') {
|
|
975
|
+
signal.removeEventListener('abort', onAbort);
|
|
976
|
+
}
|
|
880
977
|
}
|
|
881
978
|
}
|
|
882
979
|
|
|
@@ -969,7 +1066,13 @@ function createClient(opts = {}) {
|
|
|
969
1066
|
choice && choice.delta && choice.delta.content,
|
|
970
1067
|
choice && choice.message && choice.message.content,
|
|
971
1068
|
fullText,
|
|
972
|
-
(chunk) =>
|
|
1069
|
+
(chunk) => {
|
|
1070
|
+
const clean = stripEnvelope(chunk);
|
|
1071
|
+
if (clean) {
|
|
1072
|
+
cleanText += clean;
|
|
1073
|
+
onStream({ delta: clean });
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
973
1076
|
);
|
|
974
1077
|
const fragments =
|
|
975
1078
|
(choice && choice.delta && choice.delta.tool_calls) ||
|
|
@@ -999,13 +1102,22 @@ function createClient(opts = {}) {
|
|
|
999
1102
|
throw err;
|
|
1000
1103
|
}
|
|
1001
1104
|
|
|
1105
|
+
// A trailing '|…' that never closed (stream ended right after it, or it
|
|
1106
|
+
// was real prose all along) is real content, not an unfinished envelope
|
|
1107
|
+
// token — release it rather than dropping it silently.
|
|
1108
|
+
const strayTail = stripEnvelope.flush();
|
|
1109
|
+
if (strayTail) {
|
|
1110
|
+
cleanText += strayTail;
|
|
1111
|
+
onStream({ delta: strayTail });
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1002
1114
|
// An id-less fragment stream still yields usable calls, but the caller
|
|
1003
1115
|
// needs *an* id to pair results back, so synthesize one.
|
|
1004
1116
|
const toolCalls = [...toolCallsByIndex.entries()]
|
|
1005
1117
|
.sort((a, b) => a[0] - b[0])
|
|
1006
1118
|
.map(([idx, c]) => ({ ...c, id: c.id || `call_${idx}` }));
|
|
1007
1119
|
|
|
1008
|
-
const message = { content:
|
|
1120
|
+
const message = { content: cleanText };
|
|
1009
1121
|
if (toolCalls.length) message.tool_calls = toolCalls;
|
|
1010
1122
|
const result = {
|
|
1011
1123
|
model: resultModel,
|