aegis-desktop 0.8.14 → 0.8.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/lib/local/local.js +54 -2
- package/main.js +22 -5
- package/package.json +1 -1
- package/renderer/app.js +94 -8
- 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".
|
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.15",
|
|
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
|
|
@@ -1772,7 +1820,27 @@ const memoryView = {
|
|
|
1772
1820
|
* instead. Confusing the two is how a save would read a hidden textarea. */
|
|
1773
1821
|
function overlayOpen() {
|
|
1774
1822
|
if (memoryOverlayOpen()) return true;
|
|
1775
|
-
|
|
1823
|
+
// Ask the PANEL, not the module.
|
|
1824
|
+
//
|
|
1825
|
+
// `window.aegisFingerprint` is desktop/renderer/fingerprint.js, whose exports
|
|
1826
|
+
// are `mount` plus pure helpers — it has no `isOpen`. The thing that knows
|
|
1827
|
+
// whether the sheet is up is the handle `mount()` returned, kept in `fpPanel`.
|
|
1828
|
+
// Reaching for `window.aegisFingerprint.isOpen()` threw inside the Escape
|
|
1829
|
+
// listener, and a listener that throws is reported as an uncaught error
|
|
1830
|
+
// rather than propagated to the dispatcher — so `defaultPrevented` stayed
|
|
1831
|
+
// false, Escape stopped interrupting turns, and the visible symptom was a
|
|
1832
|
+
// salvage failure several layers away. A source-shape change must not be able
|
|
1833
|
+
// to do that again: an unusable handle reads as "not open" here, so a broken
|
|
1834
|
+
// φ(α) panel can never disarm the keyboard interrupt (the smoke run fails
|
|
1835
|
+
// loudly instead, on the recorded reason).
|
|
1836
|
+
if (fpPanel && typeof fpPanel.isOpen === 'function') {
|
|
1837
|
+
try {
|
|
1838
|
+
return !!fpPanel.isOpen();
|
|
1839
|
+
} catch (e) {
|
|
1840
|
+
return false;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
return false;
|
|
1776
1844
|
}
|
|
1777
1845
|
|
|
1778
1846
|
/** The memory inspector alone. Split out of overlayOpen() when the authorship
|
|
@@ -4483,11 +4551,20 @@ async function init() {
|
|
|
4483
4551
|
// which made its veto assertion unfalsifiable. Exposes state only: no
|
|
4484
4552
|
// setters, nothing that can drive the UI. Frozen so a stray write in a test
|
|
4485
4553
|
// cannot fake a passing run.
|
|
4554
|
+
// Bound below, read by the diagnostic just above. `defaultPrevented === false`
|
|
4555
|
+
// on its own cannot say whether Escape was declined by policy or never
|
|
4556
|
+
// handled at all, and that ambiguity is what makes an unwired interrupt look
|
|
4557
|
+
// like a renderer regression.
|
|
4558
|
+
let escapeBind = null;
|
|
4486
4559
|
window.__aegisSmoke = Object.freeze({
|
|
4487
4560
|
isScrolledUp: () => transcript.isScrolledUp(),
|
|
4488
4561
|
metrics: () => transcript.metrics(),
|
|
4562
|
+
escapeDecision: () => (escapeBind && typeof escapeBind.lastDecision === 'function'
|
|
4563
|
+
? escapeBind.lastDecision()
|
|
4564
|
+
: null),
|
|
4565
|
+
stopOutcome: () => lastStopOutcome,
|
|
4489
4566
|
});
|
|
4490
|
-
bindEscapeInterrupt({
|
|
4567
|
+
escapeBind = bindEscapeInterrupt({
|
|
4491
4568
|
doc: document,
|
|
4492
4569
|
// The memory overlay wins: while it is open, Escape closes it rather than
|
|
4493
4570
|
// reaching past it to cancel a turn the user may not be looking at.
|
|
@@ -4499,9 +4576,18 @@ async function init() {
|
|
|
4499
4576
|
// both are somehow up.
|
|
4500
4577
|
isOverlayOpen: overlayOpen,
|
|
4501
4578
|
onOverlayEscape: () => {
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
4579
|
+
// Same handle, same reason as overlayOpen(): the module has no `close`,
|
|
4580
|
+
// the mounted panel does. Guessed at the module here too, which threw for
|
|
4581
|
+
// the same reason — and an overlay can only be closed by its own sheet's
|
|
4582
|
+
// handle, so there is nothing to fall back to but the memory overlay.
|
|
4583
|
+
if (fpPanel && typeof fpPanel.close === 'function') {
|
|
4584
|
+
try {
|
|
4585
|
+
fpPanel.close();
|
|
4586
|
+
return;
|
|
4587
|
+
} catch (e) {
|
|
4588
|
+
// fall through to the memory overlay rather than rethrowing inside a
|
|
4589
|
+
// keydown listener
|
|
4590
|
+
}
|
|
4505
4591
|
}
|
|
4506
4592
|
closeMemoryOverlay();
|
|
4507
4593
|
},
|
|
@@ -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,
|