aegis-desktop 0.8.35 → 1.0.0
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 +11 -0
- package/lib/deep-link.js +35 -1
- package/lib/local/autonomous.js +81 -0
- package/lib/local/engine.js +334 -12
- package/main.js +224 -6
- package/package.json +1 -1
- package/preload.js +12 -0
- package/renderer/app.js +233 -0
- package/renderer/style.css +14 -0
- package/renderer/usage.js +68 -1
- package/vendor/aegis.js +22 -0
- package/vendor/byok-catalog.js +43 -8
- package/vendor/user-models.js +378 -0
package/main.js
CHANGED
|
@@ -182,6 +182,14 @@ const localTools = require('./lib/local/tools.js');
|
|
|
182
182
|
// the `queue:` IPC surface registered by registerQueueIpc() below.
|
|
183
183
|
const queueModule = require('./lib/local/queue.js');
|
|
184
184
|
const autonomousModule = require('./lib/local/autonomous.js');
|
|
185
|
+
// Auto mode's round-horizon half: the same arithmetic the upstream engine
|
|
186
|
+
// (aegiscodex-dev src/autonomous.js) and the CLI's presenter use, read here by
|
|
187
|
+
// the one host that owns this face's policy. See createModelDispatch.
|
|
188
|
+
const {
|
|
189
|
+
autoContinuePlan,
|
|
190
|
+
autoContinueLimit,
|
|
191
|
+
autoRoundsDefault,
|
|
192
|
+
} = require('./lib/local/autonomous.js');
|
|
185
193
|
|
|
186
194
|
// The Pro boundary for the autonomous queue (Phase 4 of
|
|
187
195
|
// docs/upgrade-conversion-plan.md). MAIN-process only, no Electron inside, so
|
|
@@ -1078,7 +1086,34 @@ function createConfirmModeDispatch(settings) {
|
|
|
1078
1086
|
};
|
|
1079
1087
|
}
|
|
1080
1088
|
|
|
1081
|
-
/**
|
|
1089
|
+
/**
|
|
1090
|
+
* Auto mode's other switch: a posture of "don't ask" IS auto mode on this face,
|
|
1091
|
+
* so an explicit setConfirmMode must move the model dispatch's session state
|
|
1092
|
+
* with it. Otherwise the two halves of the mode can be told different things —
|
|
1093
|
+
* the engine stops asking, the round horizon keeps raising its card, and the
|
|
1094
|
+
* chip in the composer claims auto mode is on.
|
|
1095
|
+
*
|
|
1096
|
+
* OFF starts auto mode for this sitting (`setAutoRounds(true)`, which also
|
|
1097
|
+
* retires a boot-time AEGIS_AUTO_ROUNDS=0), ON ends it. A malformed payload
|
|
1098
|
+
* changes nothing, exactly as in createConfirmModeDispatch — including the
|
|
1099
|
+
* session state, so the two cannot drift on a bad message.
|
|
1100
|
+
*/
|
|
1101
|
+
function withAutoRoundsSwitch(confirmDispatch, modelDispatch) {
|
|
1102
|
+
const setConfirmMode = confirmDispatch.setConfirmMode;
|
|
1103
|
+
return {
|
|
1104
|
+
...confirmDispatch,
|
|
1105
|
+
setConfirmMode: (payload) => {
|
|
1106
|
+
const res = setConfirmMode(payload);
|
|
1107
|
+
if (payload && typeof payload.enabled === 'boolean' && modelDispatch && typeof modelDispatch.setAutoRounds === 'function') {
|
|
1108
|
+
modelDispatch.setAutoRounds(payload.enabled === false);
|
|
1109
|
+
}
|
|
1110
|
+
return res;
|
|
1111
|
+
},
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Register getConfirmMode/setConfirmMode as `aegis:<name>` on ipcMain —
|
|
1082
1117
|
* the same ${IPC_PREFIX}<method> convention registerIpc uses for the rest of
|
|
1083
1118
|
* the renderer's aegis.* bridge (their handlers need nothing but the
|
|
1084
1119
|
* payload, so the event is dropped exactly like every non-chatCompletion
|
|
@@ -1398,11 +1433,149 @@ function readTextFileForPreview(payload) {
|
|
|
1398
1433
|
/**
|
|
1399
1434
|
* Pure mapping: model:<name> -> LocalEngine call (unit-testable in Node).
|
|
1400
1435
|
*/
|
|
1401
|
-
function createModelDispatch(engine) {
|
|
1402
|
-
|
|
1436
|
+
function createModelDispatch(engine, { env = process.env, settings } = {}) {
|
|
1437
|
+
// ── Auto mode's second half: the round horizon ────────────────────────────
|
|
1438
|
+
//
|
|
1439
|
+
// autoContinuePlan/autoContinueLimit (lib/local/autonomous.js) answer "may
|
|
1440
|
+
// this horizon ask be answered for the user?" — the arithmetic the engine
|
|
1441
|
+
// defines in src/autonomous.js and the ONLY thing separating auto mode in
|
|
1442
|
+
// aegiscodex-dev from auto mode here. The desktop wired the first half (no
|
|
1443
|
+
// approval cards, via the Tool-approvals switch) and nothing at all for this
|
|
1444
|
+
// one, so a desktop turn that hit its round horizon raised the blocking card
|
|
1445
|
+
// on the first hit and every hit after it. That is the exact question auto
|
|
1446
|
+
// mode exists to stop asking: same answer, same click, once per horizon.
|
|
1447
|
+
//
|
|
1448
|
+
// The policy lives HERE rather than in the engine on purpose — the engine's
|
|
1449
|
+
// own comment states the contract ("the hook is optional and the caller owns
|
|
1450
|
+
// the policy"), so the engine keeps asking and each host decides. This host's
|
|
1451
|
+
// answer is the posture the user already set in one place: confirmations OFF
|
|
1452
|
+
// (the same boolean behind the settings switch and the composer's "auto mode"
|
|
1453
|
+
// chip) means auto mode is on, and the tally is per session because that is
|
|
1454
|
+
// the lifetime of the turn it is funding.
|
|
1455
|
+
const autoUsed = new Map(); // sessionId -> automatic continuations spent
|
|
1456
|
+
|
|
1457
|
+
// Auto mode's session START state — the engine's AEGIS_AUTO_ROUNDS=0 opt-out
|
|
1458
|
+
// (src/autonomous.js autoRoundsDefault), read once here rather than on every
|
|
1459
|
+
// call. Re-derived per call it is a VETO, not a start state: with the variable
|
|
1460
|
+
// exported in the environment that launched the app, the Tool-approvals switch
|
|
1461
|
+
// and any `/automode on` from the CLI sharing the store would write the
|
|
1462
|
+
// posture and change nothing — the chip lit, the round card still every
|
|
1463
|
+
// horizon, and no way back to auto mode short of restarting the app. The
|
|
1464
|
+
// engine's own contract is the other one: the env var picks where the session
|
|
1465
|
+
// STARTS, and the toggle moves it freely from there.
|
|
1466
|
+
//
|
|
1467
|
+
// The opt-out is retired by an explicit act, of which there are two here:
|
|
1468
|
+
// setAutoRounds (the switch and the IPC, wired at bootstrap) and a stored
|
|
1469
|
+
// posture that differs from the one this process booted with (a flip made by
|
|
1470
|
+
// another face — the CLI's `/automode` writes the same key).
|
|
1471
|
+
let autoRoundsSession = autoRoundsDefault(env);
|
|
1472
|
+
let bootPosture;
|
|
1473
|
+
let autoRoundsRetired = false;
|
|
1474
|
+
|
|
1475
|
+
/** True only when auto mode is on: the session state AND a posture that says
|
|
1476
|
+
* don't ask. Read per call, never captured: the settings
|
|
1477
|
+
* switch and `/automode` in the CLI both write the same shared key, so a
|
|
1478
|
+
* flip takes effect on the next horizon without a restart.
|
|
1479
|
+
*
|
|
1480
|
+
* `undefined` from every accessor means "this store cannot answer" — and an
|
|
1481
|
+
* unknown posture must NOT be read as auto mode ON: silently extending turns
|
|
1482
|
+
* (and spending billed rounds) on a store we cannot read is the worse
|
|
1483
|
+
* failure, and the pre-change behaviour (show the card) is the safe default.
|
|
1484
|
+
* AEGIS_AUTO_ROUNDS=0 turns it off session-wide, exactly as in the engine. */
|
|
1485
|
+
const autoRoundsOn = () => {
|
|
1486
|
+
const store = settings || (engine && engine.settings) || null;
|
|
1487
|
+
const bool = (v) => (typeof v === 'boolean' ? v : undefined);
|
|
1488
|
+
let posture;
|
|
1489
|
+
try {
|
|
1490
|
+
if (store && typeof store.confirmModeState === 'function') {
|
|
1491
|
+
const st = store.confirmModeState();
|
|
1492
|
+
posture = bool(st && st.enabled);
|
|
1493
|
+
}
|
|
1494
|
+
if (posture === undefined && store && typeof store.getConfirmMode === 'function') {
|
|
1495
|
+
posture = bool(store.getConfirmMode());
|
|
1496
|
+
}
|
|
1497
|
+
} catch {
|
|
1498
|
+
/* an unreadable store is not auto mode — the card stays up */
|
|
1499
|
+
}
|
|
1500
|
+
// A posture that moved since boot means somebody acted (another face wrote
|
|
1501
|
+
// the same key), so the env opt-out has been answered and no longer applies.
|
|
1502
|
+
if (!autoRoundsRetired && posture !== undefined) {
|
|
1503
|
+
if (bootPosture === undefined) bootPosture = posture;
|
|
1504
|
+
else if (posture !== bootPosture) {
|
|
1505
|
+
autoRoundsRetired = true;
|
|
1506
|
+
autoRoundsSession = true;
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
if (!autoRoundsSession || !store || posture === undefined) return false;
|
|
1510
|
+
return posture === false;
|
|
1511
|
+
};
|
|
1512
|
+
|
|
1513
|
+
/** Move the session state — the switch (via setConfirmMode at bootstrap) and
|
|
1514
|
+
* any explicit auto-mode request. Turns OFF means the operator asked for
|
|
1515
|
+
* manual NOW, so it sticks against the boot-time opt-out in both
|
|
1516
|
+
* directions. */
|
|
1517
|
+
const setAutoRounds = (v) => {
|
|
1518
|
+
autoRoundsSession = Boolean(v);
|
|
1519
|
+
autoRoundsRetired = true;
|
|
1520
|
+
};
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* Wrap the renderer's stream so a round-horizon ask can be answered for the
|
|
1524
|
+
* human while auto mode is on, and forwarded untouched otherwise.
|
|
1525
|
+
*
|
|
1526
|
+
* Three things are load-bearing:
|
|
1527
|
+
*
|
|
1528
|
+
* · the ENGINE is what answers (`respondRoundsAsk`), not a synthetic frame
|
|
1529
|
+
* — the turn is held open on that promise, so the only honest way to skip
|
|
1530
|
+
* the dialog is to resolve it;
|
|
1531
|
+
* · `{ note }` reaches the renderer, because a budget that grew invisibly
|
|
1532
|
+
* is the "why is it still running?" report, and the CLI announces this
|
|
1533
|
+
* same continuation through its transcript note;
|
|
1534
|
+
* · a declined/failed answer FALLS THROUGH to the card. The allowance is
|
|
1535
|
+
* spent, so this is the real ask — the same "then it asks" contract the
|
|
1536
|
+
* CLI and the engine's status line advertise.
|
|
1537
|
+
*/
|
|
1538
|
+
const chatWithAutoRounds = (payload) => {
|
|
1539
|
+
const forward = payload && payload.onStream;
|
|
1540
|
+
if (typeof forward !== 'function') return engine.chat(payload, forward);
|
|
1541
|
+
const sessionId = (payload && payload.sessionId) || '';
|
|
1542
|
+
const onStream = (chunk) => {
|
|
1543
|
+
if (chunk && chunk.rounds && chunk.rounds.id && autoRoundsOn()) {
|
|
1544
|
+
const plan = autoContinuePlan({
|
|
1545
|
+
on: true,
|
|
1546
|
+
used: autoUsed.get(sessionId) || 0,
|
|
1547
|
+
limit: autoContinueLimit(env),
|
|
1548
|
+
});
|
|
1549
|
+
autoUsed.set(sessionId, plan.used);
|
|
1550
|
+
if (plan.auto) {
|
|
1551
|
+
const answered =
|
|
1552
|
+
typeof engine.respondRoundsAsk === 'function'
|
|
1553
|
+
? engine.respondRoundsAsk(chunk.rounds.id, true)
|
|
1554
|
+
: { ok: false };
|
|
1555
|
+
if (answered && answered.ok) {
|
|
1556
|
+
const spent = Number(chunk.rounds.spent) || 0;
|
|
1557
|
+
forward({
|
|
1558
|
+
note:
|
|
1559
|
+
`auto mode: the ${spent}-round limit was reached — continuing automatically ` +
|
|
1560
|
+
`(${plan.used}/${plan.cap}, then it asks).`,
|
|
1561
|
+
});
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
// Not auto (allowance spent, or the engine could not take an answer):
|
|
1566
|
+
// show the real card. plan.used is 0 in the spent case, so the next
|
|
1567
|
+
// horizon starts a fresh block of automatic continuations — the same
|
|
1568
|
+
// reset the CLI's presenter applies once the human is actually asked.
|
|
1569
|
+
}
|
|
1570
|
+
forward(chunk);
|
|
1571
|
+
};
|
|
1572
|
+
return engine.chat(payload, onStream);
|
|
1573
|
+
};
|
|
1574
|
+
|
|
1575
|
+
const dispatch = {
|
|
1403
1576
|
listClasses: () => engine.listClasses(),
|
|
1404
1577
|
listModels: (payload) => engine.listModels(payload && payload.class),
|
|
1405
|
-
chat:
|
|
1578
|
+
chat: chatWithAutoRounds,
|
|
1406
1579
|
// The AEGIS key lives in a reserved namespace the store refuses to expose
|
|
1407
1580
|
// or delete through this surface; the filter is belt-and-braces so a
|
|
1408
1581
|
// mis-wired store can never hand the renderer a removable AEGIS entry.
|
|
@@ -1415,6 +1588,22 @@ function createModelDispatch(engine) {
|
|
|
1415
1588
|
}),
|
|
1416
1589
|
'settings.remove': (payload) =>
|
|
1417
1590
|
engine.settings.remove(payload && payload.provider),
|
|
1591
|
+
// The USER's own model declarations (`client/user-models.js`, the file the
|
|
1592
|
+
// CLI's `/model-add` writes). Settings is the only place a desktop user can
|
|
1593
|
+
// add a model the server's catalog does not name, and until this existed the
|
|
1594
|
+
// only ways were a release or hand-editing a JSON file the app never named.
|
|
1595
|
+
//
|
|
1596
|
+
// These three go through the ENGINE rather than the settings store: the
|
|
1597
|
+
// engine's `listModels('byok')` is what applies them, so the write and the
|
|
1598
|
+
// read share one implementation and cannot disagree about the file. Add and
|
|
1599
|
+
// remove THROW on a malformed provider/model id — the renderer shows the
|
|
1600
|
+
// message, which is the honest outcome for a string that would otherwise be
|
|
1601
|
+
// forwarded to a vendor and 404 there.
|
|
1602
|
+
'userModels.get': () => engine.userModels(),
|
|
1603
|
+
'userModels.add': (payload) =>
|
|
1604
|
+
engine.addUserModel(payload && payload.provider, payload && payload.model),
|
|
1605
|
+
'userModels.remove': (payload) =>
|
|
1606
|
+
engine.removeUserModel(payload && payload.provider, payload && payload.model),
|
|
1418
1607
|
// Persisting memory ("sync finished turns to cloud memory automatically").
|
|
1419
1608
|
// Reserved namespace, so it is invisible to the provider CRUD above — but
|
|
1420
1609
|
// unlike a provider it IS reachable deliberately, through these two. The
|
|
@@ -1449,6 +1638,25 @@ function createModelDispatch(engine) {
|
|
|
1449
1638
|
clearApprovals: (payload) =>
|
|
1450
1639
|
engine.clearSessionApprovals(payload && payload.sessionId),
|
|
1451
1640
|
};
|
|
1641
|
+
|
|
1642
|
+
// Auto mode's session state is read and moved DIRECTLY, never over IPC.
|
|
1643
|
+
// Two reasons, and both are load-bearing:
|
|
1644
|
+
//
|
|
1645
|
+
// · every ENUMERABLE key of this object becomes an `aegis:<name>` channel
|
|
1646
|
+
// (registerModelIpc iterates Object.entries), so exporting these would
|
|
1647
|
+
// hand the renderer a way to move auto mode's round half without moving
|
|
1648
|
+
// the posture — the exact two-answers-one-question drift this file's
|
|
1649
|
+
// dispatch exists to prevent. The renderer's switch reaches both halves
|
|
1650
|
+
// through aegis:setConfirmMode (withAutoRoundsSwitch), which is the one
|
|
1651
|
+
// act that means "auto mode" on this face;
|
|
1652
|
+
//
|
|
1653
|
+
// · a signature change to the bridge has to be visible where the bridge is
|
|
1654
|
+
// described, so the count in test/model-dispatch.mjs stays a real check.
|
|
1655
|
+
Object.defineProperties(dispatch, {
|
|
1656
|
+
autoRoundsOn: { value: autoRoundsOn, enumerable: false },
|
|
1657
|
+
setAutoRounds: { value: setAutoRounds, enumerable: false },
|
|
1658
|
+
});
|
|
1659
|
+
return dispatch;
|
|
1452
1660
|
}
|
|
1453
1661
|
|
|
1454
1662
|
/**
|
|
@@ -3141,7 +3349,7 @@ function bootstrap() {
|
|
|
3141
3349
|
handleReplyFinished,
|
|
3142
3350
|
() => providerRouteConfigured(engine)
|
|
3143
3351
|
);
|
|
3144
|
-
registerModelIpc(
|
|
3352
|
+
const modelIpc = registerModelIpc(
|
|
3145
3353
|
ipcMain,
|
|
3146
3354
|
engine,
|
|
3147
3355
|
sessionsDir,
|
|
@@ -3156,7 +3364,16 @@ function bootstrap() {
|
|
|
3156
3364
|
// is where the settings store lands — the exact tool gate it controls lives
|
|
3157
3365
|
// in the engine created alongside it (lib/local/engine.js gatedExecuteTool
|
|
3158
3366
|
// reads settings.getConfirmMode() on every mutating tool call).
|
|
3159
|
-
|
|
3367
|
+
//
|
|
3368
|
+
// …and it is also auto mode's OTHER switch: a posture of "don't ask" is what
|
|
3369
|
+
// auto mode means on this face, so flipping the switch off is an explicit
|
|
3370
|
+
// request for it and must move the session state with it. Without this, an
|
|
3371
|
+
// app launched with AEGIS_AUTO_ROUNDS=0 (a start-manual opt-out, per the
|
|
3372
|
+
// engine) could be switched to auto mode in the UI — approvals off, chip lit
|
|
3373
|
+
// — while the round horizon kept raising its card, because the env var was
|
|
3374
|
+
// re-read as a veto on every horizon. Same escape hatch as `/automode on` in
|
|
3375
|
+
// the CLI, which shares this store.
|
|
3376
|
+
registerConfirmModeIpc(ipcMain, withAutoRoundsSwitch(createConfirmModeDispatch(settings), modelIpc.modelDispatch));
|
|
3160
3377
|
|
|
3161
3378
|
// Funnel instrumentation (Phase 2 of docs/upgrade-conversion-plan.md):
|
|
3162
3379
|
// aegis:telemetryEvent / aegis:telemetryReport, backed by
|
|
@@ -3755,6 +3972,7 @@ module.exports = {
|
|
|
3755
3972
|
createQuickLauncherDispatch,
|
|
3756
3973
|
registerQuickLauncherIpc,
|
|
3757
3974
|
createConfirmModeDispatch,
|
|
3975
|
+
withAutoRoundsSwitch,
|
|
3758
3976
|
createTelemetryDispatch,
|
|
3759
3977
|
registerTelemetryIpc,
|
|
3760
3978
|
createCovenantDispatch,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegis-desktop",
|
|
3
3
|
"productName": "AEGIS Desktop",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "1.0.0",
|
|
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/preload.js
CHANGED
|
@@ -349,6 +349,18 @@ const models = {
|
|
|
349
349
|
}),
|
|
350
350
|
remove: (provider) => invokeModel('settings.remove', { provider }),
|
|
351
351
|
},
|
|
352
|
+
// The user's OWN model declarations (client/user-models.js): the file the
|
|
353
|
+
// CLI's `/model-add` writes, reached here so a model the server's catalog
|
|
354
|
+
// does not name can be added from Settings instead of by hand. `get()`
|
|
355
|
+
// resolves `{ dir, file, map, invalid }` — `file` is shown in the UI so the
|
|
356
|
+
// remaining escape hatch (an editor) is discoverable, and `invalid` names any
|
|
357
|
+
// hand-edited row that was skipped. `add`/`remove` reject with the validation
|
|
358
|
+
// message for a malformed provider or model id.
|
|
359
|
+
userModels: {
|
|
360
|
+
get: () => invokeModel('userModels.get'),
|
|
361
|
+
add: (provider, model) => invokeModel('userModels.add', { provider, model }),
|
|
362
|
+
remove: (provider, model) => invokeModel('userModels.remove', { provider, model }),
|
|
363
|
+
},
|
|
352
364
|
cancel: (sessionId) => invokeModel('cancel', { sessionId }),
|
|
353
365
|
// Tool-call approval gate: the renderer's approval card calls this to
|
|
354
366
|
// answer a pending exec/writeFile/editFile request (the approval itself
|
package/renderer/app.js
CHANGED
|
@@ -3608,6 +3608,30 @@ function renderApprovalCard(row, approval, beforeSelector) {
|
|
|
3608
3608
|
return card;
|
|
3609
3609
|
}
|
|
3610
3610
|
|
|
3611
|
+
/**
|
|
3612
|
+
* A turn-level note written by the APP, not by the model (`onDelta`'s
|
|
3613
|
+
* `{ note }` chunk). Rendered inside the assistant row it belongs to, above the
|
|
3614
|
+
* text stream, and kept out of `streamedText` for the reason the branch that
|
|
3615
|
+
* calls this states: the row's text is persisted as the assistant's own message
|
|
3616
|
+
* and pushed to the account's cloud memory, so app-authored prose placed there
|
|
3617
|
+
* would come back on the next turn as the model's previous answer.
|
|
3618
|
+
*
|
|
3619
|
+
* It exists because auto mode's silent round continuations have to be VISIBLE:
|
|
3620
|
+
* a turn whose budget quietly grew is the "why is it still running?" report
|
|
3621
|
+
* this app keeps getting, and the CLI announces the same continuation with the
|
|
3622
|
+
* same wording in its transcript.
|
|
3623
|
+
*/
|
|
3624
|
+
function appendTurnNote(row, text, beforeSelector) {
|
|
3625
|
+
if (!row || !text) return null;
|
|
3626
|
+
const note = document.createElement('div');
|
|
3627
|
+
note.className = 'turn-note';
|
|
3628
|
+
note.textContent = text;
|
|
3629
|
+
const before = beforeSelector ? row.querySelector(beforeSelector) : null;
|
|
3630
|
+
if (before) row.insertBefore(note, before);
|
|
3631
|
+
else row.appendChild(note);
|
|
3632
|
+
return note;
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3611
3635
|
/**
|
|
3612
3636
|
* Render the round-horizon card (`onDelta`'s `{ rounds: { id, spent,
|
|
3613
3637
|
* extensions, max, size } }` chunk — see `requestRoundsAsk` in
|
|
@@ -4160,6 +4184,12 @@ async function saveLocalBase(baseURL) {
|
|
|
4160
4184
|
|
|
4161
4185
|
async function loadSettings() {
|
|
4162
4186
|
els.settingsList.innerHTML = '';
|
|
4187
|
+
// The provider ids the class offers, collected while the byok rows are built.
|
|
4188
|
+
// `/byok`'s own list IS the catalog, so the "add a model" field below can
|
|
4189
|
+
// offer it instead of asking the user to remember a slug — and it can still
|
|
4190
|
+
// accept a typed id, because the very case this row exists for is a provider
|
|
4191
|
+
// whose lineup moved ahead of our catalog.
|
|
4192
|
+
const byokProviderIds = [];
|
|
4163
4193
|
let settings = [];
|
|
4164
4194
|
try {
|
|
4165
4195
|
settings = (await models.settings.get()) || [];
|
|
@@ -4198,6 +4228,7 @@ async function loadSettings() {
|
|
|
4198
4228
|
const byokProviders = Array.isArray(byokData && byokData.providers) ? byokData.providers : [];
|
|
4199
4229
|
for (const p of byokProviders) {
|
|
4200
4230
|
if (!p || !p.id) continue;
|
|
4231
|
+
byokProviderIds.push({ id: p.id, label: p.label || p.id });
|
|
4201
4232
|
const provider = `byok:${p.id}`;
|
|
4202
4233
|
const local = settings.find((s) => s.provider === provider) || {
|
|
4203
4234
|
provider, baseURL: '', configured: false, keyMask: null,
|
|
@@ -4234,6 +4265,195 @@ async function loadSettings() {
|
|
|
4234
4265
|
} catch {
|
|
4235
4266
|
/* catalog unreachable (offline, server down) — no rows, and the hint above says why */
|
|
4236
4267
|
}
|
|
4268
|
+
|
|
4269
|
+
// The user's OWN model declarations (`client/user-models.js`, the file the
|
|
4270
|
+
// CLI's `/model-add` writes). Rendered outside the byok try above so it
|
|
4271
|
+
// survives an unreachable catalog, and it is the ONE way a desktop user can
|
|
4272
|
+
// pin a model the AEGIS catalog does not name — a newly shipped vendor id, a
|
|
4273
|
+
// fine-tune, a preview route — without waiting for a release or hand-editing
|
|
4274
|
+
// a JSON file the app never named. The relay is a pass-through, so anything
|
|
4275
|
+
// the vendor serves for a provider whose key is set is callable.
|
|
4276
|
+
try {
|
|
4277
|
+
await renderUserModelRows(byokProviderIds);
|
|
4278
|
+
} catch (err) {
|
|
4279
|
+
els.settingsHint.textContent =
|
|
4280
|
+
`your own models: ${err && err.message ? err.message : err}`;
|
|
4281
|
+
}
|
|
4282
|
+
}
|
|
4283
|
+
|
|
4284
|
+
/**
|
|
4285
|
+
* The "your own models" block: one add row plus a row per declaration.
|
|
4286
|
+
*
|
|
4287
|
+
* Everything here goes through the engine (`models.userModels.*`), never
|
|
4288
|
+
* through the settings store, because the engine's `listModels('byok')` is what
|
|
4289
|
+
* APPLIES the declarations — one implementation writes the file and one reads
|
|
4290
|
+
* it, so the row a user adds here is the row the picker offers on its next
|
|
4291
|
+
* refresh (loadModels is re-run by the callers below).
|
|
4292
|
+
*
|
|
4293
|
+
* A rejected value does not throw into the console: `add`/`remove` reject with
|
|
4294
|
+
* the validation message (an id the relay would forward to a vendor 404s on the
|
|
4295
|
+
* user's first turn, so refusing it here is the whole value of the check) and it
|
|
4296
|
+
* is printed in the hint line with the file path, which is also the documented
|
|
4297
|
+
* escape hatch for a bulk edit.
|
|
4298
|
+
*/
|
|
4299
|
+
async function renderUserModelRows(byokProviderIds) {
|
|
4300
|
+
if (!els.settingsList || !models.userModels) return;
|
|
4301
|
+
const report = await models.userModels.get();
|
|
4302
|
+
if (!report) return;
|
|
4303
|
+
const map = (report && report.map) || {};
|
|
4304
|
+
const ids = (byokProviderIds || []).map((p) => (p && p.id) || '').filter(Boolean);
|
|
4305
|
+
|
|
4306
|
+
const block = document.createElement('div');
|
|
4307
|
+
block.id = 'user-models-block';
|
|
4308
|
+
|
|
4309
|
+
const title = document.createElement('div');
|
|
4310
|
+
title.className = 'setting-name';
|
|
4311
|
+
title.textContent = 'Your own models (BYOK)';
|
|
4312
|
+
block.appendChild(title);
|
|
4313
|
+
|
|
4314
|
+
// ── add: provider + model id + one button
|
|
4315
|
+
const addRow = document.createElement('div');
|
|
4316
|
+
addRow.className = 'setting-row';
|
|
4317
|
+
addRow.dataset.provider = 'byok:__user-model-add';
|
|
4318
|
+
|
|
4319
|
+
const providerInput = document.createElement('input');
|
|
4320
|
+
providerInput.type = 'text';
|
|
4321
|
+
providerInput.className = 'setting-input';
|
|
4322
|
+
providerInput.id = 'user-model-provider';
|
|
4323
|
+
providerInput.placeholder = 'provider (deepseek)';
|
|
4324
|
+
providerInput.autocomplete = 'off';
|
|
4325
|
+
providerInput.spellcheck = false;
|
|
4326
|
+
// A datalist rather than a select: the catalog's ids are a convenience for the
|
|
4327
|
+
// common case, and a typed slug is still allowed for the case this row exists
|
|
4328
|
+
// for (the catalog has not caught up). A select would have made the second one
|
|
4329
|
+
// impossible without a form of its own.
|
|
4330
|
+
if (ids.length) {
|
|
4331
|
+
const list = document.createElement('datalist');
|
|
4332
|
+
list.id = 'user-model-providers';
|
|
4333
|
+
for (const id of ids) {
|
|
4334
|
+
const opt = document.createElement('option');
|
|
4335
|
+
opt.value = id;
|
|
4336
|
+
list.appendChild(opt);
|
|
4337
|
+
}
|
|
4338
|
+
addRow.appendChild(list);
|
|
4339
|
+
providerInput.setAttribute('list', 'user-model-providers');
|
|
4340
|
+
}
|
|
4341
|
+
addRow.appendChild(providerInput);
|
|
4342
|
+
|
|
4343
|
+
const modelInput = document.createElement('input');
|
|
4344
|
+
modelInput.type = 'text';
|
|
4345
|
+
modelInput.className = 'setting-input';
|
|
4346
|
+
modelInput.id = 'user-model-id';
|
|
4347
|
+
modelInput.placeholder = 'model id (deepseek-v4.2-preview)';
|
|
4348
|
+
modelInput.autocomplete = 'off';
|
|
4349
|
+
modelInput.spellcheck = false;
|
|
4350
|
+
addRow.appendChild(modelInput);
|
|
4351
|
+
|
|
4352
|
+
const actions = document.createElement('div');
|
|
4353
|
+
actions.className = 'setting-actions';
|
|
4354
|
+
const addBtn = document.createElement('button');
|
|
4355
|
+
addBtn.type = 'button';
|
|
4356
|
+
addBtn.className = 'ghost-btn';
|
|
4357
|
+
addBtn.id = 'user-model-add';
|
|
4358
|
+
addBtn.textContent = 'Add model';
|
|
4359
|
+
addBtn.addEventListener('click', async () => {
|
|
4360
|
+
const provider = providerInput.value.trim();
|
|
4361
|
+
const model = modelInput.value.trim();
|
|
4362
|
+
els.settingsHint.textContent = 'adding…';
|
|
4363
|
+
try {
|
|
4364
|
+
const res = await models.userModels.add(provider, model);
|
|
4365
|
+
els.settingsHint.textContent = res && res.added === false
|
|
4366
|
+
? `${res.provider}:${res.model} was already declared.`
|
|
4367
|
+
: `added ${res.provider}:${res.model} — /models and the picker now offer it.`;
|
|
4368
|
+
await loadSettings();
|
|
4369
|
+
await loadModels(els.classSelect.value);
|
|
4370
|
+
} catch (err) {
|
|
4371
|
+
els.settingsHint.textContent = `${err && err.message ? err.message : err}`;
|
|
4372
|
+
}
|
|
4373
|
+
});
|
|
4374
|
+
actions.appendChild(addBtn);
|
|
4375
|
+
addRow.appendChild(actions);
|
|
4376
|
+
|
|
4377
|
+
const hint = document.createElement('div');
|
|
4378
|
+
hint.className = 'setting-status';
|
|
4379
|
+
hint.id = 'user-model-hint';
|
|
4380
|
+
hint.textContent = report.file
|
|
4381
|
+
? `saved in ${report.file} — the CLI reads the same file (/model-add)`
|
|
4382
|
+
: 'saved beside your AEGIS config — the CLI reads the same file (/model-add)';
|
|
4383
|
+
addRow.appendChild(hint);
|
|
4384
|
+
block.appendChild(addRow);
|
|
4385
|
+
|
|
4386
|
+
// ── one removal row per declaration, provider-major and sorted, so the list
|
|
4387
|
+
// is stable between renders (an order that shuffled would make the Remove
|
|
4388
|
+
// button beside a row a guess).
|
|
4389
|
+
const providers = Object.keys(map).sort();
|
|
4390
|
+
for (const provider of providers) {
|
|
4391
|
+
for (const model of map[provider]) {
|
|
4392
|
+
const row = document.createElement('div');
|
|
4393
|
+
row.className = 'setting-row';
|
|
4394
|
+
row.dataset.provider = `byok:${provider}`;
|
|
4395
|
+
|
|
4396
|
+
const name = document.createElement('div');
|
|
4397
|
+
name.className = 'setting-name';
|
|
4398
|
+
name.textContent = `${provider} — ${model}`;
|
|
4399
|
+
row.appendChild(name);
|
|
4400
|
+
|
|
4401
|
+
const status = document.createElement('div');
|
|
4402
|
+
status.className = 'setting-status';
|
|
4403
|
+
// Declared, but not offered: the server's BYOK catalog lists no row for
|
|
4404
|
+
// this provider, so no key could ever be attached to it and the engine
|
|
4405
|
+
// will not paint it. Saying so here is the difference between "my typo is
|
|
4406
|
+
// being ignored" and "this provider cannot take a key".
|
|
4407
|
+
status.textContent = ids.length && !ids.includes(provider)
|
|
4408
|
+
? 'no provider row for this id — /byok lists the ones the relay takes'
|
|
4409
|
+
: 'offered in the picker on this machine';
|
|
4410
|
+
row.appendChild(status);
|
|
4411
|
+
|
|
4412
|
+
const rowActions = document.createElement('div');
|
|
4413
|
+
rowActions.className = 'setting-actions';
|
|
4414
|
+
const rmBtn = document.createElement('button');
|
|
4415
|
+
rmBtn.type = 'button';
|
|
4416
|
+
rmBtn.className = 'ghost-btn danger';
|
|
4417
|
+
rmBtn.textContent = 'Remove';
|
|
4418
|
+
rmBtn.addEventListener('click', async () => {
|
|
4419
|
+
els.settingsHint.textContent = 'removing…';
|
|
4420
|
+
try {
|
|
4421
|
+
const res = await models.userModels.remove(provider, model);
|
|
4422
|
+
els.settingsHint.textContent = res && res.removed === false
|
|
4423
|
+
? `${provider}:${model} was not declared.`
|
|
4424
|
+
: `removed ${provider}:${model}.`;
|
|
4425
|
+
await loadSettings();
|
|
4426
|
+
await loadModels(els.classSelect.value);
|
|
4427
|
+
} catch (err) {
|
|
4428
|
+
els.settingsHint.textContent = `${err && err.message ? err.message : err}`;
|
|
4429
|
+
}
|
|
4430
|
+
});
|
|
4431
|
+
rowActions.appendChild(rmBtn);
|
|
4432
|
+
row.appendChild(rowActions);
|
|
4433
|
+
|
|
4434
|
+
block.appendChild(row);
|
|
4435
|
+
}
|
|
4436
|
+
}
|
|
4437
|
+
|
|
4438
|
+
// A hand-edited file can carry a row this module refuses (a typo, an id with a
|
|
4439
|
+
// space). It is named rather than dropped in silence: otherwise "my model is
|
|
4440
|
+
// nowhere in the picker" has no explanation anywhere in the UI.
|
|
4441
|
+
const invalid = (report && report.invalid) || [];
|
|
4442
|
+
for (const bad of invalid) {
|
|
4443
|
+
const row = document.createElement('div');
|
|
4444
|
+
row.className = 'setting-row';
|
|
4445
|
+
const name = document.createElement('div');
|
|
4446
|
+
name.className = 'setting-name';
|
|
4447
|
+
name.textContent = `${bad.provider || 'models.json'}: ${bad.model == null ? '' : String(bad.model)}`.trim();
|
|
4448
|
+
row.appendChild(name);
|
|
4449
|
+
const status = document.createElement('div');
|
|
4450
|
+
status.className = 'setting-status';
|
|
4451
|
+
status.textContent = `skipped — ${bad.reason}`;
|
|
4452
|
+
row.appendChild(status);
|
|
4453
|
+
block.appendChild(row);
|
|
4454
|
+
}
|
|
4455
|
+
|
|
4456
|
+
els.settingsList.appendChild(block);
|
|
4237
4457
|
}
|
|
4238
4458
|
|
|
4239
4459
|
async function saveSetting(provider, key) {
|
|
@@ -4663,6 +4883,19 @@ async function send() {
|
|
|
4663
4883
|
paintStream();
|
|
4664
4884
|
return;
|
|
4665
4885
|
}
|
|
4886
|
+
if (chunk && typeof chunk.note === 'string' && chunk.note) {
|
|
4887
|
+
// A turn-level notice that is NOT the model's words — today the only one
|
|
4888
|
+
// is auto mode's "continuing automatically" line (desktop/main.js
|
|
4889
|
+
// chatWithAutoRounds). It is deliberately not folded into
|
|
4890
|
+
// `streamedText`: that string becomes the assistant's message and is
|
|
4891
|
+
// persisted and synced, so an app-authored sentence put there would
|
|
4892
|
+
// re-enter the model's context on the next turn as something it had said.
|
|
4893
|
+
const row = ensurePendingRow();
|
|
4894
|
+
row.classList.remove('pending');
|
|
4895
|
+
appendTurnNote(row, chunk.note, '.body');
|
|
4896
|
+
stickToBottom();
|
|
4897
|
+
return;
|
|
4898
|
+
}
|
|
4666
4899
|
if (chunk && chunk.approval) {
|
|
4667
4900
|
const row = ensurePendingRow();
|
|
4668
4901
|
row.classList.remove('pending');
|
package/renderer/style.css
CHANGED
|
@@ -1543,6 +1543,20 @@ label {
|
|
|
1543
1543
|
color: var(--text2);
|
|
1544
1544
|
}
|
|
1545
1545
|
|
|
1546
|
+
/* An app-authored turn note ({ note } frames — today auto mode's "continuing
|
|
1547
|
+
automatically" line). Muted and unboxed on purpose: it is a statement about
|
|
1548
|
+
the turn's budget, not a question, and it must not read as either of the two
|
|
1549
|
+
cards that ARE questions (approval, round horizon). */
|
|
1550
|
+
.turn-note {
|
|
1551
|
+
font-family: var(--font-mono);
|
|
1552
|
+
font-size: 11px;
|
|
1553
|
+
color: var(--text2);
|
|
1554
|
+
border-left: 2px solid var(--accent);
|
|
1555
|
+
padding: 2px 0 2px 8px;
|
|
1556
|
+
margin: 4px 0;
|
|
1557
|
+
opacity: 0.9;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1546
1560
|
.approval-title {
|
|
1547
1561
|
font-family: var(--font-mono);
|
|
1548
1562
|
font-size: 11px;
|