aegis-desktop 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -4
- package/lib/local/engine.js +94 -18
- package/lib/local/providers.js +67 -4
- package/main.js +6 -5
- package/package.json +6 -3
- package/renderer/app.js +168 -32
- package/renderer/index.html +3 -0
- package/renderer/stream-policy.js +88 -0
- package/renderer/transcript-view.js +176 -0
- package/renderer/usage.js +42 -0
- package/vendor/aegis.js +47 -7
package/README.md
CHANGED
|
@@ -1,8 +1,49 @@
|
|
|
1
1
|
# AEGIS Desktop
|
|
2
2
|
|
|
3
|
-
Electron
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
A standalone Electron chat app over the [AEGIS](https://aegiscloud.org) API,
|
|
4
|
+
with an **agentic tool loop**: the model can read, write, and edit files,
|
|
5
|
+
list directories, glob, grep, run shell commands in a persistent session, and
|
|
6
|
+
delegate whole sub-tasks to subagents. It does **not** require Claude Code.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install -g aegis-desktop
|
|
10
|
+
aegis
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Model classes
|
|
14
|
+
|
|
15
|
+
Pick any of four transports from the model-class picker, switchable
|
|
16
|
+
mid-conversation with context intact:
|
|
17
|
+
|
|
18
|
+
| Class | Transport | Key held in |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| **Aegis Cloud** | `aegiscloud.org` — one entry, **Nexus**; the pool auto-routes across whichever providers are live | main process |
|
|
21
|
+
| **Ollama** | local `ollama` daemon | no key needed |
|
|
22
|
+
| **Custom OpenAI-compatible** (LM Studio, OpenRouter, vLLM, …) | direct from the desktop app | main process — never sent to the renderer |
|
|
23
|
+
| **Anthropic-compatible** (Claude, or any Messages-format gateway) | direct from the desktop app | main process |
|
|
24
|
+
|
|
25
|
+
Get a free AEGIS key at **https://aegiscloud.org**, or use your own
|
|
26
|
+
Ollama/OpenAI-compatible/Anthropic-compatible endpoint — no AEGIS account
|
|
27
|
+
needed for those.
|
|
28
|
+
|
|
29
|
+
## Tools available to the model
|
|
30
|
+
|
|
31
|
+
| Tool | What it does |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `readFile` · `writeFile` · `editFile` | File access scoped to the working directory |
|
|
34
|
+
| `listDir` · `glob` · `grep` | Navigate and search a tree |
|
|
35
|
+
| `exec` | Run commands in a persistent shell session |
|
|
36
|
+
| `task` | Delegate a self-contained sub-task to a subagent |
|
|
37
|
+
|
|
38
|
+
Before `exec`, `writeFile`, or `editFile` runs, a diff/approval card asks you
|
|
39
|
+
to confirm — approve once, approve for the rest of the conversation, or deny.
|
|
40
|
+
Flip **Settings → "Confirm before running tools"** off if you'd rather the
|
|
41
|
+
agent run mutating tool calls without asking; it's on by default.
|
|
42
|
+
|
|
43
|
+
Conversations persist locally and sync to AEGIS cloud memory via a pending
|
|
44
|
+
queue that flushes on each "Sync now" or heartbeat retry. The **remember**
|
|
45
|
+
button on any assistant reply pins that message to cross-machine memory —
|
|
46
|
+
queued locally if you're offline.
|
|
6
47
|
|
|
7
48
|
## Keyboard shortcuts
|
|
8
49
|
|
|
@@ -45,6 +86,15 @@ accelerator is already claimed by another application, registration fails
|
|
|
45
86
|
gracefully: a warning is logged to the main process console and the
|
|
46
87
|
Quick Launcher card shows the reason instead of the app crashing or hanging.
|
|
47
88
|
|
|
89
|
+
## Deep links
|
|
90
|
+
|
|
91
|
+
The app registers an `aegis://` protocol handler:
|
|
92
|
+
|
|
93
|
+
| Link | What it does |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `aegis://open?session=<id>` | Resumes a saved session |
|
|
96
|
+
| `aegis://new?prompt=<text>` | Starts a fresh chat with that prompt pre-filled |
|
|
97
|
+
|
|
48
98
|
## Run from source
|
|
49
99
|
|
|
50
100
|
```bash
|
|
@@ -53,9 +103,35 @@ npm install
|
|
|
53
103
|
npm start
|
|
54
104
|
```
|
|
55
105
|
|
|
106
|
+
## Build a distributable
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
npm run dist # packaged app (AppImage / MSI+NSIS / dmg)
|
|
110
|
+
npm run dist:dir # unpacked dir, for quick testing
|
|
111
|
+
```
|
|
112
|
+
|
|
56
113
|
## Checks
|
|
57
114
|
|
|
58
115
|
```bash
|
|
59
|
-
npm run check
|
|
116
|
+
npm run check # node --check every main-process + renderer file
|
|
60
117
|
node ../test/desktop-shell.mjs # headless IPC smoke test (no Electron binary needed)
|
|
61
118
|
```
|
|
119
|
+
|
|
120
|
+
## Structure
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
main.js Electron main process — window + IPC shell only
|
|
124
|
+
preload.js Context-isolated IPC bridge exposed to the renderer
|
|
125
|
+
renderer/ UI (vanilla JS, no framework)
|
|
126
|
+
lib/local/ Model classes, providers, agentic tool loop, prompt
|
|
127
|
+
lib/sync/ Local session/memory persistence + sync queue
|
|
128
|
+
vendor/aegis.js The AEGIS transport client (thin — no engine logic)
|
|
129
|
+
bin/aegis.js `aegis` CLI entry point for the global npm install
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
This directory is part of the [aegiscode-plugin](../README.md) monorepo,
|
|
133
|
+
which also ships a Claude Code plugin and the shared `client/aegis.js`
|
|
134
|
+
transport over the same AEGIS backend — see the repo root for that fuller
|
|
135
|
+
architecture picture. A read-only mirror of just this directory (for
|
|
136
|
+
browsing or `git clone`) lives at
|
|
137
|
+
[aegiscloud/aegiscode-desktop](https://github.com/aegiscloud/aegiscode-desktop).
|
package/lib/local/engine.js
CHANGED
|
@@ -123,23 +123,47 @@ function normalizeCatalog(models) {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
/**
|
|
126
|
-
* The Aegis Cloud catalog (`/api/v1/models`)
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
126
|
+
* The Aegis Cloud catalog (`/api/v1/models`) lists every backend the pool can
|
|
127
|
+
* reach: per-provider ids (`openai`, `anthropic`, `groq`, `gemini`, ...) and
|
|
128
|
+
* six pooled-brain tier ids (`{aegis,nexus}-brain[-smart|-neo]`) that all run
|
|
129
|
+
* the same worker pool on the same backend model. None of that is a human's
|
|
130
|
+
* model choice — which providers currently hold a valid key is an ops detail
|
|
131
|
+
* (today: deepseek/anthropic/groq; openai and gemini drift in and out), and
|
|
132
|
+
* surfacing it invites picking a provider that happens to be dead right now.
|
|
133
|
+
* The pool already auto-routes across whichever providers are live, so the
|
|
134
|
+
* desktop dropdown offers exactly one entry for the "aegis" class: the
|
|
135
|
+
* collapsed "Nexus" brain — never the raw provider list.
|
|
134
136
|
*/
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
|
|
137
|
+
const NEXUS_BRAIN_IDS = Object.freeze(['nexus-brain', 'aegis-brain']);
|
|
138
|
+
const NEXUS_LABEL = 'Nexus';
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Pick the one entry standing in for the pooled brain. The server serves
|
|
142
|
+
* `nexus-brain` as the canonical tier (`hidden: false`, no `alias_of`) and
|
|
143
|
+
* marks every other spelling — `aegis-brain` and the `-smart`/`-neo` tiers —
|
|
144
|
+
* as `hidden: true, alias_of: "nexus-brain"`. Prefer the canonical id so the
|
|
145
|
+
* request travels on the name the server owns; fall back to the alias, then to
|
|
146
|
+
* any tier whose `alias_of` names the brain, so a renamed or trimmed catalog
|
|
147
|
+
* still resolves to something selectable instead of silently emptying the
|
|
148
|
+
* dropdown (the previous fixed-id lookup returned [] if `aegis-brain` was ever
|
|
149
|
+
* retired, leaving the class with no model to choose).
|
|
150
|
+
*/
|
|
151
|
+
function selectBrainEntry(models) {
|
|
152
|
+
return (
|
|
153
|
+
models.find((m) => NEXUS_BRAIN_IDS.includes(m.id) && !m.hidden && m.alias_of === undefined)
|
|
154
|
+
|| models.find((m) => NEXUS_BRAIN_IDS.includes(m.id))
|
|
155
|
+
|| models.find((m) => m.alias_of && NEXUS_BRAIN_IDS.includes(m.alias_of))
|
|
156
|
+
|| null
|
|
157
|
+
);
|
|
158
|
+
}
|
|
138
159
|
|
|
139
160
|
function filterAegisCatalog(models) {
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
161
|
+
const nexus = selectBrainEntry(models);
|
|
162
|
+
if (!nexus) return [];
|
|
163
|
+
// Drop the alias bookkeeping: this entry *is* the selection, so the renderer
|
|
164
|
+
// must never treat it as a hidden alias and filter it back out.
|
|
165
|
+
const { hidden, alias_of, ...rest } = nexus;
|
|
166
|
+
return [{ ...rest, label: NEXUS_LABEL }];
|
|
143
167
|
}
|
|
144
168
|
|
|
145
169
|
// ── Agent-loop helpers ──────────────────────────────────────────────────────
|
|
@@ -497,6 +521,12 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
497
521
|
mode: opts.mode,
|
|
498
522
|
maxTokens: opts.maxTokens,
|
|
499
523
|
stream: true,
|
|
524
|
+
// The pooled (Nexus) brain is streamed, and an OpenAI-compatible SSE
|
|
525
|
+
// stream reports no token usage unless asked. Without this the Aegis
|
|
526
|
+
// Cloud class — the desktop's default — was the one class that answered
|
|
527
|
+
// with text but never a token count, so a pooled turn's spend was
|
|
528
|
+
// invisible here while the same model through the MCP path reported it.
|
|
529
|
+
includeUsage: true,
|
|
500
530
|
onStream: opts.onDelta,
|
|
501
531
|
// Extended-reasoning trace (the fan-out's worker findings). Its own
|
|
502
532
|
// channel so it never counts as answer text — see vendor/aegis.js.
|
|
@@ -658,6 +688,41 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
658
688
|
let truncationRetried = false;
|
|
659
689
|
let synthesisDone = false;
|
|
660
690
|
|
|
691
|
+
// Token accounting for the whole TURN, not just its last round. An
|
|
692
|
+
// agentic turn makes one provider call per tool round, and returning only
|
|
693
|
+
// the final round's `usage` (what this did) reported a fraction of what
|
|
694
|
+
// was actually spent — the tool phase's tokens simply disappeared. Every
|
|
695
|
+
// dispatch is summed, including the truncation retry and the synthesis
|
|
696
|
+
// re-dispatch: both are real, separately billed provider calls.
|
|
697
|
+
const turnUsage = { calls: 0 };
|
|
698
|
+
const USAGE_FIELDS = ['input_tokens', 'output_tokens', 'prompt_tokens', 'completion_tokens', 'total_tokens'];
|
|
699
|
+
const addUsage = (res) => {
|
|
700
|
+
const u = res && res.usage;
|
|
701
|
+
if (!u || typeof u !== 'object') return;
|
|
702
|
+
turnUsage.calls += 1;
|
|
703
|
+
for (const key of USAGE_FIELDS) {
|
|
704
|
+
if (typeof u[key] === 'number' && Number.isFinite(u[key])) {
|
|
705
|
+
turnUsage[key] = (turnUsage[key] || 0) + u[key];
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
// A provider that reports only the split (Anthropic-compatible) has no
|
|
709
|
+
// total to sum, so derive one or the renderer has nothing to print.
|
|
710
|
+
if (typeof u.total_tokens !== 'number') {
|
|
711
|
+
const derived =
|
|
712
|
+
(u.prompt_tokens ?? u.input_tokens ?? 0) + (u.completion_tokens ?? u.output_tokens ?? 0);
|
|
713
|
+
if (derived) turnUsage.total_tokens = (turnUsage.total_tokens || 0) + derived;
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
// The turn's totals win over any single round's, so the attached usage is
|
|
717
|
+
// never the last round wearing the whole turn's label.
|
|
718
|
+
const withTurnUsage = (res) => {
|
|
719
|
+
if (!res || typeof res !== 'object' || !turnUsage.calls) return res;
|
|
720
|
+
// Built from the accumulator alone: every field already present in a
|
|
721
|
+
// round's usage was summed into it, so merging the last round back in
|
|
722
|
+
// could only reintroduce a partial number under a whole-turn label.
|
|
723
|
+
return { ...res, usage: { ...turnUsage } };
|
|
724
|
+
};
|
|
725
|
+
|
|
661
726
|
// Round 1's shorthand prompt was sent as `prompt`, not as a message, so
|
|
662
727
|
// any follow-up dispatch in this turn must fold it into the history
|
|
663
728
|
// first or the model would be shown a nudge with no question above it.
|
|
@@ -683,6 +748,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
683
748
|
if (!retriable) throw e;
|
|
684
749
|
res = await dispatch(cls, { ...opts, tools: [] });
|
|
685
750
|
}
|
|
751
|
+
addUsage(res);
|
|
686
752
|
|
|
687
753
|
// Budget exhausted before the answer was written. Doubling it costs
|
|
688
754
|
// one request and converts a dead turn into a real one; a second
|
|
@@ -698,11 +764,12 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
698
764
|
if (!truncationRetried && !assistantText(res) && isTruncated(res)) {
|
|
699
765
|
truncationRetried = true;
|
|
700
766
|
res = await dispatch(cls, { ...opts, maxTokens: doubledBudget(opts.maxTokens) });
|
|
767
|
+
addUsage(res);
|
|
701
768
|
}
|
|
702
769
|
|
|
703
770
|
const calls = toolSchemas.length ? extractToolCalls(res) : [];
|
|
704
771
|
if (!calls.length) {
|
|
705
|
-
if (assistantText(res) || synthesisDone || !toolsEnabled) return res;
|
|
772
|
+
if (assistantText(res) || synthesisDone || !toolsEnabled) return withTurnUsage(res);
|
|
706
773
|
// The model stopped without calling a tool and without saying
|
|
707
774
|
// anything. Force the summary out of the context it already holds
|
|
708
775
|
// instead of handing the renderer a blank completion. Skipped when
|
|
@@ -712,6 +779,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
712
779
|
foldPromptIntoHistory();
|
|
713
780
|
history.push({ role: 'user', content: EMPTY_TURN_NUDGE });
|
|
714
781
|
res = await dispatch(cls, { ...opts, messages: history, prompt: '', tools: [] });
|
|
782
|
+
addUsage(res);
|
|
715
783
|
if (!assistantText(res)) {
|
|
716
784
|
throw emptyTurnError({
|
|
717
785
|
cls,
|
|
@@ -720,7 +788,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
720
788
|
finishReason: finishReasonOf(res),
|
|
721
789
|
});
|
|
722
790
|
}
|
|
723
|
-
return res;
|
|
791
|
+
return withTurnUsage(res);
|
|
724
792
|
}
|
|
725
793
|
|
|
726
794
|
foldPromptIntoHistory();
|
|
@@ -743,6 +811,8 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
743
811
|
cls, model, maxTokens, mode: payload && payload.mode, parentSignal: signal, depth, rootSessionId, rootOnDelta,
|
|
744
812
|
})
|
|
745
813
|
: await gatedExecuteTool(call, { toolCtx, rootSessionId, rootOnDelta, signal });
|
|
814
|
+
// A subagent's spend rides back on its tool result (see runSubagent).
|
|
815
|
+
if (result && result.usage) addUsage({ usage: result.usage });
|
|
746
816
|
if (onDelta) onDelta({ delta: '', tool: { name: call.name, args: call.args, ok: result.ok } });
|
|
747
817
|
history.push({
|
|
748
818
|
role: 'tool',
|
|
@@ -798,9 +868,15 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
798
868
|
() => {}
|
|
799
869
|
);
|
|
800
870
|
const text = assistantText(res);
|
|
871
|
+
// The subagent's tokens were billed to the same account as the parent
|
|
872
|
+
// turn, so its usage travels back on the tool result and is summed into
|
|
873
|
+
// the parent's total. Attached even on the no-output branch: a subagent
|
|
874
|
+
// that burned a full context and said nothing is precisely the spend the
|
|
875
|
+
// user most needs to see.
|
|
876
|
+
const spent = res && res.usage ? { usage: res.usage } : {};
|
|
801
877
|
return text
|
|
802
|
-
? { ok: true, output: text }
|
|
803
|
-
: { ok: false, error: `subagent (${label}) produced no output
|
|
878
|
+
? { ok: true, output: text, ...spent }
|
|
879
|
+
: { ok: false, error: `subagent (${label}) produced no output`, ...spent };
|
|
804
880
|
} catch (e) {
|
|
805
881
|
return { ok: false, error: `subagent (${label}) failed: ${e && e.message ? e.message : e}` };
|
|
806
882
|
} finally {
|
package/lib/local/providers.js
CHANGED
|
@@ -380,6 +380,48 @@ async function openaiCompatible({
|
|
|
380
380
|
return result;
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Normalise an Anthropic usage object into the one shape every caller reads.
|
|
385
|
+
*
|
|
386
|
+
* Anthropic reports tokens split across TWO events and in TWO pieces:
|
|
387
|
+
* - `message_start` carries the prompt side (`input_tokens`, plus
|
|
388
|
+
* `cache_creation_input_tokens` / `cache_read_input_tokens`);
|
|
389
|
+
* - `message_delta` carries ONLY the cumulative `output_tokens`.
|
|
390
|
+
* Nothing in the wire format is called `total_tokens`, which is the single
|
|
391
|
+
* field the renderer prints — so every Anthropic-compatible model reported no
|
|
392
|
+
* token count at all while OpenAI-compatible ones showed one.
|
|
393
|
+
*
|
|
394
|
+
* Returns `null` for an empty/absent usage so the caller can omit the field
|
|
395
|
+
* rather than advertise a fake `0`.
|
|
396
|
+
*/
|
|
397
|
+
function normalizeAnthropicUsage(raw) {
|
|
398
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
399
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
|
|
400
|
+
const input = num(raw.input_tokens);
|
|
401
|
+
const output = num(raw.output_tokens);
|
|
402
|
+
const cacheRead = num(raw.cache_read_input_tokens);
|
|
403
|
+
const cacheCreate = num(raw.cache_creation_input_tokens);
|
|
404
|
+
// Cached prompt tokens are still prompt tokens: OpenAI counts them inside
|
|
405
|
+
// `prompt_tokens` (exposing the subset as prompt_tokens_details.cached), so
|
|
406
|
+
// folding them in keeps the two wire formats' numbers comparable — and a
|
|
407
|
+
// turn that reads a big cached prefix must not look free.
|
|
408
|
+
const prompt = input + cacheRead + cacheCreate;
|
|
409
|
+
if (!prompt && !output) return null;
|
|
410
|
+
const usage = {
|
|
411
|
+
// Anthropic's own names first, so a caller reading native blocks keeps both
|
|
412
|
+
// halves of the split it used to get (it previously got only output_tokens).
|
|
413
|
+
input_tokens: input,
|
|
414
|
+
output_tokens: output,
|
|
415
|
+
// OpenAI spellings, read by the renderer and every other transport here.
|
|
416
|
+
prompt_tokens: prompt,
|
|
417
|
+
completion_tokens: output,
|
|
418
|
+
total_tokens: prompt + output,
|
|
419
|
+
};
|
|
420
|
+
if (cacheRead) usage.cache_read_input_tokens = cacheRead;
|
|
421
|
+
if (cacheCreate) usage.cache_creation_input_tokens = cacheCreate;
|
|
422
|
+
return usage;
|
|
423
|
+
}
|
|
424
|
+
|
|
383
425
|
/** Anthropic Messages streaming chat (x-api-key + anthropic-version). */
|
|
384
426
|
async function anthropicMessages({
|
|
385
427
|
baseURL,
|
|
@@ -423,8 +465,11 @@ async function anthropicMessages({
|
|
|
423
465
|
|
|
424
466
|
let fullText = '';
|
|
425
467
|
let resultModel = modelId;
|
|
426
|
-
let usage = null;
|
|
427
468
|
let stopReason = null;
|
|
469
|
+
// Accumulated, never replaced: the prompt half arrives on `message_start` and
|
|
470
|
+
// the output half on `message_delta`, so assignment (which is what this used
|
|
471
|
+
// to do) left whichever event came last owning the whole object.
|
|
472
|
+
const usageAcc = {};
|
|
428
473
|
const toolBlocks = new Map(); // content-block index → {id, name, args}
|
|
429
474
|
|
|
430
475
|
await requestStream({
|
|
@@ -446,7 +491,7 @@ async function anthropicMessages({
|
|
|
446
491
|
}
|
|
447
492
|
if (json.type === 'message_start' && json.message) {
|
|
448
493
|
if (json.message.model) resultModel = json.message.model;
|
|
449
|
-
if (json.message.usage)
|
|
494
|
+
if (json.message.usage) Object.assign(usageAcc, json.message.usage);
|
|
450
495
|
}
|
|
451
496
|
// A tool_use block opens here; its arguments arrive as
|
|
452
497
|
// input_json_delta fragments below (the text deltas we already parsed
|
|
@@ -474,13 +519,30 @@ async function anthropicMessages({
|
|
|
474
519
|
}
|
|
475
520
|
}
|
|
476
521
|
if (json.type === 'message_delta') {
|
|
477
|
-
|
|
522
|
+
// `message_delta.usage` is PARTIAL — `output_tokens` only — so it is
|
|
523
|
+
// merged onto what `message_start` already reported, not assigned over
|
|
524
|
+
// it. Assigning it wholesale is what made prompt tokens vanish from
|
|
525
|
+
// every Anthropic-compatible call's accounting.
|
|
526
|
+
if (json.usage) {
|
|
527
|
+
for (const [k, v] of Object.entries(json.usage)) {
|
|
528
|
+
if (k === 'output_tokens') continue;
|
|
529
|
+
// Only take a field this event carries a real value for. Some
|
|
530
|
+
// Anthropic-compatible gateways echo the whole usage object here
|
|
531
|
+
// with a zeroed prompt side, and letting a bogus 0 overwrite the
|
|
532
|
+
// count message_start already reported is the same data loss in a
|
|
533
|
+
// different costume.
|
|
534
|
+
if (usageAcc[k] == null || (typeof v === 'number' && v > 0)) usageAcc[k] = v;
|
|
535
|
+
}
|
|
536
|
+
if (typeof json.usage.output_tokens === 'number') {
|
|
537
|
+
usageAcc.output_tokens = json.usage.output_tokens;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
478
540
|
if (json.delta && json.delta.stop_reason) stopReason = json.delta.stop_reason;
|
|
479
541
|
}
|
|
480
542
|
// Non-streaming fallback: a full Anthropic Message with content blocks.
|
|
481
543
|
if (Array.isArray(json.content)) {
|
|
482
544
|
if (json.model) resultModel = json.model;
|
|
483
|
-
if (json.usage)
|
|
545
|
+
if (json.usage) Object.assign(usageAcc, json.usage);
|
|
484
546
|
if (json.stop_reason) stopReason = json.stop_reason;
|
|
485
547
|
json.content.forEach((block, i) => {
|
|
486
548
|
if (block && block.type === 'text' && block.text && !fullText) {
|
|
@@ -501,6 +563,7 @@ async function anthropicMessages({
|
|
|
501
563
|
|
|
502
564
|
const toolCalls = finalizeToolCalls(toolBlocks);
|
|
503
565
|
const result = { model: resultModel, choices: [{ message: { content: fullText } }] };
|
|
566
|
+
const usage = normalizeAnthropicUsage(usageAcc);
|
|
504
567
|
if (usage) result.usage = usage;
|
|
505
568
|
if (stopReason) result.stop_reason = stopReason;
|
|
506
569
|
if (toolCalls.length) {
|
package/main.js
CHANGED
|
@@ -1432,11 +1432,6 @@ function bootstrap() {
|
|
|
1432
1432
|
pushToMain: pushQuickLauncherResult,
|
|
1433
1433
|
});
|
|
1434
1434
|
registerQuickLauncherIpc(ipcMain, quickLauncherDispatch);
|
|
1435
|
-
// Apply whatever was last saved (or the default) right away: ship the
|
|
1436
|
-
// shortcut only when app.isPackaged || the settings flag is on — see
|
|
1437
|
-
// shouldEnableGlobalShortcut — so a plain `electron .` dev run never grabs
|
|
1438
|
-
// a systemwide hotkey unless the developer opted in from Settings.
|
|
1439
|
-
quickLauncherDispatch.setConfig(settings.quickLauncherConfig());
|
|
1440
1435
|
|
|
1441
1436
|
// A held global shortcut outlives this app if not released — every quit
|
|
1442
1437
|
// path (explicit quit, window-all-closed on non-mac, OS shutdown) must
|
|
@@ -1572,6 +1567,12 @@ function bootstrap() {
|
|
|
1572
1567
|
// key was saved in-app (safeStorage is usable only after app ready).
|
|
1573
1568
|
const persistedKey = settings.aegisRawKey();
|
|
1574
1569
|
if (persistedKey) aegis.setApiKey(persistedKey);
|
|
1570
|
+
// Apply whatever was last saved (or the default): ship the shortcut only
|
|
1571
|
+
// when app.isPackaged || the settings flag is on — see
|
|
1572
|
+
// shouldEnableGlobalShortcut — so a plain `electron .` dev run never
|
|
1573
|
+
// grabs a systemwide hotkey unless the developer opted in from Settings.
|
|
1574
|
+
// Must run after 'ready' — globalShortcut throws before then.
|
|
1575
|
+
quickLauncherDispatch.setConfig(settings.quickLauncherConfig());
|
|
1575
1576
|
const win = createWindow();
|
|
1576
1577
|
updateManager.start();
|
|
1577
1578
|
// Cold-launch deep link (Linux/Windows argv, or a pre-ready macOS
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegis-desktop",
|
|
3
3
|
"productName": "AEGIS Desktop",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.2",
|
|
5
5
|
"description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "AEGIS Code",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"icon": "node scripts/generate-icon.mjs",
|
|
30
30
|
"dist": "npm run predist && electron-builder",
|
|
31
31
|
"dist:dir": "npm run predist && electron-builder --dir",
|
|
32
|
-
"check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/max-tokens.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check lib/local/context.js && node --check lib/local/providers.js && node --check lib/local/ollama.js && node --check lib/local/engine.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check bin/aegis.js",
|
|
32
|
+
"check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/max-tokens.js && node --check renderer/usage.js && node --check renderer/stream-policy.js && node --check renderer/transcript-view.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check lib/local/context.js && node --check lib/local/providers.js && node --check lib/local/ollama.js && node --check lib/local/engine.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check bin/aegis.js",
|
|
33
33
|
"test:shell": "node ../test/desktop-shell.mjs",
|
|
34
34
|
"test:model": "node ../test/model-dispatch.mjs",
|
|
35
35
|
"test:max-tokens": "node ../test/max-tokens.test.mjs",
|
|
@@ -38,7 +38,10 @@
|
|
|
38
38
|
"test:engine": "node ../test/local-engine.test.mjs",
|
|
39
39
|
"test:highlight": "node ../test/aegis-highlight.test.mjs",
|
|
40
40
|
"test:markdown": "node ../test/markdown.test.mjs",
|
|
41
|
-
"test:deep-link": "node test/deep-link.test.mjs"
|
|
41
|
+
"test:deep-link": "node test/deep-link.test.mjs",
|
|
42
|
+
"test:stream-policy": "node ../test/stream-policy.test.mjs",
|
|
43
|
+
"test:renderer-dom": "node ../test/renderer-dom.test.mjs",
|
|
44
|
+
"test:renderer-wiring": "node ../test/renderer-wiring.test.mjs"
|
|
42
45
|
},
|
|
43
46
|
"dependencies": {
|
|
44
47
|
"electron": "^33.0.0",
|
package/renderer/app.js
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
* selection, routed in the main process. If this file grows engine logic it is
|
|
14
14
|
* wrong.
|
|
15
15
|
*
|
|
16
|
-
* `maxTokensCeiling`/`FLAT_CEILING` come from max-tokens.js
|
|
17
|
-
* classic
|
|
18
|
-
* ceiling math
|
|
16
|
+
* `maxTokensCeiling`/`FLAT_CEILING` come from max-tokens.js and `usageTokens`
|
|
17
|
+
* from usage.js, sibling classic scripts loaded before this one (see
|
|
18
|
+
* index.html) so the per-model ceiling math and the token-usage → displayed
|
|
19
|
+
* number mapping stay unit-testable without window.aegis/window.models.
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
// Everything below runs inside an IIFE. preload.js's contextBridge.exposeInMainWorld
|
|
@@ -249,6 +250,79 @@ function abortBranches() {
|
|
|
249
250
|
activeBranches.clear();
|
|
250
251
|
}
|
|
251
252
|
|
|
253
|
+
// ----------------------------------------------------------------- streaming
|
|
254
|
+
// Two problems share a root: a running turn owns the transcript. It scrolls
|
|
255
|
+
// the view on every chunk and repaints on every chunk, so the user can neither
|
|
256
|
+
// read earlier turns nor stay responsive enough to hit "stop". Both are fixed
|
|
257
|
+
// by giving the reader a veto over the scroll and batching the paints.
|
|
258
|
+
//
|
|
259
|
+
// The decisions *and* their DOM listeners live in transcript-view.js (a
|
|
260
|
+
// sibling classic script loaded before this one), so the behaviours this path
|
|
261
|
+
// exists to guarantee — follow only at the tail, one paint per frame, Escape
|
|
262
|
+
// interrupts — are asserted against real code in test/renderer-dom.test.mjs
|
|
263
|
+
// instead of only as pure math in stream-policy.js.
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Set when the user asks the running turn to stop. The abort comes back as a
|
|
267
|
+
* rejected IPC call, which does not preserve `err.name`, so this flag — not an
|
|
268
|
+
* AbortError check — is what distinguishes a stop the user asked for from a
|
|
269
|
+
* genuine failure.
|
|
270
|
+
*/
|
|
271
|
+
let userStopped = false;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Transcript policy, created by init(): the reader's scroll veto, the
|
|
275
|
+
* frame-coalesced painter, and the Escape→stop listener all live in it. Built
|
|
276
|
+
* in init() rather than here because #messages does not exist until the body
|
|
277
|
+
* has parsed.
|
|
278
|
+
*/
|
|
279
|
+
let transcript = null;
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Auto-scroll only while the reader is still at the tail — the rule itself is
|
|
283
|
+
* `shouldFollow` in transcript-view.js. Null-safe: a boot that failed early
|
|
284
|
+
* must not turn into a second error on the first paint.
|
|
285
|
+
*/
|
|
286
|
+
function stickToBottom(opts) {
|
|
287
|
+
if (!transcript) return false;
|
|
288
|
+
return transcript.follow(opts);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** One paint per frame, off the latest cumulative text — see transcript-view.js. */
|
|
292
|
+
function rafPainter(paint) {
|
|
293
|
+
// Every caller runs after boot; painting directly is the safe degradation.
|
|
294
|
+
return transcript ? transcript.paint(paint) : paint;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Stop the turn running right now. The transport already honours the abort all
|
|
299
|
+
* the way down (engine.cancel -> AbortController -> the cloud client's fetch),
|
|
300
|
+
* so this only has to reach it — the button in the bubble and Escape are two
|
|
301
|
+
* doors onto the same call.
|
|
302
|
+
*/
|
|
303
|
+
function stopPendingTurn() {
|
|
304
|
+
if (!pendingSessionId) return false;
|
|
305
|
+
// Recorded before the abort lands: `send()`'s catch reads it to tell a
|
|
306
|
+
// deliberate stop from a real error.
|
|
307
|
+
userStopped = true;
|
|
308
|
+
try {
|
|
309
|
+
models.cancel(pendingSessionId);
|
|
310
|
+
} catch {
|
|
311
|
+
/* a dead controller is not an error */
|
|
312
|
+
}
|
|
313
|
+
// The abort is not instantaneous. Marking the button is all the feedback that
|
|
314
|
+
// survives the trip: `setBusy(false)` deletes the entire pending bubble on
|
|
315
|
+
// the way out, so anything written into it would vanish a moment later.
|
|
316
|
+
if (pendingEl) {
|
|
317
|
+
const btn = pendingEl.querySelector('.cancel-btn');
|
|
318
|
+
if (btn) {
|
|
319
|
+
btn.disabled = true;
|
|
320
|
+
btn.textContent = 'stopping…';
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
|
|
252
326
|
function exploreEnabled() {
|
|
253
327
|
const box = els.exploreToggle;
|
|
254
328
|
return Boolean(box && box.checked);
|
|
@@ -1196,7 +1270,10 @@ function addMessage(role, text, meta, sessionId, toolLog) {
|
|
|
1196
1270
|
}
|
|
1197
1271
|
|
|
1198
1272
|
els.messages.appendChild(row);
|
|
1199
|
-
|
|
1273
|
+
// Follows only when the reader is still at the tail — see stickToBottom.
|
|
1274
|
+
// A discrete new message must not drag the view away from someone reading
|
|
1275
|
+
// history; the send path forces the follow explicitly instead.
|
|
1276
|
+
stickToBottom();
|
|
1200
1277
|
return row;
|
|
1201
1278
|
}
|
|
1202
1279
|
|
|
@@ -1355,9 +1432,8 @@ async function spawnPath(card, spec) {
|
|
|
1355
1432
|
const bits = [spec.path.title];
|
|
1356
1433
|
if (data && data.model) bits.push(data.model);
|
|
1357
1434
|
else if (spec.model) bits.push(spec.model);
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
}
|
|
1435
|
+
const flowTokens = usageTokens(data && data.usage);
|
|
1436
|
+
if (flowTokens != null) bits.push(`${flowTokens} tokens`);
|
|
1361
1437
|
meta.textContent = bits.join(' · ');
|
|
1362
1438
|
} catch (err) {
|
|
1363
1439
|
const message = err && err.message ? err.message : String(err);
|
|
@@ -1440,9 +1516,9 @@ function setBusy(busy, { cancellable } = {}) {
|
|
|
1440
1516
|
cancelBtn.type = 'button';
|
|
1441
1517
|
cancelBtn.className = 'cancel-btn';
|
|
1442
1518
|
cancelBtn.textContent = 'cancel';
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1519
|
+
// One path for both doors: the button and Escape must produce identical
|
|
1520
|
+
// feedback, including the salvage `send()` performs.
|
|
1521
|
+
cancelBtn.addEventListener('click', () => stopPendingTurn());
|
|
1446
1522
|
pendingEl.appendChild(cancelBtn);
|
|
1447
1523
|
}
|
|
1448
1524
|
} else if (pendingEl) {
|
|
@@ -2166,6 +2242,9 @@ async function send() {
|
|
|
2166
2242
|
}
|
|
2167
2243
|
|
|
2168
2244
|
els.prompt.value = '';
|
|
2245
|
+
// Clear the stop flag a previous turn may have left set, so a stale `true`
|
|
2246
|
+
// can never make an unrelated failure look like a deliberate stop.
|
|
2247
|
+
userStopped = false;
|
|
2169
2248
|
addMessage('user', prompt);
|
|
2170
2249
|
|
|
2171
2250
|
const ceiling = applyMaxTokensClamp(model);
|
|
@@ -2190,6 +2269,9 @@ async function send() {
|
|
|
2190
2269
|
threadMessages.push({ role: 'user', content: prompt });
|
|
2191
2270
|
|
|
2192
2271
|
setBusy(true, { cancellable: true });
|
|
2272
|
+
// Sending is an explicit act, so it always returns the view to the tail.
|
|
2273
|
+
// This is the single moment the auto-scroll overrides the reader's scroll.
|
|
2274
|
+
stickToBottom({ force: true });
|
|
2193
2275
|
|
|
2194
2276
|
// Persist the user turn locally (best-effort — never blocks chat).
|
|
2195
2277
|
try {
|
|
@@ -2201,24 +2283,38 @@ async function send() {
|
|
|
2201
2283
|
let streamedText = '';
|
|
2202
2284
|
let reasoningText = '';
|
|
2203
2285
|
const toolLog = [];
|
|
2286
|
+
|
|
2287
|
+
// Text arrives in dozens of small chunks per second; painting each one is
|
|
2288
|
+
// what made the window feel locked up. One paint per frame, off the latest
|
|
2289
|
+
// cumulative text.
|
|
2290
|
+
const paintStream = rafPainter(() => {
|
|
2291
|
+
if (!pendingEl) return;
|
|
2292
|
+
pendingEl.classList.remove('pending');
|
|
2293
|
+
if (reasoningText) {
|
|
2294
|
+
const rEl = ensureReasoningEl(pendingEl);
|
|
2295
|
+
if (rEl.textContent !== reasoningText) rEl.textContent = reasoningText;
|
|
2296
|
+
}
|
|
2297
|
+
const bodyEl = pendingEl.querySelector('.body');
|
|
2298
|
+
if (bodyEl && bodyEl.textContent !== streamedText) bodyEl.textContent = streamedText;
|
|
2299
|
+
stickToBottom();
|
|
2300
|
+
});
|
|
2301
|
+
|
|
2204
2302
|
const onDelta = (chunk) => {
|
|
2205
2303
|
// Extended-reasoning trace from a pooled brain turn: the fan-out's worker
|
|
2206
2304
|
// findings, streamed before the synthesis pass writes the answer. Shown so
|
|
2207
2305
|
// "work autonomously" doesn't look idle for the whole worker phase.
|
|
2208
2306
|
if (chunk && typeof chunk.reasoning === 'string' && chunk.reasoning) {
|
|
2209
2307
|
reasoningText += chunk.reasoning;
|
|
2210
|
-
|
|
2211
|
-
pendingEl.classList.remove('pending');
|
|
2212
|
-
ensureReasoningEl(pendingEl).textContent = reasoningText;
|
|
2213
|
-
els.messages.scrollTop = els.messages.scrollHeight;
|
|
2214
|
-
}
|
|
2308
|
+
paintStream();
|
|
2215
2309
|
return;
|
|
2216
2310
|
}
|
|
2217
2311
|
if (chunk && chunk.approval) {
|
|
2218
2312
|
if (pendingEl) {
|
|
2219
2313
|
pendingEl.classList.remove('pending');
|
|
2220
2314
|
renderApprovalCard(pendingEl, chunk.approval, '.body');
|
|
2221
|
-
|
|
2315
|
+
// Forced on purpose: the turn is blocked until this is answered, so
|
|
2316
|
+
// the card has to be brought into view even if the reader scrolled up.
|
|
2317
|
+
stickToBottom({ force: true });
|
|
2222
2318
|
}
|
|
2223
2319
|
return;
|
|
2224
2320
|
}
|
|
@@ -2227,7 +2323,7 @@ async function send() {
|
|
|
2227
2323
|
if (pendingEl) {
|
|
2228
2324
|
pendingEl.classList.remove('pending');
|
|
2229
2325
|
appendToolActivity(pendingEl, chunk.tool, 'tool-activity', '.body');
|
|
2230
|
-
|
|
2326
|
+
stickToBottom();
|
|
2231
2327
|
}
|
|
2232
2328
|
return;
|
|
2233
2329
|
}
|
|
@@ -2235,11 +2331,7 @@ async function send() {
|
|
|
2235
2331
|
chunk && (typeof chunk.delta === 'string' ? chunk.delta : chunk.content);
|
|
2236
2332
|
if (!delta) return;
|
|
2237
2333
|
streamedText += delta;
|
|
2238
|
-
|
|
2239
|
-
pendingEl.classList.remove('pending');
|
|
2240
|
-
const bodyEl = pendingEl.querySelector('.body');
|
|
2241
|
-
if (bodyEl) bodyEl.textContent = streamedText;
|
|
2242
|
-
els.messages.scrollTop = els.messages.scrollHeight;
|
|
2334
|
+
paintStream();
|
|
2243
2335
|
};
|
|
2244
2336
|
|
|
2245
2337
|
try {
|
|
@@ -2261,9 +2353,8 @@ async function send() {
|
|
|
2261
2353
|
else if (model) bits.push(`model: ${model}`);
|
|
2262
2354
|
bits.push(classLabel(cls));
|
|
2263
2355
|
if (autonomous) bits.push(`autonomous (${effort}, ${workers || 3}w)`);
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
}
|
|
2356
|
+
const turnTokens = usageTokens(data && data.usage);
|
|
2357
|
+
if (turnTokens != null) bits.push(`tokens: ${turnTokens}`);
|
|
2267
2358
|
addMessage('assistant', text, bits.join(' · ') || undefined, sessionId, toolLog);
|
|
2268
2359
|
|
|
2269
2360
|
try {
|
|
@@ -2279,11 +2370,27 @@ async function send() {
|
|
|
2279
2370
|
addFlowLane({ prompt, cls, model, maxTokens, parentSessionId: sessionId });
|
|
2280
2371
|
}
|
|
2281
2372
|
} catch (err) {
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2373
|
+
// A stop is not a failure. The transport rethrows on abort — the SSE read
|
|
2374
|
+
// rejects and the loop re-raises — so the naive path here would discard
|
|
2375
|
+
// everything already streamed and answer with a red "aborted" error,
|
|
2376
|
+
// destroying the partial reply at the exact moment the user asked to keep
|
|
2377
|
+
// it. Salvage the partial turn and label it honestly instead.
|
|
2378
|
+
if (isCancellation(err, { userStopped })) {
|
|
2379
|
+
const text = streamedText || reasoningText || '(stopped before any output)';
|
|
2380
|
+
threadMessages.push({ role: 'assistant', content: text });
|
|
2381
|
+
addMessage('assistant', text, 'stopped by you', sessionId, toolLog);
|
|
2382
|
+
try {
|
|
2383
|
+
await sync.append(sessionId, { role: 'assistant', content: text });
|
|
2384
|
+
} catch {
|
|
2385
|
+
/* persistence is non-fatal */
|
|
2386
|
+
}
|
|
2387
|
+
} else {
|
|
2388
|
+
addMessage(
|
|
2389
|
+
'assistant',
|
|
2390
|
+
`Error: ${err && err.message ? err.message : err}`,
|
|
2391
|
+
'request failed'
|
|
2392
|
+
);
|
|
2393
|
+
}
|
|
2287
2394
|
} finally {
|
|
2288
2395
|
setBusy(false);
|
|
2289
2396
|
pendingSessionId = null;
|
|
@@ -2452,8 +2559,37 @@ async function init() {
|
|
|
2452
2559
|
renderMemoryOverlay();
|
|
2453
2560
|
});
|
|
2454
2561
|
}
|
|
2455
|
-
|
|
2456
|
-
|
|
2562
|
+
// The transcript's scroll/paint/Escape policy. Both listeners are registered
|
|
2563
|
+
// inside transcript-view.js so the behaviours they enforce are the ones
|
|
2564
|
+
// test/renderer-dom.test.mjs drives: the passive `scroll` listener is the
|
|
2565
|
+
// reader's veto over the streaming auto-scroll (without it the transcript
|
|
2566
|
+
// stays pinned to the tail no matter how far up you read while a model is
|
|
2567
|
+
// working), and the `keydown` listener is Escape-as-interrupt (the keyboard
|
|
2568
|
+
// twin of the cancel button, for the window that is too busy to aim at it).
|
|
2569
|
+
transcript = createTranscriptView({
|
|
2570
|
+
messages: els.messages,
|
|
2571
|
+
requestFrame: (fn) => requestAnimationFrame(fn),
|
|
2572
|
+
});
|
|
2573
|
+
transcript.attachScrollVeto();
|
|
2574
|
+
// Read-only diagnostic surface for the headless smoke run
|
|
2575
|
+
// (test/electron-smoke.mjs). Everything in this file lives inside the IIFE,
|
|
2576
|
+
// so an injected script cannot otherwise see the scroll veto — the Phase 9
|
|
2577
|
+
// harness read `transcript.isScrolledUp()` directly and silently got `null`,
|
|
2578
|
+
// which made its veto assertion unfalsifiable. Exposes state only: no
|
|
2579
|
+
// setters, nothing that can drive the UI. Frozen so a stray write in a test
|
|
2580
|
+
// cannot fake a passing run.
|
|
2581
|
+
window.__aegisSmoke = Object.freeze({
|
|
2582
|
+
isScrolledUp: () => transcript.isScrolledUp(),
|
|
2583
|
+
metrics: () => transcript.metrics(),
|
|
2584
|
+
});
|
|
2585
|
+
bindEscapeInterrupt({
|
|
2586
|
+
doc: document,
|
|
2587
|
+
// The memory overlay wins: while it is open, Escape closes it rather than
|
|
2588
|
+
// reaching past it to cancel a turn the user may not be looking at.
|
|
2589
|
+
isOverlayOpen: overlayOpen,
|
|
2590
|
+
onOverlayEscape: closeMemoryOverlay,
|
|
2591
|
+
hasPendingTurn: () => !!pendingSessionId,
|
|
2592
|
+
stopTurn: stopPendingTurn,
|
|
2457
2593
|
});
|
|
2458
2594
|
|
|
2459
2595
|
// Auto-update banner: `?`-guarded like the memory inspector above, since
|
package/renderer/index.html
CHANGED
|
@@ -329,6 +329,9 @@
|
|
|
329
329
|
</div>
|
|
330
330
|
|
|
331
331
|
<script src="max-tokens.js"></script>
|
|
332
|
+
<script src="usage.js"></script>
|
|
333
|
+
<script src="stream-policy.js"></script>
|
|
334
|
+
<script src="transcript-view.js"></script>
|
|
332
335
|
<script src="vendor/marked.umd.js"></script>
|
|
333
336
|
<script src="vendor/purify.min.js"></script>
|
|
334
337
|
<script src="vendor/aegis-highlight.js"></script>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure decisions behind a live streaming turn: whether the transcript is
|
|
5
|
+
* allowed to follow the stream, and whether a rejection means the user asked
|
|
6
|
+
* to stop. Kept out of app.js — like max-tokens.js — so both rules are
|
|
7
|
+
* unit-testable from plain Node without a DOM or window.aegis. app.js only
|
|
8
|
+
* calls into this.
|
|
9
|
+
*
|
|
10
|
+
* Both rules exist because a running turn used to own the transcript: it
|
|
11
|
+
* re-pinned the view on every chunk (so earlier turns could not be read) and
|
|
12
|
+
* it reported a deliberate stop as a failure (so interrupting lost the answer
|
|
13
|
+
* already on screen).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Distance from the bottom still counted as "following the stream". */
|
|
17
|
+
const STICK_SLOP_PX = 48;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Is the view at (or effectively at) the tail? A few pixels of slack absorbs
|
|
21
|
+
* sub-pixel layout and a scrollbar that appears mid-stream; without it the
|
|
22
|
+
* follow silently stops a fraction short.
|
|
23
|
+
*/
|
|
24
|
+
function nearBottom(metrics, slop) {
|
|
25
|
+
const m = metrics || {};
|
|
26
|
+
const budget = Number.isFinite(slop) ? slop : STICK_SLOP_PX;
|
|
27
|
+
const scrollHeight = Number(m.scrollHeight) || 0;
|
|
28
|
+
const scrollTop = Number(m.scrollTop) || 0;
|
|
29
|
+
const clientHeight = Number(m.clientHeight) || 0;
|
|
30
|
+
return scrollHeight - scrollTop - clientHeight <= budget;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* May the transcript be scrolled to the bottom right now? `force` is for the
|
|
35
|
+
* cases where the view genuinely must move (the user just sent, or a card is
|
|
36
|
+
* blocking the turn); otherwise the reader's explicit scroll-away wins.
|
|
37
|
+
*
|
|
38
|
+
* The flag is checked before the measurement on purpose: a mid-stream reflow
|
|
39
|
+
* can momentarily measure as "at the bottom" while the reader is nowhere near
|
|
40
|
+
* it, which is exactly the case where re-pinning feels like a hijack.
|
|
41
|
+
*/
|
|
42
|
+
function shouldFollow(metrics, opts) {
|
|
43
|
+
const o = opts || {};
|
|
44
|
+
if (o.force) return true;
|
|
45
|
+
if (o.userScrolledUp) return false;
|
|
46
|
+
return nearBottom(metrics, o.slop);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Signatures of a real abort, as each layer spells it: Chromium's fetch
|
|
51
|
+
* ("The user aborted a request."), undici/Node ("This operation was aborted"),
|
|
52
|
+
* and an AbortSignal's own reason ("signal is aborted without reason").
|
|
53
|
+
*
|
|
54
|
+
* Deliberately NOT a bare /abort/i. A dropped socket surfaces as
|
|
55
|
+
* "ECONNABORTED: connection aborted by peer" or "socket hang up", which
|
|
56
|
+
* contains the same word but is a genuine transport failure — matching it
|
|
57
|
+
* would relabel a real error as a deliberate stop and strand the user with a
|
|
58
|
+
* silently truncated answer.
|
|
59
|
+
*/
|
|
60
|
+
const ABORT_SIGNATURES = [
|
|
61
|
+
/AbortError/,
|
|
62
|
+
/\boperation was aborted\b/i,
|
|
63
|
+
/\buser aborted\b/i,
|
|
64
|
+
/\brequest\s+aborted\b/i,
|
|
65
|
+
/\bsignal is aborted\b/i,
|
|
66
|
+
/\baborted without reason\b/i,
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Did the user ask for this turn to stop? The abort travels back over IPC,
|
|
71
|
+
* which rebuilds the Error object and drops `name`/`code` — the renderer only
|
|
72
|
+
* sees a wrapped message. So the caller's own flag is the primary signal, and
|
|
73
|
+
* the signatures above are a backstop for an abort this renderer did not
|
|
74
|
+
* initiate.
|
|
75
|
+
*/
|
|
76
|
+
function isCancellation(err, opts) {
|
|
77
|
+
const o = opts || {};
|
|
78
|
+
if (o.userStopped) return true;
|
|
79
|
+
if (!err) return false;
|
|
80
|
+
if (err.name === 'AbortError' || err.code === 'ABORT_ERR') return true;
|
|
81
|
+
const msg = typeof err === 'string' ? err : err.message;
|
|
82
|
+
if (typeof msg !== 'string') return false;
|
|
83
|
+
return ABORT_SIGNATURES.some((re) => re.test(msg));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
87
|
+
module.exports = { nearBottom, shouldFollow, isCancellation, STICK_SLOP_PX };
|
|
88
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The DOM-touching half of the transcript policy (plan Phase 8), extracted
|
|
5
|
+
* from app.js so the two symptoms this work exists to prevent can be asserted
|
|
6
|
+
* *behaviourally* — not just as pure math in stream-policy.js:
|
|
7
|
+
*
|
|
8
|
+
* - the transcript snapping to the bottom while you read → `follow()` asks
|
|
9
|
+
* stream-policy's `shouldFollow()` and the reader's own scroll sets the
|
|
10
|
+
* veto through `attachScrollVeto()`;
|
|
11
|
+
* - Escape failing to interrupt → `bindEscapeInterrupt()` is the one place
|
|
12
|
+
* the keyboard listener is registered.
|
|
13
|
+
*
|
|
14
|
+
* Why extract instead of testing app.js itself: app.js boots only under the
|
|
15
|
+
* Electron host (`window.aegis`/`window.models`) and builds a 2000-line UI, so
|
|
16
|
+
* a test that drives it would assert on a mock of everything. Everything here
|
|
17
|
+
* touches exactly four DOM surfaces — three numbers on the transcript element
|
|
18
|
+
* (scrollHeight/scrollTop/clientHeight), one passive `scroll` listener, one
|
|
19
|
+
* `keydown` listener on the document, and `requestAnimationFrame` — so
|
|
20
|
+
* test/renderer-dom.test.mjs drives the real file with a 60-line fake DOM and
|
|
21
|
+
* no third-party dependency. app.js only calls into this.
|
|
22
|
+
*
|
|
23
|
+
* Loaded as a classic script (index.html) *before* app.js, and requireable
|
|
24
|
+
* from Node like max-tokens.js/stream-policy.js.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// The decisions stay in stream-policy.js — one definition of "at the tail" and
|
|
28
|
+
// of "the reader has the veto". Classic script: resolve the globals declared by
|
|
29
|
+
// the sibling script tag; CommonJS: resolve the module. Eager, so a missing
|
|
30
|
+
// stream-policy.js script tag fails at load instead of on the first paint.
|
|
31
|
+
const policy =
|
|
32
|
+
typeof module !== 'undefined' && module.exports
|
|
33
|
+
? require('./stream-policy.js')
|
|
34
|
+
: { nearBottom: nearBottom, shouldFollow: shouldFollow };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Coalescing paint + follow-the-tail state for the one transcript element.
|
|
38
|
+
*
|
|
39
|
+
* `messages` is the transcript (`#messages`); `requestFrame` defaults to the
|
|
40
|
+
* window's `requestAnimationFrame` and is injectable so a test can flush
|
|
41
|
+
* frames deterministically.
|
|
42
|
+
*/
|
|
43
|
+
function createTranscriptView(deps) {
|
|
44
|
+
const d = deps || {};
|
|
45
|
+
const messages = d.messages;
|
|
46
|
+
const requestFrame =
|
|
47
|
+
typeof d.requestFrame === 'function'
|
|
48
|
+
? d.requestFrame
|
|
49
|
+
: typeof requestAnimationFrame === 'function'
|
|
50
|
+
? requestAnimationFrame
|
|
51
|
+
: (fn) => fn();
|
|
52
|
+
|
|
53
|
+
/** Set while the reader has deliberately scrolled away from the tail. */
|
|
54
|
+
let scrolledUp = false;
|
|
55
|
+
|
|
56
|
+
/** Current transcript scroll metrics, or null when there is no transcript. */
|
|
57
|
+
function metrics() {
|
|
58
|
+
if (!messages) return null;
|
|
59
|
+
return {
|
|
60
|
+
scrollHeight: messages.scrollHeight,
|
|
61
|
+
scrollTop: messages.scrollTop,
|
|
62
|
+
clientHeight: messages.clientHeight,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The reader's veto over the streaming auto-scroll. This is the passive
|
|
68
|
+
* `scroll` listener: without it `scrolledUp` can never become true, leaving
|
|
69
|
+
* the transcript pinned to the tail no matter how far up you read while a
|
|
70
|
+
* model is working. Fires on every scroll frame, so it only records position.
|
|
71
|
+
*/
|
|
72
|
+
function noteScroll() {
|
|
73
|
+
scrolledUp = !policy.nearBottom(metrics());
|
|
74
|
+
return scrolledUp;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function attachScrollVeto(el) {
|
|
78
|
+
const target = el || messages;
|
|
79
|
+
if (!target || typeof target.addEventListener !== 'function') return false;
|
|
80
|
+
target.addEventListener('scroll', noteScroll, { passive: true });
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Follow the tail only while the reader is still there (was
|
|
86
|
+
* `stickToBottom`). A streaming turn must never yank the view back down once
|
|
87
|
+
* someone has scrolled up to read — that was why the transcript felt
|
|
88
|
+
* unscrollable while a model was working. `force` is for the cases where the
|
|
89
|
+
* view genuinely must follow: a message the user just sent, or a card they
|
|
90
|
+
* just opened. Returns whether the view moved.
|
|
91
|
+
*/
|
|
92
|
+
function follow(opts) {
|
|
93
|
+
if (!messages) return false;
|
|
94
|
+
if (!policy.shouldFollow(metrics(), { force: opts && opts.force, userScrolledUp: scrolledUp })) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
if (opts && opts.force) scrolledUp = false;
|
|
98
|
+
messages.scrollTop = messages.scrollHeight;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Coalesce high-frequency stream updates to one paint per frame (was
|
|
104
|
+
* `rafPainter`). A cloud brain fan-out emits dozens of chunks a second, and
|
|
105
|
+
* each repaint read scrollHeight (forcing a synchronous layout) — that
|
|
106
|
+
* thrash is what made the window feel frozen mid-turn. Only the latest
|
|
107
|
+
* payload is painted; dropped frames are invisible because the text is
|
|
108
|
+
* cumulative. The returned function is idempotent within a frame.
|
|
109
|
+
*/
|
|
110
|
+
function paint(fn) {
|
|
111
|
+
let queued = false;
|
|
112
|
+
return function schedule() {
|
|
113
|
+
if (queued) return;
|
|
114
|
+
queued = true;
|
|
115
|
+
requestFrame(() => {
|
|
116
|
+
queued = false;
|
|
117
|
+
fn();
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
metrics: metrics,
|
|
124
|
+
noteScroll: noteScroll,
|
|
125
|
+
attachScrollVeto: attachScrollVeto,
|
|
126
|
+
follow: follow,
|
|
127
|
+
paint: paint,
|
|
128
|
+
isScrolledUp: () => scrolledUp,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Escape → the running turn's interrupt (the keyboard twin of the cancel
|
|
134
|
+
* button, for the window that is too busy to aim at it).
|
|
135
|
+
*
|
|
136
|
+
* Deliberately the only `keydown` registration: the memory overlay wins while
|
|
137
|
+
* it is open — Escape closes it rather than reaching past it to cancel a turn
|
|
138
|
+
* the user may not be looking at — and Escape does nothing at all when no turn
|
|
139
|
+
* is pending, so the key never becomes a surprise.
|
|
140
|
+
*
|
|
141
|
+
* Returns the handler plus an `unbind()` for symmetry with the listener it
|
|
142
|
+
* owns; the return value is also what lets a test call the decision directly.
|
|
143
|
+
*/
|
|
144
|
+
function bindEscapeInterrupt(deps) {
|
|
145
|
+
const d = deps || {};
|
|
146
|
+
|
|
147
|
+
function handle(e) {
|
|
148
|
+
if (!e || e.key !== 'Escape') return false;
|
|
149
|
+
if (typeof d.isOverlayOpen === 'function' && d.isOverlayOpen()) {
|
|
150
|
+
if (typeof d.onOverlayEscape === 'function') d.onOverlayEscape();
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
if (typeof d.hasPendingTurn === 'function' && !d.hasPendingTurn()) return false;
|
|
154
|
+
if (typeof d.stopTurn !== 'function') return false;
|
|
155
|
+
if (typeof e.preventDefault === 'function') e.preventDefault();
|
|
156
|
+
d.stopTurn();
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (d.doc && typeof d.doc.addEventListener === 'function') {
|
|
161
|
+
d.doc.addEventListener('keydown', handle);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
handle: handle,
|
|
166
|
+
unbind: () => {
|
|
167
|
+
if (d.doc && typeof d.doc.removeEventListener === 'function') {
|
|
168
|
+
d.doc.removeEventListener('keydown', handle);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
175
|
+
module.exports = { createTranscriptView, bindEscapeInterrupt };
|
|
176
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure token-usage → display-number mapping.
|
|
5
|
+
*
|
|
6
|
+
* Standalone from app.js (same reason as max-tokens.js/stream-policy.js): it is
|
|
7
|
+
* requireable from a plain Node test without window.aegis. app.js only calls
|
|
8
|
+
* into it.
|
|
9
|
+
*
|
|
10
|
+
* Why this exists at all: the two wire formats spell the same quantity
|
|
11
|
+
* differently, and the renderer used to read exactly one of the spellings.
|
|
12
|
+
*
|
|
13
|
+
* OpenAI-compatible { prompt_tokens, completion_tokens, total_tokens }
|
|
14
|
+
* Anthropic-compatible { input_tokens, output_tokens } ← no total
|
|
15
|
+
*
|
|
16
|
+
* Reading only `total_tokens` — the field OpenAI happens to provide — meant
|
|
17
|
+
* every Anthropic-compatible model rendered with no token count at all, while
|
|
18
|
+
* the call was silently being billed. Accepting both spellings, and deriving
|
|
19
|
+
* the total when the provider doesn't state one, is what makes the spend
|
|
20
|
+
* visible regardless of which endpoint answered.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Number of tokens to show for a completed turn, or `null` when the provider
|
|
25
|
+
* reported none (an unknown count must render as nothing, never as `0`).
|
|
26
|
+
*
|
|
27
|
+
* @param {{total_tokens?: number, prompt_tokens?: number, completion_tokens?: number,
|
|
28
|
+
* input_tokens?: number, output_tokens?: number}|null|undefined} usage
|
|
29
|
+
* @returns {number|null}
|
|
30
|
+
*/
|
|
31
|
+
function usageTokens(usage) {
|
|
32
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
33
|
+
if (typeof usage.total_tokens === 'number') return usage.total_tokens;
|
|
34
|
+
const input = usage.input_tokens ?? usage.prompt_tokens;
|
|
35
|
+
const output = usage.output_tokens ?? usage.completion_tokens;
|
|
36
|
+
if (typeof input !== 'number' && typeof output !== 'number') return null;
|
|
37
|
+
return (input || 0) + (output || 0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
41
|
+
module.exports = { usageTokens };
|
|
42
|
+
}
|
package/vendor/aegis.js
CHANGED
|
@@ -290,6 +290,7 @@ function createClient(opts = {}) {
|
|
|
290
290
|
onStream,
|
|
291
291
|
onReasoning,
|
|
292
292
|
idleTimeoutMs,
|
|
293
|
+
includeUsage,
|
|
293
294
|
signal,
|
|
294
295
|
extra,
|
|
295
296
|
} = {}) {
|
|
@@ -307,9 +308,22 @@ function createClient(opts = {}) {
|
|
|
307
308
|
if (!stream || typeof onStream !== 'function') {
|
|
308
309
|
return apiPost('/api/v1/chat/completions', { ...body, stream: false });
|
|
309
310
|
}
|
|
311
|
+
// An OpenAI-compatible SSE response carries no `usage` unless the caller
|
|
312
|
+
// asks for it (`stream_options.include_usage`). Without this the streamed
|
|
313
|
+
// AEGIS pool call — the only path the desktop uses for the Nexus brain —
|
|
314
|
+
// resolved with no usage at all, so a pooled turn could report its text but
|
|
315
|
+
// never what it spent, while the same model through the non-streaming MCP
|
|
316
|
+
// path reported both. Only the streaming request sets it: `stream_options`
|
|
317
|
+
// is invalid alongside `stream:false`, so the non-stream branch above and
|
|
318
|
+
// every non-stream fallback must stay clean.
|
|
319
|
+
if (includeUsage) body.stream_options = { include_usage: true };
|
|
310
320
|
return postStream('/api/v1/chat/completions', body, authHeaders(), onStream, signal, {
|
|
311
321
|
onReasoning,
|
|
312
322
|
idleTimeoutMs,
|
|
323
|
+
// A server that predates `stream_options` 400s the whole request; the
|
|
324
|
+
// stream is worth more than the token count, so retry on the wire without
|
|
325
|
+
// the hint before sacrificing streaming for the non-stream fallback.
|
|
326
|
+
retryWithoutStreamOptions: Boolean(includeUsage),
|
|
313
327
|
});
|
|
314
328
|
}
|
|
315
329
|
|
|
@@ -370,23 +384,49 @@ function createClient(opts = {}) {
|
|
|
370
384
|
* This keeps streaming purely additive for hosts that opt in.
|
|
371
385
|
*/
|
|
372
386
|
async function postStream(path, body, headers, onStream, signal, streamOpts) {
|
|
373
|
-
const { onReasoning, idleTimeoutMs } = streamOpts || {};
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
res = await fetch(`${apiBase}${path}`, {
|
|
387
|
+
const { onReasoning, idleTimeoutMs, retryWithoutStreamOptions } = streamOpts || {};
|
|
388
|
+
const request = (payload) =>
|
|
389
|
+
fetch(`${apiBase}${path}`, {
|
|
377
390
|
method: 'POST',
|
|
378
391
|
headers,
|
|
379
|
-
body: JSON.stringify({ ...
|
|
392
|
+
body: JSON.stringify({ ...payload, stream: true }),
|
|
380
393
|
signal,
|
|
381
394
|
});
|
|
395
|
+
let res;
|
|
396
|
+
try {
|
|
397
|
+
res = await request(body);
|
|
382
398
|
} catch (err) {
|
|
383
399
|
throw err; // network-level failure; nothing to fall back to
|
|
384
400
|
}
|
|
385
401
|
|
|
402
|
+
// Ask for the token count, but never at the cost of the stream: a server
|
|
403
|
+
// that predates `stream_options` rejects the request outright, so drop that
|
|
404
|
+
// one hint and put it back on the wire before falling back to a non-stream
|
|
405
|
+
// response (which would answer in one lump and end the live paint).
|
|
406
|
+
if (!res.ok && retryWithoutStreamOptions && body.stream_options) {
|
|
407
|
+
try {
|
|
408
|
+
if (res.body) await res.body.cancel();
|
|
409
|
+
} catch {
|
|
410
|
+
/* the failed response is being discarded anyway */
|
|
411
|
+
}
|
|
412
|
+
const cleanBody = { ...body };
|
|
413
|
+
delete cleanBody.stream_options;
|
|
414
|
+
body = cleanBody;
|
|
415
|
+
try {
|
|
416
|
+
res = await request(body);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
throw err;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
386
422
|
if (!res.ok) {
|
|
387
423
|
// The endpoint may not accept `stream: true`. Retry once without it so
|
|
388
|
-
// the caller gets the normal structured JSON (result or error).
|
|
389
|
-
|
|
424
|
+
// the caller gets the normal structured JSON (result or error). Strip
|
|
425
|
+
// `stream_options` too — it is only legal with `stream: true`, so leaving
|
|
426
|
+
// it on would turn this graceful fallback into a second rejection.
|
|
427
|
+
const cleanBody = { ...body };
|
|
428
|
+
delete cleanBody.stream_options;
|
|
429
|
+
const data = await apiPost(path, { ...cleanBody, stream: false }, headers);
|
|
390
430
|
const fullText = textOf(data);
|
|
391
431
|
if (fullText) onStream({ delta: fullText });
|
|
392
432
|
return data;
|