@genesislcap/ai-assistant 15.23.3 → 15.24.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/dist/ai-assistant.api.json +419 -50
- package/dist/ai-assistant.d.ts +217 -683
- package/dist/chat-driver.cjs.map +1 -1
- package/dist/chat-driver.mjs.map +1 -1
- package/dist/custom-elements.json +310 -609
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +16 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/index.d.ts +1 -0
- package/dist/dts/components/settings-modal/index.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.d.ts +215 -0
- package/dist/dts/components/settings-modal/settings-modal.d.ts.map +1 -0
- package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
- package/dist/dts/main/attachment-controller.d.ts +66 -0
- package/dist/dts/main/attachment-controller.d.ts.map +1 -0
- package/dist/dts/main/budget-controller.d.ts +368 -0
- package/dist/dts/main/budget-controller.d.ts.map +1 -0
- package/dist/dts/main/composer-menu-controller.d.ts +113 -0
- package/dist/dts/main/composer-menu-controller.d.ts.map +1 -0
- package/dist/dts/main/composer-resize-controller.d.ts +80 -0
- package/dist/dts/main/composer-resize-controller.d.ts.map +1 -0
- package/dist/dts/main/context-gate-controller.d.ts +197 -0
- package/dist/dts/main/context-gate-controller.d.ts.map +1 -0
- package/dist/dts/main/cost-ledger-controller.d.ts +228 -0
- package/dist/dts/main/cost-ledger-controller.d.ts.map +1 -0
- package/dist/dts/main/main.d.ts +201 -722
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/main/main.types.d.ts +25 -0
- package/dist/dts/main/main.types.d.ts.map +1 -1
- package/dist/dts/main/meta-snapshot.d.ts +50 -0
- package/dist/dts/main/meta-snapshot.d.ts.map +1 -0
- package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +18 -0
- package/dist/esm/components/settings-modal/index.js +1 -0
- package/dist/esm/components/settings-modal/settings-modal.js +436 -0
- package/dist/esm/components/settings-modal/settings-modal.template.js +15 -4
- package/dist/esm/main/attachment-controller.js +254 -0
- package/dist/esm/main/budget-controller.js +745 -0
- package/dist/esm/main/composer-menu-controller.js +234 -0
- package/dist/esm/main/composer-resize-controller.js +119 -0
- package/dist/esm/main/context-gate-controller.js +289 -0
- package/dist/esm/main/cost-ledger-controller.js +498 -0
- package/dist/esm/main/main.js +501 -2268
- package/dist/esm/main/meta-snapshot.js +108 -0
- package/dist/esm/state/persistence/session-persister.js +5 -4
- package/docs/migration-GENC-1580.md +209 -0
- package/package.json +16 -16
package/dist/esm/main/main.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// =============================================================================
|
|
23
23
|
var FoundationAiAssistant_1;
|
|
24
24
|
import { __awaiter, __decorate, __rest } from "tslib";
|
|
25
|
-
import { AIProviderRegistry,
|
|
25
|
+
import { AIProviderRegistry, isObservableAIProviderRegistry } from '@genesislcap/foundation-ai';
|
|
26
26
|
import { showNotificationDialog } from '@genesislcap/foundation-notifications';
|
|
27
27
|
import { avoidTreeShaking } from '@genesislcap/foundation-utils';
|
|
28
28
|
import { customElement, DOM, html, GenesisElement, observable, volatile, attr, } from '@genesislcap/web-core';
|
|
@@ -36,14 +36,15 @@ import { AiChatInteractionWrapper } from '../components/chat-interaction-wrapper
|
|
|
36
36
|
import { AiChatMarkdown } from '../components/chat-markdown/chat-markdown';
|
|
37
37
|
import { AiFlowingWavesIndicator } from '../components/flowing-waves-indicator';
|
|
38
38
|
import { AiHaloOverlay } from '../components/halo-overlay';
|
|
39
|
-
import { OrchestratingDriver } from '../components/orchestrating-driver/orchestrating-driver';
|
|
39
|
+
import { asOrchestrator, OrchestratingDriver, } from '../components/orchestrating-driver/orchestrating-driver';
|
|
40
40
|
import { AiPlasmaOrbIndicator } from '../components/plasma-orb-indicator';
|
|
41
|
+
import { SettingsModalController, } from '../components/settings-modal';
|
|
41
42
|
import { AiWavesIndicator } from '../components/waves-indicator';
|
|
42
43
|
import { AssistantAppSettingsProvider, } from '../provider/assistant-app-settings';
|
|
43
44
|
import { recordMetaEvent, getMetaEvents, clearSession as clearMetaSession, DEBUG_LOG_README, } from '../state/debug-event-log';
|
|
44
45
|
import { getOrCreateDriver, getDriver, getDriverAgentsKey, deleteDriver, } from '../state/driver-registry';
|
|
45
46
|
import { buildTimelineEntries } from '../state/persistence/build-timeline-entries';
|
|
46
|
-
import { assembleDebugLog,
|
|
47
|
+
import { assembleDebugLog, withFreshMetaSnapshot } from '../state/persistence/diagnostics';
|
|
47
48
|
import { deleteDiagnosticsCursorsFor, resetDiagnosticsCursorsFor, } from '../state/persistence/diagnostics-cursors';
|
|
48
49
|
import { getOrCreatePersister, getPersister, deletePersister, } from '../state/persistence/persister-registry';
|
|
49
50
|
import { SessionPersister, } from '../state/persistence/session-persister';
|
|
@@ -51,25 +52,24 @@ import { lastUserMessageIndex } from '../state/persistence/session-snapshot';
|
|
|
51
52
|
import { getSessionStore, hasSessionStore, deleteSessionStore, } from '../state/session-store';
|
|
52
53
|
import { pinColourAt } from '../styles/ai-colours';
|
|
53
54
|
import { ChatSuggestions } from '../suggestions/chat-suggestions';
|
|
54
|
-
import { AnimatedPanelToggle } from '../utils/animated-panel-toggle';
|
|
55
55
|
import { resolveExclusiveLoadingStyle } from '../utils/animation-exclusivity';
|
|
56
|
-
import { deleteBankedBaseline
|
|
57
|
-
import { collectSessionModels } from '../utils/collect-session-models';
|
|
56
|
+
import { deleteBankedBaseline } from '../utils/banked-usage-baselines';
|
|
58
57
|
import { estimateContextTokens, isContextMeasurable, } from '../utils/context-tokens';
|
|
59
|
-
import { clearCostSessionHistory, isCostSessionRecord, loadCostSessionHistory, resolveBankedUsage, saveCostSessionHistory, sortRecordsByRecency, upsertRecord, } from '../utils/cost-session-history';
|
|
60
|
-
import { deriveCostSessionTitleFromMessages, resolveCostSessionTitle, } from '../utils/derive-cost-session-title';
|
|
61
|
-
import { formatUsd } from '../utils/format-usd';
|
|
62
58
|
import { logger } from '../utils/logger';
|
|
63
59
|
import { filterVisibleMessages, trailingInteractionRow } from '../utils/message-partition';
|
|
64
|
-
import { CONTEXT_ABORT_MARGIN_TOKENS, resolveContextBudget, } from '../utils/resolve-context-budget';
|
|
65
|
-
import { resolveCostHistoryConfig, } from '../utils/resolve-cost-history-config';
|
|
66
60
|
import { resolvePreferenceBaseline } from '../utils/resolve-preference-baseline';
|
|
67
61
|
import { stripAgentHandlers } from '../utils/strip-agent-handlers';
|
|
68
|
-
import {
|
|
69
|
-
import {
|
|
62
|
+
import { emptyUsage, sumUsage, totalTokens } from '../utils/sum-usage';
|
|
63
|
+
import { AttachmentController } from './attachment-controller';
|
|
64
|
+
import { BudgetController } from './budget-controller';
|
|
65
|
+
import { ComposerMenuController } from './composer-menu-controller';
|
|
66
|
+
import { ComposerResizeController } from './composer-resize-controller';
|
|
67
|
+
import { ContextGateController } from './context-gate-controller';
|
|
68
|
+
import { CostLedgerController } from './cost-ledger-controller';
|
|
70
69
|
import { styles } from './main.styles';
|
|
71
70
|
import { FoundationAiAssistantTemplate } from './main.template';
|
|
72
71
|
import { AiAssistantEvent } from './main.types';
|
|
72
|
+
import { buildMetaSnapshot } from './meta-snapshot';
|
|
73
73
|
/** Context window sizes (in tokens) for known models. */
|
|
74
74
|
// ─── Avatar icons ─────────────────────────────────────────────────────────────
|
|
75
75
|
// Inline SVG bundled as strings — no runtime asset dependency. They use
|
|
@@ -91,83 +91,6 @@ export const genesisIconSvg = `<svg viewBox="-4 0 30 26" fill="none" xmlns="http
|
|
|
91
91
|
function sanitizeIconMarkup(markup) {
|
|
92
92
|
return DOMPurify.sanitize(markup, { USE_PROFILES: { svg: true, svgFilters: true } });
|
|
93
93
|
}
|
|
94
|
-
/**
|
|
95
|
-
* Duration of a composer menu's close animation. Must stay in sync with the
|
|
96
|
-
* `composer-menu-out` keyframe duration in `main.styles.ts` — the toggle strips
|
|
97
|
-
* the `closing` class on a timer, so a shorter value here unmounts the panel
|
|
98
|
-
* mid-animation and a longer one leaves it sitting invisible.
|
|
99
|
-
*/
|
|
100
|
-
const AGENT_PICKER_CLOSE_MS = 200;
|
|
101
|
-
const SESSION_MENU_CLOSE_MS = 200;
|
|
102
|
-
/**
|
|
103
|
-
* Floor for an open menu's height, applied when the assistant is too short to
|
|
104
|
-
* give it the room it wants. Below this a menu is more frustrating than a
|
|
105
|
-
* scrollbar is, so it overhangs instead.
|
|
106
|
-
*/
|
|
107
|
-
const COMPOSER_MENU_MIN_HEIGHT = 140;
|
|
108
|
-
/**
|
|
109
|
-
* How long cost-ledger writes coalesce. Comfortably longer than the gap between the turns of a
|
|
110
|
-
* tool loop, so a multi-step run writes once rather than per turn, and short enough that a flush
|
|
111
|
-
* has landed well before a user could close the tab. Nothing reads the stored ledger while the
|
|
112
|
-
* assistant is running — the Usage tab renders the in-memory list — so the delay is invisible.
|
|
113
|
-
*/
|
|
114
|
-
const LEDGER_FLUSH_DEBOUNCE_MS = 2000;
|
|
115
|
-
/**
|
|
116
|
-
* The image types BOTH vendors accept, and therefore the only ones sent as image blocks.
|
|
117
|
-
*
|
|
118
|
-
* A host's `acceptedFiles` may well say `image/*`, which covers HEIC, BMP, TIFF, AVIF and SVG —
|
|
119
|
-
* none of which the APIs take. Sending one fails the whole turn the user is waiting on, so
|
|
120
|
-
* anything outside this set falls through to the text branch instead: harmless for a binary, and
|
|
121
|
-
* genuinely useful for SVG, which is text a model can read.
|
|
122
|
-
*
|
|
123
|
-
* `image/jpg` is absent on purpose — `normalizeImageMime` rewrites it to `image/jpeg` at the
|
|
124
|
-
* transport, but a file arriving with that spelling is still a real JPEG, so it is accepted here.
|
|
125
|
-
*/
|
|
126
|
-
const VENDOR_IMAGE_MIME_TYPES = new Set([
|
|
127
|
-
'image/jpeg',
|
|
128
|
-
'image/jpg',
|
|
129
|
-
'image/png',
|
|
130
|
-
'image/gif',
|
|
131
|
-
'image/webp',
|
|
132
|
-
]);
|
|
133
|
-
/**
|
|
134
|
-
* Ceiling on ONE image attachment, under Anthropic's ~5MB-per-image limit with room for the ~33%
|
|
135
|
-
* base64 overhead. Refused here, with the size named, rather than at the vendor: a 400 arrives
|
|
136
|
-
* only after the user has waited out the turn.
|
|
137
|
-
*
|
|
138
|
-
* A VENDOR guard, and only that. An earlier version of this comment also claimed it protected the
|
|
139
|
-
* session's storage quota — it does not, and the arithmetic says so: one file at this ceiling is
|
|
140
|
-
* ~4.7MB of base64, which alone can exceed a 5MB `localStorage` budget before the transcript is
|
|
141
|
-
* counted. Attachments riding into persisted storage is a real and separate problem; it is not
|
|
142
|
-
* this constant's to solve, and saying otherwise hid it.
|
|
143
|
-
*/
|
|
144
|
-
const MAX_IMAGE_ATTACHMENT_BYTES = 3500000;
|
|
145
|
-
/**
|
|
146
|
-
* Ceiling on ALL attachments carried by one message, across every type and every successive pick.
|
|
147
|
-
*
|
|
148
|
-
* The per-file check above is blind to the total: the picker APPENDS (`loadSelectedFiles`), so ten
|
|
149
|
-
* files each just under the per-image limit are ten passes and one ~34MB request. Sized against
|
|
150
|
-
* the vendors' request ceilings — comfortably inside the smaller of them with room for the
|
|
151
|
-
* transcript itself — so a turn that would be rejected wholesale is refused here, where the user
|
|
152
|
-
* can still act on it, naming the file that crossed the line.
|
|
153
|
-
*
|
|
154
|
-
* Like its sibling, this does NOT make attachments safe to persist — 8MB of attachments is still
|
|
155
|
-
* ~10.7MB of base64. What a persisted session should keep of its images is GENC-1531; note the
|
|
156
|
-
* transports also REPLAY history attachments on every later call, so it is a recurring cost as
|
|
157
|
-
* well as a storage one.
|
|
158
|
-
*/
|
|
159
|
-
const MAX_ATTACHMENTS_TOTAL_BYTES = 8000000;
|
|
160
|
-
/** Base64 carries 3 bytes in every 4 characters, so decoded size is 3/4 of the encoded length. */
|
|
161
|
-
const BASE64_DECODED_RATIO = 0.75;
|
|
162
|
-
/** A rough byte size for an already-accepted attachment, for budgeting against the total cap. */
|
|
163
|
-
function attachmentBytes(a) {
|
|
164
|
-
var _a, _b, _c, _d;
|
|
165
|
-
return a.kind === 'image'
|
|
166
|
-
? Math.ceil(((_b = (_a = a.data) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) * BASE64_DECODED_RATIO)
|
|
167
|
-
: ((_d = (_c = a.content) === null || _c === void 0 ? void 0 : _c.length) !== null && _d !== void 0 ? _d : 0);
|
|
168
|
-
}
|
|
169
|
-
/** Bytes per megabyte, for the size the refusal message quotes back to the user. */
|
|
170
|
-
const BYTES_PER_MB = 1000000;
|
|
171
94
|
/**
|
|
172
95
|
* Reporter id for this element's OWN session persister in `brokenSources` (GENC-1511).
|
|
173
96
|
*
|
|
@@ -181,11 +104,6 @@ const SESSION_PERSISTENCE_SOURCE = 'session';
|
|
|
181
104
|
* store; a host owning several should name each, so one recovering does not withdraw the others.
|
|
182
105
|
*/
|
|
183
106
|
const DEFAULT_PERSISTENCE_SOURCE = 'host';
|
|
184
|
-
/** Drag-to-resize bounds for the chat input (composer), in px. */
|
|
185
|
-
const COMPOSER_MIN_HEIGHT_PX = 48;
|
|
186
|
-
const COMPOSER_MAX_HEIGHT_PX = 400;
|
|
187
|
-
/** Keep at least this much of the message list visible while growing the composer. */
|
|
188
|
-
const COMPOSER_MIN_MESSAGES_PX = 80;
|
|
189
107
|
/**
|
|
190
108
|
* Composer placeholder while the assistant is blocked (GENC-1464). Deliberately
|
|
191
109
|
* fixed and short, and NOT host-overridable — the banner directly above it
|
|
@@ -196,108 +114,13 @@ const COMPOSER_MIN_MESSAGES_PX = 80;
|
|
|
196
114
|
*/
|
|
197
115
|
const BLOCKED_PLACEHOLDER = 'AI usage limit reached';
|
|
198
116
|
/**
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Banner copy built from the figures a 402 actually reported, or `undefined`
|
|
206
|
-
* when the proxy sent none.
|
|
207
|
-
*
|
|
208
|
-
* `undefined` is the meaningful return, not a fallback: paired with the
|
|
209
|
-
* slice's "a re-latch without a reason keeps the existing explanation" rule, it
|
|
210
|
-
* makes a figureless driver latch leave a host-supplied reason intact rather
|
|
211
|
-
* than blanking it back to the default at the exact moment the wall is hit.
|
|
212
|
-
*
|
|
213
|
-
* Exported for the unit test that pins the formatting; not part of the element
|
|
214
|
-
* API.
|
|
215
|
-
*
|
|
216
|
-
* @internal
|
|
217
|
-
*/
|
|
218
|
-
export function formatBlockedReason(
|
|
219
|
-
// `Partial`, so the store's `VendorBudgetFigures` satisfies it as well as a live
|
|
220
|
-
// 402's payload: the banner composes from stored figures and the latch from the
|
|
221
|
-
// fresh ones, and both carry the same dollars. One formatter for both means a
|
|
222
|
-
// remembered wall and a just-hit one cannot word themselves differently.
|
|
223
|
-
budget, vendor) {
|
|
224
|
-
if (!budget || (budget.budgetUsd == null && budget.spentUsd == null))
|
|
225
|
-
return undefined;
|
|
226
|
-
const money = (v) => (v == null ? 'an unknown amount' : formatUsd(v));
|
|
227
|
-
const figures = `(${money(budget.spentUsd)} of ${money(budget.budgetUsd)})`;
|
|
228
|
-
// No "you have $0.00 left" clause here, deliberately: "limit is reached" already
|
|
229
|
-
// says the allowance is gone, and a remainder clamped at zero CONTRADICTS the
|
|
230
|
-
// figures beside it — spend overshoots the cap on the last allowed call, so the
|
|
231
|
-
// words would read "$0.00 left" next to numbers showing $12.34 past it.
|
|
232
|
-
// A per-vendor statement carries NO action clause, because the right action is
|
|
233
|
-
// not knowable at latch time: whether "switch vendor" or "contact your
|
|
234
|
-
// administrator" is the honest advice depends on whether any OTHER vendor still
|
|
235
|
-
// has headroom, which only the composing getter can see. The vendor-agnostic
|
|
236
|
-
// form keeps its historical trailing sentence — nothing composes onto it.
|
|
237
|
-
if (!vendor)
|
|
238
|
-
return `AI usage limit reached ${figures}. Contact your administrator to raise it.`;
|
|
239
|
-
return `${vendorDisplayName(vendor)}'s AI usage limit is reached ${figures}.`;
|
|
240
|
-
}
|
|
241
|
-
/**
|
|
242
|
-
* Human-readable name for a vendor, falling back to its raw type.
|
|
243
|
-
*
|
|
244
|
-
* `'none'` needs no special case: it is the "no provider configured" sentinel,
|
|
245
|
-
* it is filtered out of {@link FoundationAiAssistant.reachableVendors}, the latch
|
|
246
|
-
* refuses to attribute a wall to it, and
|
|
247
|
-
* {@link FoundationAiAssistant.setVendorBlocked} rejects it at the boundary — so
|
|
248
|
-
* nothing can put it in front of this function. The `?? vendor` fallback is for a
|
|
249
|
-
* vendor type added to `AIProviderType` without a label, which would print the
|
|
250
|
-
* type rather than nothing.
|
|
251
|
-
*/
|
|
252
|
-
function vendorDisplayName(vendor) {
|
|
253
|
-
var _a;
|
|
254
|
-
return (_a = VENDOR_LABELS[vendor]) !== null && _a !== void 0 ? _a : vendor;
|
|
255
|
-
}
|
|
256
|
-
/**
|
|
257
|
-
* `a`, `a and b`, `a, b and c`.
|
|
258
|
-
*
|
|
259
|
-
* The general joiner is kept rather than hardcoded to a pair because the list
|
|
260
|
-
* is `BUDGETED_VENDORS`-shaped, and that list is a cross-repo contract owned by
|
|
261
|
-
* the proxy (today `anthropic` and `gemini`; `openai` is refused up front with
|
|
262
|
-
* 400 UNSUPPORTED_PROVIDER and can never be walled) — a vendor added there must
|
|
263
|
-
* not require an edit here. Its upper bound is not an assertion in a comment — it is
|
|
264
|
-
* the key count of `VENDOR_LABELS`, since every element of every list passed here
|
|
265
|
-
* comes from `blockedVendors` or `reachableVendors`, both of which hold
|
|
266
|
-
* `AIProviderType` values, and the type has five members of which `'none'` is
|
|
267
|
-
* excluded from both.
|
|
268
|
-
*/
|
|
269
|
-
function formatVendorList(vendors) {
|
|
270
|
-
return joinList(vendors.map(vendorDisplayName));
|
|
271
|
-
}
|
|
272
|
-
/**
|
|
273
|
-
* `"A"`, `"A and B"`, `"A, B and C"` — over already-rendered labels, so a caller
|
|
274
|
-
* can annotate each name (the multi-vendor banner appends each vendor's figures)
|
|
275
|
-
* without duplicating the joining rules.
|
|
117
|
+
* Budget formatting helpers, re-exported so they stay on this module's public
|
|
118
|
+
* surface (`src/index.ts` does `export * from './main/main'`). They live in
|
|
119
|
+
* `budget-controller.ts` with the code that composes them; moving them without
|
|
120
|
+
* this line would have quietly dropped two `@internal` exports from the package
|
|
121
|
+
* entry point, and broken the two test files that import them from here.
|
|
276
122
|
*/
|
|
277
|
-
|
|
278
|
-
var _a;
|
|
279
|
-
if (names.length <= 1)
|
|
280
|
-
return (_a = names[0]) !== null && _a !== void 0 ? _a : '';
|
|
281
|
-
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
282
|
-
}
|
|
283
|
-
/**
|
|
284
|
-
* Whole-number percentage of a budget consumed, for the meter (GENC-1464).
|
|
285
|
-
*
|
|
286
|
-
* Deliberately unclamped — spend can genuinely overshoot the cap (the last
|
|
287
|
-
* allowed call may cross it), and the meter's TEXT shows the truth; only the
|
|
288
|
-
* bar clamps, at the render site. The one special case is a `$0` cap, which the
|
|
289
|
-
* platform treats as "no spend allowed" (not "unlimited"): it has no headroom
|
|
290
|
-
* by definition, so the meter reads full rather than dividing by zero.
|
|
291
|
-
*
|
|
292
|
-
* Exported for the unit test that pins the arithmetic; not element API.
|
|
293
|
-
*
|
|
294
|
-
* @internal
|
|
295
|
-
*/
|
|
296
|
-
export function budgetPercentOf(figures) {
|
|
297
|
-
if (figures.budgetUsd <= 0)
|
|
298
|
-
return 100;
|
|
299
|
-
return Math.round((figures.spentUsd / figures.budgetUsd) * 100);
|
|
300
|
-
}
|
|
123
|
+
export { budgetPercentOf, formatBlockedReason } from './budget-controller';
|
|
301
124
|
// Register supporting components when the main component module is imported.
|
|
302
125
|
avoidTreeShaking(AiChatMarkdown, AiChatInteractionWrapper, AiHaloOverlay, AiWavesIndicator, AiFlowingWavesIndicator, AiPlasmaOrbIndicator, AiChatBubble, AiActivityHalo, ChatSuggestions, AgentPicker);
|
|
303
126
|
/**
|
|
@@ -328,7 +151,111 @@ avoidTreeShaking(AiChatMarkdown, AiChatInteractionWrapper, AiHaloOverlay, AiWave
|
|
|
328
151
|
*/
|
|
329
152
|
let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistant extends GenesisElement {
|
|
330
153
|
constructor() {
|
|
154
|
+
// ─── Controllers ────────────────────────────────────────────────────────────
|
|
155
|
+
//
|
|
156
|
+
// DECLARED FIRST, and that is load-bearing. FAST fires a `<field>Changed` hook from
|
|
157
|
+
// the field's own initializer, so `@observable chatConfig = {}` below runs
|
|
158
|
+
// `chatConfigChanged` — which reaches two of these — while the class body is still
|
|
159
|
+
// initializing. A controller declared after the observable it serves is `undefined`
|
|
160
|
+
// at that moment, and the hook throws inside `document.createElement`, before any
|
|
161
|
+
// test or host code can catch it. Field initializers run in declaration order, so
|
|
162
|
+
// putting them here is the fix; keep new controllers in this block.
|
|
163
|
+
// ─── Composer controllers ───────────────────────────────────────────────────
|
|
164
|
+
// Three separable concerns that share only the `.composer` DOM region: the
|
|
165
|
+
// drag-to-resize geometry, the two anchored menus, and file attachments. Each
|
|
166
|
+
// receives callbacks rather than this element, so none of them can reach back
|
|
167
|
+
// into element API — and none of them touches the driver.
|
|
331
168
|
super(...arguments);
|
|
169
|
+
this.composerResize = new ComposerResizeController({
|
|
170
|
+
enabled: () => { var _a; return ((_a = this.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.resizableInput) !== false; },
|
|
171
|
+
inputEl: () => this.chatInputEl,
|
|
172
|
+
messagesEl: () => this.messagesEl,
|
|
173
|
+
});
|
|
174
|
+
this.composerMenus = new ComposerMenuController(this, {
|
|
175
|
+
agentPickerOpen: this.accessor('agentPickerOpen'),
|
|
176
|
+
sessionMenuOpen: this.accessor('sessionMenuOpen'),
|
|
177
|
+
focusInput: () => { var _a; return (_a = this.chatInputEl) === null || _a === void 0 ? void 0 : _a.focus(); },
|
|
178
|
+
resetSessionConfirmations: () => {
|
|
179
|
+
this.confirmingClear = false;
|
|
180
|
+
this.confirmingCompact = false;
|
|
181
|
+
},
|
|
182
|
+
logMeta: (type, detail) => this.logMeta(type, detail),
|
|
183
|
+
});
|
|
184
|
+
this.attachmentCtrl = new AttachmentController(this, {
|
|
185
|
+
acceptedFiles: () => { var _a; return (_a = this.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.acceptedFiles; },
|
|
186
|
+
logMeta: (type, detail) => this.logMeta(type, detail),
|
|
187
|
+
});
|
|
188
|
+
/**
|
|
189
|
+
* Pure derivation — owns no state. Reaches the driver only through the two
|
|
190
|
+
* callbacks below, so it stays clear of the stateful-agent surface (§11.2
|
|
191
|
+
* constraint 2) and of redux entirely.
|
|
192
|
+
*/
|
|
193
|
+
this.contextGateCtrl = new ContextGateController({
|
|
194
|
+
chatConfig: () => this.chatConfig,
|
|
195
|
+
messages: () => this.messages,
|
|
196
|
+
contextTokens: () => this.contextTokens,
|
|
197
|
+
contextLimit: () => this.contextLimit,
|
|
198
|
+
gateContextTokens: () => this._gateContextTokens,
|
|
199
|
+
contextReserve: () => this.contextReserve,
|
|
200
|
+
preambleTokens: () => { var _a, _b; return (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.getPreambleTokens) === null || _b === void 0 ? void 0 : _b.call(_a); },
|
|
201
|
+
compactionPlanFor: (options) => { var _a, _b, _c; return (_c = (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.getCompactionPlan) === null || _b === void 0 ? void 0 : _b.call(_a, options)) !== null && _c !== void 0 ? _c : null; },
|
|
202
|
+
bannerVisible: () => this.bannerVisible,
|
|
203
|
+
effectiveBlockedReason: () => this.effectiveBlockedReason,
|
|
204
|
+
flowOwnerAgentName: () => this.flowOwnerAgentName,
|
|
205
|
+
});
|
|
206
|
+
/**
|
|
207
|
+
* Cost accounting and the persisted build-history ledger (GENC-1475). Reads the live
|
|
208
|
+
* session through callbacks and owns the rows, the load/write latches and the debounce
|
|
209
|
+
* timer — the element keeps only the names the settings modal binds.
|
|
210
|
+
*/
|
|
211
|
+
this.costLedger = new CostLedgerController({
|
|
212
|
+
chatConfig: () => this.chatConfig,
|
|
213
|
+
messages: () => this.messages,
|
|
214
|
+
sessionUsage: () => this.sessionUsage,
|
|
215
|
+
providerStatuses: () => this.providerStatuses,
|
|
216
|
+
persistenceActive: () => this.persistenceActive,
|
|
217
|
+
stateKey: () => this.getStateKey(),
|
|
218
|
+
headerTitle: () => this.headerTitle,
|
|
219
|
+
provider: () => this.persistence.provider,
|
|
220
|
+
elementId: () => this.id,
|
|
221
|
+
});
|
|
222
|
+
/**
|
|
223
|
+
* The AI-spend budget: vendor walls, the derived composer lock, the banner copy
|
|
224
|
+
* and the settings meter (GENC-1464).
|
|
225
|
+
*
|
|
226
|
+
* Takes the session store itself rather than a callback per field — see
|
|
227
|
+
* `BudgetDeps` for why this one differs from its siblings.
|
|
228
|
+
*/
|
|
229
|
+
this.budget = new BudgetController({
|
|
230
|
+
sessionRef: () => this._sessionRef,
|
|
231
|
+
providerStatuses: () => this.providerStatuses,
|
|
232
|
+
providerRegistry: () => this.providerRegistry,
|
|
233
|
+
chatConfig: () => this.chatConfig,
|
|
234
|
+
});
|
|
235
|
+
/**
|
|
236
|
+
* The settings modal (`docs/code-health.md` §3). Reaches the two design-system
|
|
237
|
+
* refs through callbacks, because the template writes them by name — see
|
|
238
|
+
* `SettingsModalDeps`.
|
|
239
|
+
*/
|
|
240
|
+
this.settings = new SettingsModalController({
|
|
241
|
+
chatConfig: () => this.chatConfig,
|
|
242
|
+
contextTokens: () => this.contextTokens,
|
|
243
|
+
contextLimit: () => this.contextLimit,
|
|
244
|
+
budgetUsageVisible: () => this.budget.settingsUsageVisible,
|
|
245
|
+
costConfig: () => this.costLedger.config,
|
|
246
|
+
costHistoryLength: () => this.costLedger.history.length,
|
|
247
|
+
liveCostSessionTitle: () => this.costLedger.liveTitle,
|
|
248
|
+
providerStatusCount: () => this.providerStatuses.length,
|
|
249
|
+
sessionRef: () => this._sessionRef,
|
|
250
|
+
stateKey: () => this.getStateKey(),
|
|
251
|
+
popoutMode: () => this.popoutMode,
|
|
252
|
+
headerTitle: () => this.headerTitle,
|
|
253
|
+
appSettingsProvider: () => this.appSettingsProvider,
|
|
254
|
+
modal: () => this.settingsModal,
|
|
255
|
+
tabsEl: () => this.settingsTabsEl,
|
|
256
|
+
requestBudgetRefresh: () => this.requestBudgetRefresh(),
|
|
257
|
+
logMeta: (type, detail) => this.logMeta(type, detail),
|
|
258
|
+
});
|
|
332
259
|
this.designSystemPrefix = 'rapid';
|
|
333
260
|
this.headerTitle = 'Genesis Assistant';
|
|
334
261
|
/** Sanitized `assistantIcon`, or the default sparkle mark; bound into the template. @internal */
|
|
@@ -348,34 +275,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
348
275
|
this.persistence = {};
|
|
349
276
|
/** When set, enables Redux DevTools for this instance's session store. */
|
|
350
277
|
this.debugRedux = false;
|
|
351
|
-
/**
|
|
352
|
-
* One warning per element for garbage fed to
|
|
353
|
-
* {@link FoundationAiAssistant.setVendorBudget}. Once, not per call, because
|
|
354
|
-
* the documented feed re-runs after every turn — a host bug would otherwise
|
|
355
|
-
* repeat the same warning on every send for the life of the page.
|
|
356
|
-
*/
|
|
357
|
-
this.vendorBudgetWarningIssued = false;
|
|
358
|
-
/**
|
|
359
|
-
* When budget figures last arrived — from the host's {@link FoundationAiAssistant.setVendorBudget}
|
|
360
|
-
* or the wall latch's own feed — as epoch ms; `null` until the first feed.
|
|
361
|
-
*
|
|
362
|
-
* ELEMENT state, not slice state, on purpose: the reducer is idempotent by
|
|
363
|
-
* value (an unchanged re-feed must not re-render the meter rows), but "when
|
|
364
|
-
* was this fetched" must move on every feed, changed figures or not — a
|
|
365
|
-
* refresh that confirms the same dollars is still a refresh. Only the
|
|
366
|
-
* toolbar label observes this, so a stamp re-renders one span, not the rows.
|
|
367
|
-
*/
|
|
368
|
-
this.budgetMeterLastFedAt = null;
|
|
369
278
|
// ---- Transient UI state (stays as @observable on the component) ----
|
|
370
279
|
this._suggestionsGeneration = 0;
|
|
371
|
-
this.attachments = [];
|
|
372
|
-
this.attachmentErrors = [];
|
|
373
280
|
/** Whether the loading spinner is currently visible. Controlled by the loading delay timer. */
|
|
374
281
|
this.showLoadingIndicator = false;
|
|
375
|
-
/** Bump to re-render app settings toggles when the host provider notifies a change. */
|
|
376
|
-
this.appSettingsRevision = 0;
|
|
377
|
-
/** Whether the settings modal is open. */
|
|
378
|
-
this.settingsOpen = false;
|
|
379
282
|
/**
|
|
380
283
|
* True while `downloadDebugLog` is building the log (GENC-1351 §5.8). Drives the
|
|
381
284
|
* download button's disabled + spinner state — the lifetime-stitch path is async
|
|
@@ -384,8 +287,6 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
384
287
|
* element has its own settings modal.
|
|
385
288
|
*/
|
|
386
289
|
this.downloadingDebugLog = false;
|
|
387
|
-
/** Active tab inside the settings modal. */
|
|
388
|
-
this.settingsModalTab = 'settings';
|
|
389
290
|
/**
|
|
390
291
|
* GENC-1511 — persistence has failed repeatedly and it is no longer saving. Drives a BLOCKING
|
|
391
292
|
* modal with a single Reload action: the whole failure class this ticket exists for is one the
|
|
@@ -419,33 +320,8 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
419
320
|
this.brokenSources = new Map();
|
|
420
321
|
/** Swallows the native dialog's Escape route. Stored, not inline, so it can be removed again. */
|
|
421
322
|
this.onPersistenceModalCancel = (e) => e.preventDefault();
|
|
422
|
-
/** Light-DOM nodes assigned to settings modal slots (via `slotted()` bindings). */
|
|
423
|
-
this.settingsModelSlotted = [];
|
|
424
|
-
this.settingsAppSlotted = [];
|
|
425
|
-
this.settingsCostCurrentSlotted = [];
|
|
426
|
-
this.settingsCostHistorySlotted = [];
|
|
427
|
-
/** Persisted finalized cost sessions shown on the Usage tab build history list. */
|
|
428
|
-
this.costSessionHistory = [];
|
|
429
323
|
/** Whether the splash overlay is currently showing (no messages and showSplash is enabled). Reflected as a boolean attribute on the host. */
|
|
430
324
|
this.showingSplash = false;
|
|
431
|
-
/** Title tracked for the in-flight cost session (used when `header-title` changes). */
|
|
432
|
-
this._activeCostSessionTitle = '';
|
|
433
|
-
// NOTE: the pre-transcript ("banked") spend is deliberately NOT held here. It is keyed by
|
|
434
|
-
// session in `banked-usage-baselines` so the many element instances a single session sees —
|
|
435
|
-
// layout re-renders, popout, docking — share one baseline. See `bankedUsageFor`.
|
|
436
|
-
/**
|
|
437
|
-
* Whether the row list reflects storage yet. Gates cost writes so a project's banked
|
|
438
|
-
* spend is never derived from a ledger that simply hasn't arrived.
|
|
439
|
-
*/
|
|
440
|
-
this._costHistoryLoaded = false;
|
|
441
|
-
/**
|
|
442
|
-
* Set when a provider read failed. Latches writes off for this page-load: `saveCostHistory`
|
|
443
|
-
* replaces the whole stored list, so writing anything derived from the empty list we fell back
|
|
444
|
-
* to would destroy rows we never managed to read.
|
|
445
|
-
*/
|
|
446
|
-
this._ledgerUnreadable = false;
|
|
447
|
-
/** Pending (adopted but unwritten) ledger changes — see `persistCostHistory`. */
|
|
448
|
-
this._ledgerDirty = false;
|
|
449
325
|
// No once-guard for cost-session writes: the write is an upsert keyed by the session
|
|
450
326
|
// key, so repeat calls (pagehide, title change, per-turn) replace the same row and are
|
|
451
327
|
// idempotent. The old flag existed only because the write appended and reset.
|
|
@@ -453,26 +329,11 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
453
329
|
// the cost session on tab close — there is no hidden twin to defer to, so this fires
|
|
454
330
|
// unconditionally (the old bubble-vs-docked gating flag is gone with the two-view model).
|
|
455
331
|
this._handlePageHide = () => {
|
|
456
|
-
this.
|
|
332
|
+
this.costLedger.finalize();
|
|
457
333
|
// Straight to storage: the debounce timer will never fire once the page is going away.
|
|
458
|
-
this.
|
|
334
|
+
this.costLedger.flush();
|
|
459
335
|
};
|
|
460
336
|
this.haloStartPublished = false;
|
|
461
|
-
/**
|
|
462
|
-
* Explicit composer height (px) once the user has dragged the resize handle;
|
|
463
|
-
* `null` keeps the default auto-grow behaviour. Written into the
|
|
464
|
-
* `--composer-height` custom property via the `composerHeightStyle` getter.
|
|
465
|
-
*/
|
|
466
|
-
this.composerHeightPx = null;
|
|
467
|
-
/** Pointer Y and composer height captured at the start of a resize drag. */
|
|
468
|
-
this._resizeStartY = 0;
|
|
469
|
-
this._resizeStartHeight = 0;
|
|
470
|
-
/** The composer's natural (un-resized) height, measured on first drag — used
|
|
471
|
-
* as the lower bound so the box can't be dragged smaller than it started. */
|
|
472
|
-
this._composerNaturalHeight = 0;
|
|
473
|
-
/** Upper bound for the current drag — capped to the headroom above so growing
|
|
474
|
-
* the composer never pushes the input row's buttons off the bottom. */
|
|
475
|
-
this._resizeMaxHeight = COMPOSER_MAX_HEIGHT_PX;
|
|
476
337
|
/**
|
|
477
338
|
* True when the user's last interaction (pointer or keyboard focus) was inside
|
|
478
339
|
* the assistant. Defaults to true so the assistant claims focus on first mount.
|
|
@@ -536,16 +397,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
536
397
|
// one session (bubble + popped-out panel) and must share cursors, or they'd
|
|
537
398
|
// double-append the stream.
|
|
538
399
|
this.showHalo = 'no';
|
|
539
|
-
this._agentPickerToggle = new AnimatedPanelToggle(this, '.agent-picker-panel', AGENT_PICKER_CLOSE_MS, () => this.agentPickerOpen, (v) => {
|
|
540
|
-
this.agentPickerOpen = v;
|
|
541
|
-
});
|
|
542
400
|
// ── Session lifecycle menu (GENC-1351 §5.5) ─────────────────────────────────
|
|
543
401
|
/** Two-step confirm for the destructive Clear action; reset whenever the menu toggles. */
|
|
544
402
|
this.confirmingClear = false;
|
|
545
403
|
this.confirmingCompact = false;
|
|
546
|
-
this._sessionMenuToggle = new AnimatedPanelToggle(this, '.session-menu-panel', SESSION_MENU_CLOSE_MS, () => this.sessionMenuOpen, (v) => {
|
|
547
|
-
this.sessionMenuOpen = v;
|
|
548
|
-
});
|
|
549
404
|
}
|
|
550
405
|
assistantIconChanged() {
|
|
551
406
|
this.assistantIconSafe = this.assistantIcon
|
|
@@ -838,7 +693,13 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
838
693
|
set agentPickerOpen(value) {
|
|
839
694
|
var _a;
|
|
840
695
|
(_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setAgentPickerOpen(value);
|
|
841
|
-
this
|
|
696
|
+
// The sync belongs HERE, not in the controller, because this setter is the
|
|
697
|
+
// bottom of every write path: the controller drives the flag through an
|
|
698
|
+
// `Accessor` over this property, so its writes land here too — and so does a
|
|
699
|
+
// host's `el.agentPickerOpen = true`. Syncing in the controller instead left a
|
|
700
|
+
// host write opening the panel with no outside-click dismissal, no Escape and
|
|
701
|
+
// no reposition-on-resize (PR #2503 review).
|
|
702
|
+
this.composerMenus.syncDismissal();
|
|
842
703
|
}
|
|
843
704
|
/**
|
|
844
705
|
* Whether the lifecycle (session) menu slide-out panel is open. Like the
|
|
@@ -851,7 +712,8 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
851
712
|
set sessionMenuOpen(value) {
|
|
852
713
|
var _a;
|
|
853
714
|
(_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setSessionMenuOpen(value);
|
|
854
|
-
|
|
715
|
+
// See `agentPickerOpen`'s setter — same invariant, same reason.
|
|
716
|
+
this.composerMenus.syncDismissal();
|
|
855
717
|
}
|
|
856
718
|
/**
|
|
857
719
|
* Whether a manual compaction is in flight (GENC-1351 §5.7). On the session
|
|
@@ -922,77 +784,23 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
922
784
|
* @beta
|
|
923
785
|
*/
|
|
924
786
|
get blocked() {
|
|
925
|
-
|
|
926
|
-
const session = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant;
|
|
927
|
-
if (!session)
|
|
928
|
-
return false;
|
|
929
|
-
if (session.blocked)
|
|
930
|
-
return true;
|
|
931
|
-
// Byte-identical to the old stored read for every host that never adopts the
|
|
932
|
-
// per-vendor API — which is what makes this derivation non-breaking.
|
|
933
|
-
if (session.blockedVendors.length === 0)
|
|
934
|
-
return false;
|
|
935
|
-
// Unknown reachability (statuses not loaded yet, or providers that report no
|
|
936
|
-
// status) fails SAFE — any wall blocks everything, exactly today's semantics —
|
|
937
|
-
// and needs no clause of its own: `every` on the empty set is vacuously true.
|
|
938
|
-
return this.reachableVendors.every((v) => session.blockedVendors.includes(v));
|
|
787
|
+
return this.budget.blocked;
|
|
939
788
|
}
|
|
940
789
|
set blocked(value) {
|
|
941
|
-
|
|
942
|
-
(_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlocked({ blocked: value });
|
|
790
|
+
this.budget.blocked = value;
|
|
943
791
|
}
|
|
944
792
|
/**
|
|
945
793
|
* The distinct vendors the registry can currently reach, from the provider
|
|
946
794
|
* statuses this element already loads on connect and refreshes on every
|
|
947
|
-
* observable-registry change
|
|
948
|
-
*
|
|
949
|
-
*
|
|
950
|
-
*
|
|
951
|
-
* provider is resolved per turn AND per agent: `activeProviderInput` may be an
|
|
952
|
-
* async function of the turn's context, an orchestrated turn picks its agent
|
|
953
|
-
* with an LLM `classify()` call, and sub-agents resolve their own providers.
|
|
954
|
-
* Any pre-turn "peek" would therefore be a guess that is wrong precisely on the
|
|
955
|
-
* multi-agent hosts per-vendor budgets exist for.
|
|
956
|
-
*
|
|
957
|
-
* Asking instead which vendors are REACHABLE is answerable, synchronous, and
|
|
958
|
-
* free — and it is enough: the composer must stay live while any reachable
|
|
959
|
-
* vendor has headroom, and must lock when none does. It also makes vendor-switch
|
|
960
|
-
* recovery a derivation rather than a mutation: a host that swaps its registry
|
|
961
|
-
* to another vendor fires the observable, the statuses reload, the walled vendor
|
|
962
|
-
* drops out of this set, and `blocked` goes false with every latch left intact.
|
|
795
|
+
* observable-registry change — this element's answer to "which vendor would the
|
|
796
|
+
* next turn use", a question that has no correct answer and deliberately gets
|
|
797
|
+
* no API. See `BudgetController.reachableVendors` for why reachability is
|
|
798
|
+
* the answerable question and what deriving from it buys.
|
|
963
799
|
*
|
|
964
800
|
* @beta
|
|
965
801
|
*/
|
|
966
802
|
get reachableVendors() {
|
|
967
|
-
|
|
968
|
-
const seen = new Set();
|
|
969
|
-
for (const entry of this.providerStatuses) {
|
|
970
|
-
const provider = (_a = entry.status) === null || _a === void 0 ? void 0 : _a.provider;
|
|
971
|
-
if (!provider || provider === 'none')
|
|
972
|
-
continue;
|
|
973
|
-
// Reachable means "can serve the next turn", and `chat()` is OPTIONAL on
|
|
974
|
-
// `AIProvider` — the shipped `ChromeProvider` and `OpenAIProvider`
|
|
975
|
-
// implement `getStatus()` only. Counting a chat-less provider here made
|
|
976
|
-
// every consumer wrong at once: it was headroom for the `blocked`
|
|
977
|
-
// derivation (so `otherVendorAvailable: false` failed to lock the
|
|
978
|
-
// composer and each send burned another doomed 402), and it was a switch
|
|
979
|
-
// target for the banner ("Switch to Chrome to keep going" toward a
|
|
980
|
-
// provider that cannot chat).
|
|
981
|
-
//
|
|
982
|
-
// The gate is CAPABILITY, not vendor identity, so a host's custom
|
|
983
|
-
// chat-capable OpenAI provider still counts — the same distinction
|
|
984
|
-
// `validate-providers.ts` already enforces by throwing. And it drops a
|
|
985
|
-
// vendor only on positive evidence: statuses normally come from
|
|
986
|
-
// `providerRegistry.listStatuses()`, whose `name` always resolves, but a
|
|
987
|
-
// host (or test) that assigns `providerStatuses` directly may have no
|
|
988
|
-
// matching registry entry, and an unresolvable name must not silently
|
|
989
|
-
// erase a vendor the status layer vouched for.
|
|
990
|
-
const registered = (_b = this.providerRegistry) === null || _b === void 0 ? void 0 : _b.get(entry.name);
|
|
991
|
-
if (registered && typeof registered.chat !== 'function')
|
|
992
|
-
continue;
|
|
993
|
-
seen.add(provider);
|
|
994
|
-
}
|
|
995
|
-
return [...seen];
|
|
803
|
+
return this.budget.reachableVendors;
|
|
996
804
|
}
|
|
997
805
|
/**
|
|
998
806
|
* Vendors currently walled by the AI-spend budget, in the order they were
|
|
@@ -1000,13 +808,11 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1000
808
|
*
|
|
1001
809
|
* May include a vendor this registry cannot reach — either because the registry
|
|
1002
810
|
* moved on after the wall was latched, or because a 402's
|
|
1003
|
-
* `otherVendorAvailable: false` walls every vendor the proxy meters
|
|
1004
|
-
*
|
|
1005
|
-
*
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1008
|
-
* {@link FoundationAiAssistant.relevantBlockedVendors} — but a host rendering
|
|
1009
|
-
* this list itself should filter it against its own registry.
|
|
811
|
+
* `otherVendorAvailable: false` walls every vendor the proxy meters. Neither
|
|
812
|
+
* costs anything internally — {@link FoundationAiAssistant.blocked} asks only
|
|
813
|
+
* about the reachable set, and the banner reads the reachability-filtered
|
|
814
|
+
* `BudgetController.relevantBlockedVendors` — but a host rendering this
|
|
815
|
+
* list itself should filter it against its own registry.
|
|
1010
816
|
*
|
|
1011
817
|
* Read-only on purpose: a settable array would let a host write a partial list
|
|
1012
818
|
* and silently orphan the per-vendor banner copy.
|
|
@@ -1015,8 +821,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1015
821
|
* @beta
|
|
1016
822
|
*/
|
|
1017
823
|
get blockedVendors() {
|
|
1018
|
-
|
|
1019
|
-
return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.blockedVendors) !== null && _b !== void 0 ? _b : [];
|
|
824
|
+
return this.budget.blockedVendors;
|
|
1020
825
|
}
|
|
1021
826
|
/**
|
|
1022
827
|
* Whether this specific vendor's budget is walled — regardless of whether any
|
|
@@ -1025,7 +830,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1025
830
|
* @beta
|
|
1026
831
|
*/
|
|
1027
832
|
isVendorBlocked(vendor) {
|
|
1028
|
-
return this.
|
|
833
|
+
return this.budget.isVendorBlocked(vendor);
|
|
1029
834
|
}
|
|
1030
835
|
/**
|
|
1031
836
|
* Wall (or release) one vendor's budget, optionally with banner copy for it.
|
|
@@ -1037,35 +842,21 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1037
842
|
* become true.
|
|
1038
843
|
*
|
|
1039
844
|
* `reason` is composed into the banner sentence for that vendor, **however many
|
|
1040
|
-
* vendors are walled** — see
|
|
1041
|
-
* It is per-vendor copy, not a whole-banner override;
|
|
1042
|
-
* is the override. Omitting it keeps
|
|
1043
|
-
* carried, so a driver latch landing
|
|
845
|
+
* vendors are walled** — see `BudgetController.effectiveBlockedReason`.
|
|
846
|
+
* It is per-vendor copy, not a whole-banner override;
|
|
847
|
+
* {@link FoundationAiAssistant.blockedReason} is the override. Omitting it keeps
|
|
848
|
+
* whatever explanation that vendor already carried, so a driver latch landing
|
|
849
|
+
* after a host one cannot blank it.
|
|
1044
850
|
*
|
|
1045
|
-
* `'none'` is rejected with a warning rather than accepted
|
|
1046
|
-
* provider configured" sentinel, not a vendor
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1049
|
-
* {@link FoundationAiAssistant.reachableVendors} — so `blocked` could never
|
|
1050
|
-
* become derivable from it either. Use {@link FoundationAiAssistant.setBlocked}
|
|
1051
|
-
* for a vendor-agnostic block.
|
|
851
|
+
* `'none'` is rejected with a warning rather than accepted — it is the "no
|
|
852
|
+
* provider configured" sentinel, not a vendor, and a wall on it could never be
|
|
853
|
+
* undone by derivation. Use {@link FoundationAiAssistant.setBlocked} for a
|
|
854
|
+
* vendor-agnostic block.
|
|
1052
855
|
*
|
|
1053
856
|
* @beta
|
|
1054
857
|
*/
|
|
1055
858
|
setVendorBlocked(vendor, blocked, reason) {
|
|
1056
|
-
|
|
1057
|
-
if (vendor === 'none') {
|
|
1058
|
-
logger.warn("FoundationAiAssistant.setVendorBlocked: 'none' is the no-provider sentinel, not a vendor — " +
|
|
1059
|
-
'ignoring. Use setBlocked() for a vendor-agnostic block.');
|
|
1060
|
-
return;
|
|
1061
|
-
}
|
|
1062
|
-
(_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setVendorBlocked({ vendor, blocked, reason });
|
|
1063
|
-
}
|
|
1064
|
-
warnVendorBudgetOnce(message) {
|
|
1065
|
-
if (this.vendorBudgetWarningIssued)
|
|
1066
|
-
return;
|
|
1067
|
-
this.vendorBudgetWarningIssued = true;
|
|
1068
|
-
logger.warn(`FoundationAiAssistant.setVendorBudget: ${message}`);
|
|
859
|
+
this.budget.setVendorBlocked(vendor, blocked, reason);
|
|
1069
860
|
}
|
|
1070
861
|
/**
|
|
1071
862
|
* Feed (or clear) one vendor's AI-spend figures for the settings-modal budget
|
|
@@ -1125,45 +916,28 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1125
916
|
* @beta
|
|
1126
917
|
*/
|
|
1127
918
|
setVendorBudget(vendor, figures) {
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
// is nowhere to write, so say so rather than returning an accepted the
|
|
1147
|
-
// caller would never retry. Not warned — this is a lifecycle window, not
|
|
1148
|
-
// bad input, and the documented per-turn re-feed lands the figures as soon
|
|
1149
|
-
// as the store exists.
|
|
1150
|
-
if (!session)
|
|
1151
|
-
return false;
|
|
1152
|
-
session.actions.aiAssistant.setVendorBudget({
|
|
1153
|
-
vendor: vendor,
|
|
1154
|
-
// A fresh literal, never the caller's object — see the reducer, which
|
|
1155
|
-
// must not adopt (and potentially freeze) state the host still owns.
|
|
1156
|
-
figures: figures == null ? null : { budgetUsd: figures.budgetUsd, spentUsd: figures.spentUsd },
|
|
1157
|
-
});
|
|
1158
|
-
if (figures != null)
|
|
1159
|
-
this.budgetMeterLastFedAt = Date.now();
|
|
1160
|
-
return true;
|
|
919
|
+
return this.budget.setVendorBudget(vendor, figures);
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* When budget figures last arrived — from the host's
|
|
923
|
+
* {@link FoundationAiAssistant.setVendorBudget} or the wall latch's own feed —
|
|
924
|
+
* as epoch ms; `null` until the first feed.
|
|
925
|
+
*
|
|
926
|
+
* ELEMENT state, not slice state, on purpose: the reducer is idempotent by
|
|
927
|
+
* value (an unchanged re-feed must not re-render the meter rows), but "when
|
|
928
|
+
* was this fetched" must move on every feed, changed figures or not — a
|
|
929
|
+
* refresh that confirms the same dollars is still a refresh. Only the
|
|
930
|
+
* toolbar label observes this, so a stamp re-renders one span, not the rows.
|
|
931
|
+
*/
|
|
932
|
+
get budgetMeterLastFedAt() {
|
|
933
|
+
return this.budget.meterLastFedAt;
|
|
934
|
+
}
|
|
935
|
+
set budgetMeterLastFedAt(value) {
|
|
936
|
+
this.budget.meterLastFedAt = value;
|
|
1161
937
|
}
|
|
1162
938
|
/** "Updated HH:MM:SS" for the meter toolbar, or `undefined` before any feed. */
|
|
1163
939
|
get settingsBudgetUpdatedLabel() {
|
|
1164
|
-
|
|
1165
|
-
return undefined;
|
|
1166
|
-
return `Updated ${new Date(this.budgetMeterLastFedAt).toLocaleTimeString()}`;
|
|
940
|
+
return this.budget.updatedLabel;
|
|
1167
941
|
}
|
|
1168
942
|
/**
|
|
1169
943
|
* Ask the host to re-fetch budget figures now (GENC-1464 refresh affordance).
|
|
@@ -1185,113 +959,27 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1185
959
|
// gc-assistant's), and a delegation-based host must still see the request.
|
|
1186
960
|
this.dispatchEvent(new CustomEvent('budget-refresh-requested', { bubbles: true, composed: true }));
|
|
1187
961
|
}
|
|
1188
|
-
/** Whether this vendor's wall came from the sweep alone — see the slice's `sweptVendors`. */
|
|
1189
|
-
isVendorSwept(vendor) {
|
|
1190
|
-
var _a, _b;
|
|
1191
|
-
return ((_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.sweptVendors) !== null && _b !== void 0 ? _b : []).includes(vendor);
|
|
1192
|
-
}
|
|
1193
|
-
/**
|
|
1194
|
-
* The walled vendors the user can still be routed to — i.e.
|
|
1195
|
-
* {@link FoundationAiAssistant.blockedVendors} narrowed to the reachable set,
|
|
1196
|
-
* which is the only set the banner may name.
|
|
1197
|
-
*
|
|
1198
|
-
* A wall the registry can no longer reach is not news: it cannot be hit, and
|
|
1199
|
-
* naming it puts a vendor in front of the user that is not theirs. The concrete
|
|
1200
|
-
* failure this exists to stop: a host ships Anthropic-only, Anthropic walls, the
|
|
1201
|
-
* host swaps its registry to Gemini, Gemini walls — and the banner reads "AI
|
|
1202
|
-
* usage limits are reached for Anthropic and Gemini" to a user who has never
|
|
1203
|
-
* had an Anthropic key. The spurious second name also flips the copy onto the
|
|
1204
|
-
* plural branch, so even the closing sentence is wrong.
|
|
1205
|
-
*
|
|
1206
|
-
* When nothing is reachable YET (the statuses are still loading — the exact
|
|
1207
|
-
* window the pre-flight and the cross-tab bus latch into), the fallback keeps
|
|
1208
|
-
* every wall for LOCKING (`blocked` still derives true) but names only the
|
|
1209
|
-
* vendors walled by a REFUSAL, dropping the ones the `otherVendorAvailable`
|
|
1210
|
-
* sweep added. A refusal is a fact about a vendor this user just used; a sweep
|
|
1211
|
-
* entry is the proxy's headroom verdict about a vendor the host may not even
|
|
1212
|
-
* ship — naming it here reproduced the exact failure above from the other
|
|
1213
|
-
* direction ("Gemini's AI usage limit is reached" to an Anthropic-only user),
|
|
1214
|
-
* and flipped the copy onto the plural branch with it. Self-corrects when the
|
|
1215
|
-
* statuses land: from then on reachability, not provenance, decides.
|
|
1216
|
-
*
|
|
1217
|
-
* @internal
|
|
1218
|
-
*/
|
|
1219
|
-
get relevantBlockedVendors() {
|
|
1220
|
-
var _a, _b;
|
|
1221
|
-
const walled = this.blockedVendors;
|
|
1222
|
-
const reachable = this.reachableVendors;
|
|
1223
|
-
if (reachable.length === 0) {
|
|
1224
|
-
const swept = (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.sweptVendors) !== null && _b !== void 0 ? _b : [];
|
|
1225
|
-
return walled.filter((v) => !swept.includes(v));
|
|
1226
|
-
}
|
|
1227
|
-
return walled.filter((v) => reachable.includes(v));
|
|
1228
|
-
}
|
|
1229
962
|
/**
|
|
1230
963
|
* Whether the blocked banner has anything to say — a vendor-agnostic block, or
|
|
1231
|
-
* a partial wall that the NEXT send would actually hit.
|
|
1232
|
-
*
|
|
1233
|
-
*
|
|
1234
|
-
* default the next send resolves to) is among the walled ones. A user who has
|
|
1235
|
-
* switched to a vendor with headroom has already acted on the advice —
|
|
1236
|
-
* keeping the banner up past that point nagged about a vendor they just left
|
|
1237
|
-
* (GENC-1464 tester feedback), while switching BACK to the walled vendor
|
|
1238
|
-
* brings the banner straight back. Two conservative fallbacks keep it
|
|
1239
|
-
* fail-informative: while the statuses are still loading the active vendor is
|
|
1240
|
-
* unknowable, so any reachable-and-walled vendor still announces; and a
|
|
1241
|
-
* vendor-agnostic block announces always, exactly as before.
|
|
1242
|
-
*
|
|
1243
|
-
* Reads the reachability-filtered list for the same reason the copy does — a
|
|
1244
|
-
* host that has swapped its registry away from the walled vendor has nothing
|
|
1245
|
-
* left to announce, and would otherwise get a banner that falls through to the
|
|
1246
|
-
* generic default copy over a perfectly usable composer.
|
|
964
|
+
* a partial wall that the NEXT send would actually hit. See
|
|
965
|
+
* `BudgetController.bannerVisible` for why partial exhaustion is
|
|
966
|
+
* announced only while the active vendor is among the walled ones.
|
|
1247
967
|
*
|
|
1248
968
|
* @internal
|
|
1249
969
|
*/
|
|
1250
970
|
get bannerVisible() {
|
|
1251
|
-
|
|
1252
|
-
return true;
|
|
1253
|
-
const walled = this.relevantBlockedVendors;
|
|
1254
|
-
if (walled.length === 0)
|
|
1255
|
-
return false;
|
|
1256
|
-
const active = this.activeVendor;
|
|
1257
|
-
if (!active)
|
|
1258
|
-
return true;
|
|
1259
|
-
return walled.includes(active);
|
|
971
|
+
return this.budget.bannerVisible;
|
|
1260
972
|
}
|
|
1261
973
|
/**
|
|
1262
|
-
* Whether a suggestions fetch would hit a wall
|
|
1263
|
-
*
|
|
1264
|
-
*
|
|
1265
|
-
*
|
|
1266
|
-
* (`ChatDriver.getSuggestions` calls `providerRegistry.default()`, and
|
|
1267
|
-
* `OrchestratingDriver.getSuggestions` just delegates to it). So the vendor a
|
|
1268
|
-
* suggestions call would use is knowable, and this is the one place in the
|
|
1269
|
-
* feature where that is true.
|
|
1270
|
-
*
|
|
1271
|
-
* Same rationale as the guard it replaces: a doomed suggestions call burns a
|
|
1272
|
-
* `suggestions.failed` meta event and parks a raw transport error in
|
|
1273
|
-
* `suggestionsState` for hosts to find.
|
|
974
|
+
* Whether a suggestions fetch would hit a wall — the one place in this feature
|
|
975
|
+
* where the vendor a call would use is exactly knowable, because both
|
|
976
|
+
* suggestion paths go to the registry default. See
|
|
977
|
+
* `BudgetController.suggestionsBlocked`.
|
|
1274
978
|
*
|
|
1275
979
|
* @internal
|
|
1276
980
|
*/
|
|
1277
981
|
get suggestionsBlocked() {
|
|
1278
|
-
|
|
1279
|
-
return true;
|
|
1280
|
-
const active = this.activeVendor;
|
|
1281
|
-
return !!active && this.isVendorBlocked(active);
|
|
1282
|
-
}
|
|
1283
|
-
/**
|
|
1284
|
-
* The vendor the NEXT send resolves to — the registry default's provider, as
|
|
1285
|
-
* reported through `providerStatuses`. `undefined` while the statuses are
|
|
1286
|
-
* still loading (or when the default reports the `'none'` sentinel), which
|
|
1287
|
-
* every consumer treats conservatively.
|
|
1288
|
-
*
|
|
1289
|
-
* @internal
|
|
1290
|
-
*/
|
|
1291
|
-
get activeVendor() {
|
|
1292
|
-
var _a, _b;
|
|
1293
|
-
const vendor = (_b = (_a = this.providerStatuses.find((e) => e.isDefault)) === null || _a === void 0 ? void 0 : _a.status) === null || _b === void 0 ? void 0 : _b.provider;
|
|
1294
|
-
return vendor && vendor !== 'none' ? vendor : undefined;
|
|
982
|
+
return this.budget.suggestionsBlocked;
|
|
1295
983
|
}
|
|
1296
984
|
/**
|
|
1297
985
|
* Explanation shown in the blocked banner, or `null` for the element's default
|
|
@@ -1308,185 +996,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1308
996
|
* @beta
|
|
1309
997
|
*/
|
|
1310
998
|
get blockedReason() {
|
|
1311
|
-
|
|
1312
|
-
return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.blockedReason) !== null && _b !== void 0 ? _b : null;
|
|
999
|
+
return this.budget.blockedReason;
|
|
1313
1000
|
}
|
|
1314
1001
|
set blockedReason(value) {
|
|
1315
|
-
|
|
1316
|
-
// Deliberately NOT `setBlocked({ blocked: this.blocked, reason })`: `blocked`
|
|
1317
|
-
// is derived now, so re-latching with it would pass `false` for a partially
|
|
1318
|
-
// walled session and clear every per-vendor latch as a side effect of setting
|
|
1319
|
-
// a string.
|
|
1320
|
-
(_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlockedReason(value);
|
|
1321
|
-
}
|
|
1322
|
-
/**
|
|
1323
|
-
* Latch the backend block off a turn outcome. The single decision point for
|
|
1324
|
-
* both latch sites (the `tool-loop-end` bus subscription and `send()`'s
|
|
1325
|
-
* return-value check), so the rule cannot diverge between them.
|
|
1326
|
-
*
|
|
1327
|
-
* Idempotent and one-way **per vendor**: it never unblocks, and it never
|
|
1328
|
-
* re-writes an existing wall. That guard is load-bearing, not just an
|
|
1329
|
-
* optimisation — the bus fires from the driver's `finally`, i.e. BEFORE
|
|
1330
|
-
* `sendMessage()` resolves, so a host subscribed to `tool-loop-end` (what the
|
|
1331
|
-
* migration guide's Option C recommends) sets its own detailed reason and the
|
|
1332
|
-
* return-value latch would otherwise land a moment later and blank it.
|
|
1333
|
-
*
|
|
1334
|
-
* The guard being per-vendor rather than global is the whole difference. A
|
|
1335
|
-
* single global "already blocked, do nothing" would swallow a second vendor's
|
|
1336
|
-
* wall — so a session walled on Anthropic could never record that Gemini went
|
|
1337
|
-
* too, and under a mixed registry an Anthropic-only wall would lock a composer
|
|
1338
|
-
* that Gemini could still serve.
|
|
1339
|
-
*
|
|
1340
|
-
* **Which field is authoritative**, in order:
|
|
1341
|
-
*
|
|
1342
|
-
* 1. `budget.vendorLabel` — stamped by the transport that was actually refused,
|
|
1343
|
-
* so it can never be stale. But it is a static per-transport string, so a
|
|
1344
|
-
* white-labelled or multiplexing gateway fronting several upstreams leaves
|
|
1345
|
-
* it unclaimed by any vendor.
|
|
1346
|
-
* 2. `budget.vendor` — which the driver resolved from the label where it could,
|
|
1347
|
-
* and otherwise from the proxy's own `vendor` field on the 402. It is
|
|
1348
|
-
* therefore NOT simply `vendorLabel` normalised, and the two can disagree;
|
|
1349
|
-
* that fallback is the only attribution on offer for the gateway case above.
|
|
1350
|
-
* 3. `vendorHint` — the bus detail's top-level vendor, i.e. the driver's
|
|
1351
|
-
* last-resolved provider. Last resort because it CAN be stale: an
|
|
1352
|
-
* orchestrated turn classifies against the registry default, a provider the
|
|
1353
|
-
* chat driver may never have resolved, so its last-resolved provider there is
|
|
1354
|
-
* the previous turn's vendor or nothing at all.
|
|
1355
|
-
*
|
|
1356
|
-
* A wall that names no recognised vendor falls back to the vendor-agnostic
|
|
1357
|
-
* block — fail-safe, and identical to the pre-per-vendor behaviour.
|
|
1358
|
-
*
|
|
1359
|
-
* **`otherVendorAvailable: false` is server-known truth and outranks every
|
|
1360
|
-
* inference made here.** The proxy meters the pots, so only it can say whether
|
|
1361
|
-
* anything else has headroom; this element can only observe which vendors the
|
|
1362
|
-
* registry can REACH, which says nothing about their remaining spend. So when
|
|
1363
|
-
* the proxy says no, every other *budgeted* vendor is walled in the same pass.
|
|
1364
|
-
* That is what makes `blocked` derive true — locking the composer on the first
|
|
1365
|
-
* response instead of after a second doomed turn — and what empties the "free"
|
|
1366
|
-
* set the banner would otherwise have advised switching to.
|
|
1367
|
-
*
|
|
1368
|
-
* The sweep runs over `BUDGETED_VENDORS`, deliberately **not** over
|
|
1369
|
-
* {@link FoundationAiAssistant.reachableVendors}. Reachability is loaded
|
|
1370
|
-
* asynchronously (`activateSession` kicks off `loadProviderStatuses()` and does
|
|
1371
|
-
* not await it), so a wall landing before the statuses resolve would have swept
|
|
1372
|
-
* an empty set — silently discarding the one fact on the 402 the client cannot
|
|
1373
|
-
* re-derive, with nothing to re-run it when the statuses arrived. Worse, it let
|
|
1374
|
-
* the verdict effectively un-latch: the single wall derived `blocked` only
|
|
1375
|
-
* while the reachable set was empty, so the moment the statuses landed the
|
|
1376
|
-
* composer came back to life against a budget the proxy had already said was
|
|
1377
|
-
* gone. Sweeping the metered vendor list instead makes the verdict independent
|
|
1378
|
-
* of load order and of registry membership, which is exactly what it is: a
|
|
1379
|
-
* statement about the proxy's pots, not about this user's registry.
|
|
1380
|
-
*
|
|
1381
|
-
* Walling a vendor the registry cannot reach costs nothing: `blocked` only asks
|
|
1382
|
-
* whether every REACHABLE vendor is walled, and the banner reads
|
|
1383
|
-
* {@link FoundationAiAssistant.relevantBlockedVendors}, which filters the walls
|
|
1384
|
-
* back down to the reachable set before naming any of them.
|
|
1385
|
-
*
|
|
1386
|
-
* One boundary remains on the sweep: it walls only vendors the proxy meters,
|
|
1387
|
-
* enforced structurally by iterating `BUDGETED_VENDORS` itself rather than
|
|
1388
|
-
* filtering a wider set through a predicate. Chrome runs on-device with no pot
|
|
1389
|
-
* to exhaust, so "no other vendor has budget" is not a statement about it, and
|
|
1390
|
-
* "Switch to Chrome to keep going." stays honest advice.
|
|
1391
|
-
*
|
|
1392
|
-
* The sweep runs last but sits OUTSIDE the per-vendor idempotence guard, and
|
|
1393
|
-
* both halves of that matter. Last, so the vendor that actually refused this
|
|
1394
|
-
* turn heads the wall order the multi-vendor banner reads. Outside the guard,
|
|
1395
|
-
* because the verdict can arrive on a LATER 402 for a vendor that is already
|
|
1396
|
-
* walled — turn 1 walls Anthropic while Gemini still has headroom, turn 2 walls
|
|
1397
|
-
* it again and reports that Gemini has since gone too — and an early return
|
|
1398
|
-
* would drop exactly the news the second turn was there to deliver.
|
|
1399
|
-
*
|
|
1400
|
-
* @param reason - the turn's failure reason; anything but `'budget-exhausted'` is ignored.
|
|
1401
|
-
* @param ref - session store to write through; defaults to the live one. `send()`
|
|
1402
|
-
* passes the ref it captured before its awaits, since a lifecycle event during the
|
|
1403
|
-
* turn may already have cleared `_sessionRef`.
|
|
1404
|
-
* @param budget - what the 402 reported: figures when the proxy sent any, the
|
|
1405
|
-
* refusing vendor, and its `otherVendorAvailable` verdict.
|
|
1406
|
-
* @param vendorHint - the `tool-loop-end` detail's top-level vendor, used only
|
|
1407
|
-
* when the budget payload names none.
|
|
1408
|
-
*
|
|
1409
|
-
* @internal
|
|
1410
|
-
*/
|
|
1411
|
-
latchBlockedFrom(reason, ref = this._sessionRef, budget, vendorHint) {
|
|
1412
|
-
var _a, _b;
|
|
1413
|
-
if (reason !== 'budget-exhausted')
|
|
1414
|
-
return;
|
|
1415
|
-
const vendor = (_b = (_a = vendorTypeOfLabel(budget === null || budget === void 0 ? void 0 : budget.vendorLabel)) !== null && _a !== void 0 ? _a : budget === null || budget === void 0 ? void 0 : budget.vendor) !== null && _b !== void 0 ? _b : vendorHint;
|
|
1416
|
-
// Meter feed (GENC-1464): snap this vendor's meter to the figures the 402
|
|
1417
|
-
// itself reported, the moment the wall lands. Hosts refresh the meter on
|
|
1418
|
-
// `tool-loop-end`, and a walled turn may never reach one — the user then
|
|
1419
|
-
// stares at the pre-turn figures next to a banner saying the budget is gone
|
|
1420
|
-
// until something else ends a loop (observed live: the meter only caught up
|
|
1421
|
-
// when the user cancelled the stuck interaction). Deliberately NOT under the
|
|
1422
|
-
// per-vendor idempotence guard below, for the same reason as the sweep: a
|
|
1423
|
-
// later 402 for an already-walled vendor carries fresher spend. Both figures
|
|
1424
|
-
// are required (`VendorBudgetFigures` is total, and half a meter is a lie),
|
|
1425
|
-
// and the write goes through the captured `ref` like every other write here,
|
|
1426
|
-
// so a teardown race cannot land it in a new session's store.
|
|
1427
|
-
let metered = false;
|
|
1428
|
-
if (vendor && vendor !== 'none' && (budget === null || budget === void 0 ? void 0 : budget.budgetUsd) != null && (budget === null || budget === void 0 ? void 0 : budget.spentUsd) != null) {
|
|
1429
|
-
ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBudget({
|
|
1430
|
-
vendor,
|
|
1431
|
-
figures: { budgetUsd: budget.budgetUsd, spentUsd: budget.spentUsd },
|
|
1432
|
-
});
|
|
1433
|
-
this.budgetMeterLastFedAt = Date.now();
|
|
1434
|
-
metered = true;
|
|
1435
|
-
}
|
|
1436
|
-
if (!vendor || vendor === 'none') {
|
|
1437
|
-
if (!this.blocked) {
|
|
1438
|
-
ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setBlocked({ blocked: true, reason: formatBlockedReason(budget) });
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
else if (!this.isVendorBlocked(vendor) || this.isVendorSwept(vendor)) {
|
|
1442
|
-
// The idempotence guard protects a host-supplied reason from being
|
|
1443
|
-
// overwritten by this latch's boilerplate — but a SWEPT wall cannot carry
|
|
1444
|
-
// one (the sweep writes no reason, and any host call clears the swept
|
|
1445
|
-
// mark), so upgrading it on a first-hand refusal clobbers nothing and
|
|
1446
|
-
// gains the vendor its own figures plus a place in the early-window
|
|
1447
|
-
// banner (see `relevantBlockedVendors`).
|
|
1448
|
-
if (this.reachableVendors.length === 0) {
|
|
1449
|
-
// Not fatal — `blocked` still derives to true, so the UI is correct — but
|
|
1450
|
-
// the host gets no per-vendor benefit and would otherwise have no signal.
|
|
1451
|
-
logger.warn(`FoundationAiAssistant: ${vendor} hit its AI budget, but no registered provider counts ` +
|
|
1452
|
-
'as reachable, so per-vendor blocking degrades to blocking everything. Two causes: no ' +
|
|
1453
|
-
'provider reports a status (implement AIProvider.getStatus()), or every provider that ' +
|
|
1454
|
-
'does is chat-less and so cannot serve a turn (implement chat() on at least one).');
|
|
1455
|
-
}
|
|
1456
|
-
ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBlocked({
|
|
1457
|
-
vendor,
|
|
1458
|
-
blocked: true,
|
|
1459
|
-
// No reason when the meter snap above already stored these figures: the
|
|
1460
|
-
// banner composes the same sentence from them, so carrying a copy here
|
|
1461
|
-
// would duplicate the dollars in two places that can drift apart. It would
|
|
1462
|
-
// also make this latch INDISTINGUISHABLE from host copy, which the banner
|
|
1463
|
-
// must preserve verbatim — and preserving boilerplate is what forced it to
|
|
1464
|
-
// repeat a sentence per walled vendor instead of folding them into one.
|
|
1465
|
-
// The partial-figures case still needs copy carried here: it has no meter
|
|
1466
|
-
// entry to compose from, and "an unknown amount of $50.00" is worth saying.
|
|
1467
|
-
reason: metered ? undefined : formatBlockedReason(budget, vendor),
|
|
1468
|
-
});
|
|
1469
|
-
}
|
|
1470
|
-
// Last, so the vendor that actually refused this turn heads the wall order the
|
|
1471
|
-
// multi-vendor banner reads — and unconditionally, NOT under the per-vendor
|
|
1472
|
-
// idempotence guard above, because the verdict can arrive on a later 402 for a
|
|
1473
|
-
// vendor that is already walled.
|
|
1474
|
-
if ((budget === null || budget === void 0 ? void 0 : budget.otherVendorAvailable) !== false)
|
|
1475
|
-
return;
|
|
1476
|
-
// Over the metered vendor list, NOT the reachable set: the statuses load
|
|
1477
|
-
// asynchronously, so a reachability-scoped sweep dropped the verdict entirely
|
|
1478
|
-
// when the wall beat them home. `relevantBlockedVendors` decides what the
|
|
1479
|
-
// banner may NAME — reachability-filtered when the statuses are in, and
|
|
1480
|
-
// refusals-only while they are not, which is what `swept: true` records.
|
|
1481
|
-
for (const other of BUDGETED_VENDORS) {
|
|
1482
|
-
if (other === vendor || this.isVendorBlocked(other))
|
|
1483
|
-
continue;
|
|
1484
|
-
// No reason: the slice's "an omitted reason keeps the existing explanation"
|
|
1485
|
-
// rule applies, and there are no figures for this vendor — only the proxy's
|
|
1486
|
-
// word that it has nothing left. `swept: true` records exactly that
|
|
1487
|
-
// provenance, so the unknown-reachability banner can decline to name it.
|
|
1488
|
-
ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBlocked({ vendor: other, blocked: true, swept: true });
|
|
1489
|
-
}
|
|
1002
|
+
this.budget.blockedReason = value;
|
|
1490
1003
|
}
|
|
1491
1004
|
/**
|
|
1492
1005
|
* Block or unblock the assistant, optionally with custom banner copy.
|
|
@@ -1502,119 +1015,26 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1502
1015
|
* @beta
|
|
1503
1016
|
*/
|
|
1504
1017
|
setBlocked(blocked, reason) {
|
|
1505
|
-
|
|
1506
|
-
(_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlocked({ blocked, reason });
|
|
1018
|
+
this.budget.setBlocked(blocked, reason);
|
|
1507
1019
|
}
|
|
1508
1020
|
/**
|
|
1509
|
-
* Copy shown in the blocked banner,
|
|
1510
|
-
*
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
1513
|
-
* otherwise their names) plus the action that is actually available;
|
|
1514
|
-
* 3. the default budget-exhaustion message.
|
|
1515
|
-
*
|
|
1516
|
-
* The action clause is computed here rather than stored, because whether
|
|
1517
|
-
* "switch vendor" or "contact your administrator" is honest depends on whether
|
|
1518
|
-
* another reachable vendor still has headroom — which changes when the registry
|
|
1519
|
-
* changes, long after the wall was latched. Telling a user to switch to a
|
|
1520
|
-
* vendor that is also exhausted is worse than saying nothing.
|
|
1521
|
-
*
|
|
1522
|
-
* The `free` set is a client-side inference — "reachable and not known to be
|
|
1523
|
-
* walled" — and it is only ever allowed to be one. Where the proxy has told us
|
|
1524
|
-
* otherwise (`otherVendorAvailable: false`) the latch has already walled those
|
|
1525
|
-
* vendors, so they are not in `free` here and no "switch to X" clause can be
|
|
1526
|
-
* composed from them. Both lists are filtered by reachability, so the copy
|
|
1527
|
-
* names only vendors this user's registry can actually route to.
|
|
1528
|
-
*
|
|
1529
|
-
* **Per-vendor copy is composed however many vendors are walled**, which is
|
|
1530
|
-
* what {@link FoundationAiAssistant.setVendorBlocked} and
|
|
1531
|
-
* `docs/migration-GENC-1464.md` both promise. It previously reached only the
|
|
1532
|
-
* single-vendor branch, so a host that had carefully set copy for each of its
|
|
1533
|
-
* vendors watched all of it vanish the moment a second one walled — replaced by
|
|
1534
|
-
* generic copy, at the exact moment the situation got worse.
|
|
1535
|
-
*
|
|
1536
|
-
* The compact list is the DEFAULT shape for several walls, now carrying each
|
|
1537
|
-
* vendor's figures: "AI usage limits are reached for Gemini ($62.34 of $50.00)
|
|
1538
|
-
* and Anthropic ($31.10 of $30.00)." A driver latch stores no copy of its own
|
|
1539
|
-
* (it stores the figures, which this composes from), so that shape is what the
|
|
1540
|
-
* real budget flow produces. As soon as any walled vendor carries HOST copy,
|
|
1541
|
-
* which cannot be folded into a list item, the branch lists one statement per
|
|
1542
|
-
* vendor instead — falling back to composed copy for the ones that have none, so
|
|
1543
|
-
* the sentence set stays complete rather than silently naming a subset.
|
|
1021
|
+
* Copy shown in the blocked banner — the host's override, else copy composed
|
|
1022
|
+
* from the walled vendors, else the default. See
|
|
1023
|
+
* `BudgetController.effectiveBlockedReason`, which explains why the
|
|
1024
|
+
* action clause is composed on read rather than stored at latch time.
|
|
1544
1025
|
*
|
|
1545
1026
|
* @internal
|
|
1546
1027
|
*/
|
|
1547
1028
|
get effectiveBlockedReason() {
|
|
1548
|
-
|
|
1549
|
-
if (this.blockedReason)
|
|
1550
|
-
return this.blockedReason;
|
|
1551
|
-
const walled = this.relevantBlockedVendors;
|
|
1552
|
-
if (walled.length === 0)
|
|
1553
|
-
return DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
|
|
1554
|
-
const free = this.reachableVendors.filter((v) => !walled.includes(v));
|
|
1555
|
-
const action = free.length
|
|
1556
|
-
? `Switch to ${formatVendorList(free)} to keep going.`
|
|
1557
|
-
: walled.length > 1
|
|
1558
|
-
? RAISE_LIMITS_ACTION
|
|
1559
|
-
: RAISE_LIMIT_ACTION;
|
|
1560
|
-
const reasons = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.blockedVendorReasons;
|
|
1561
|
-
const budgets = (_b = this._sessionRef) === null || _b === void 0 ? void 0 : _b.store.aiAssistant.vendorBudgets;
|
|
1562
|
-
// The wall's own figures, read from the entry the METER renders, so the two
|
|
1563
|
-
// cannot state different dollars for the same vendor. A vendor with no entry
|
|
1564
|
-
// (a swept wall — the proxy's word, no figures of its own) contributes a bare
|
|
1565
|
-
// name rather than a gap where numbers should be.
|
|
1566
|
-
const statementFor = (v) => {
|
|
1567
|
-
var _a, _b;
|
|
1568
|
-
return (_b = (_a = reasons === null || reasons === void 0 ? void 0 : reasons[v]) !== null && _a !== void 0 ? _a : formatBlockedReason(budgets === null || budgets === void 0 ? void 0 : budgets[v], v)) !== null && _b !== void 0 ? _b : `${vendorDisplayName(v)}'s AI usage limit is reached.`;
|
|
1569
|
-
};
|
|
1570
|
-
const nameWithFigures = (v) => {
|
|
1571
|
-
const figures = budgets === null || budgets === void 0 ? void 0 : budgets[v];
|
|
1572
|
-
return figures
|
|
1573
|
-
? `${vendorDisplayName(v)} (${formatUsd(figures.spentUsd)} of ${formatUsd(figures.budgetUsd)})`
|
|
1574
|
-
: vendorDisplayName(v);
|
|
1575
|
-
};
|
|
1576
|
-
if (walled.length === 1)
|
|
1577
|
-
return `${statementFor(walled[0])} ${action}`;
|
|
1578
|
-
// Several walled vendors: fold them into ONE sentence carrying each vendor's
|
|
1579
|
-
// figures, rather than repeating "X's AI usage limit is reached (…)." per
|
|
1580
|
-
// vendor — the same clause twice reads like a stutter, and the second copy of
|
|
1581
|
-
// the boilerplate adds nothing the first did not already say.
|
|
1582
|
-
//
|
|
1583
|
-
// Host copy is not foldable: `setVendorBlocked` promises it survives verbatim,
|
|
1584
|
-
// and it cannot be rewritten into a list item. So the moment any walled vendor
|
|
1585
|
-
// carries some, the branch below lists one statement per vendor instead —
|
|
1586
|
-
// falling back to composed copy for the ones that have none, so the sentence
|
|
1587
|
-
// set stays complete rather than silently naming a subset.
|
|
1588
|
-
if (!walled.some((v) => reasons === null || reasons === void 0 ? void 0 : reasons[v])) {
|
|
1589
|
-
return `AI usage limits are reached for ${joinList(walled.map(nameWithFigures))}. ${action}`;
|
|
1590
|
-
}
|
|
1591
|
-
return `${walled.map(statementFor).join(' ')} ${action}`;
|
|
1029
|
+
return this.budget.effectiveBlockedReason;
|
|
1592
1030
|
}
|
|
1593
1031
|
/**
|
|
1594
1032
|
* Copy the driver writes into the TRANSCRIPT when a turn hits the budget wall,
|
|
1595
|
-
* or `undefined` to let the driver compose it per wall
|
|
1596
|
-
*
|
|
1597
|
-
* A set `blockedReason` still wins — a white-labelled host must not read its
|
|
1598
|
-
* own explanation in the banner and shipped copy directly below it, and the
|
|
1599
|
-
* vendor-agnostic latch's `formatBlockedReason(budget)` form stays advice-free
|
|
1600
|
-
* exactly as before.
|
|
1601
|
-
*
|
|
1602
|
-
* `undefined` (the usual case) is NOT the old `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`
|
|
1603
|
-
* fallback: it hands composition to the driver, which builds the bubble from
|
|
1604
|
-
* EACH wall's own 402 — naming the exhausted vendor and advising a switch only
|
|
1605
|
-
* when that refusal's `otherVendorAvailable` positively vouched for headroom
|
|
1606
|
-
* elsewhere. That is immune to the staleness this getter used to guard against
|
|
1607
|
-
* by freezing the copy: construction-time advice could outlive its truth
|
|
1608
|
-
* (`getOrCreateDriver` re-runs on an agents swap and would have baked the
|
|
1609
|
-
* sentence into every later bubble), but per-wall advice is derived from the
|
|
1610
|
-
* refusal it annotates and dies with the turn. Forcing the default here was
|
|
1611
|
-
* also what made the driver's composed bubble unreachable in every real
|
|
1612
|
-
* mount — the banner advised switching while the bubble under it never did
|
|
1613
|
-
* (the GENC-1464 tester finding, root cause).
|
|
1033
|
+
* or `undefined` to let the driver compose it per wall — see
|
|
1034
|
+
* `BudgetController.transcriptMessage`.
|
|
1614
1035
|
*/
|
|
1615
1036
|
get transcriptBudgetExhaustedMessage() {
|
|
1616
|
-
|
|
1617
|
-
return (_a = this.blockedReason) !== null && _a !== void 0 ? _a : undefined;
|
|
1037
|
+
return this.budget.transcriptMessage;
|
|
1618
1038
|
}
|
|
1619
1039
|
/**
|
|
1620
1040
|
* Name of the agent the user has pinned via the agent picker. `null` means
|
|
@@ -1631,12 +1051,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1631
1051
|
return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.pinnedAgentName) !== null && _b !== void 0 ? _b : null;
|
|
1632
1052
|
}
|
|
1633
1053
|
set pinnedAgentName(value) {
|
|
1634
|
-
var _a, _b, _c;
|
|
1054
|
+
var _a, _b, _c, _d;
|
|
1635
1055
|
const previous = (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.pinnedAgentName) !== null && _b !== void 0 ? _b : null;
|
|
1636
1056
|
(_c = this._sessionRef) === null || _c === void 0 ? void 0 : _c.actions.aiAssistant.setPinnedAgentName(value);
|
|
1637
|
-
|
|
1638
|
-
this.driver.setPinnedAgent(value);
|
|
1639
|
-
}
|
|
1057
|
+
(_d = this.orchestrator) === null || _d === void 0 ? void 0 : _d.setPinnedAgent(value);
|
|
1640
1058
|
// Suggestions are scoped to the active routing — resetting forces a fresh
|
|
1641
1059
|
// fetch when the user pins/unpins so the prompts match the new agent.
|
|
1642
1060
|
if (previous !== value) {
|
|
@@ -1659,11 +1077,9 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1659
1077
|
return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.flowOwnerAgentName) !== null && _b !== void 0 ? _b : null;
|
|
1660
1078
|
}
|
|
1661
1079
|
set flowOwnerAgentName(value) {
|
|
1662
|
-
var _a;
|
|
1080
|
+
var _a, _b;
|
|
1663
1081
|
(_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setFlowOwnerAgentName(value);
|
|
1664
|
-
|
|
1665
|
-
this.driver.setFlowOwner(value);
|
|
1666
|
-
}
|
|
1082
|
+
(_b = this.orchestrator) === null || _b === void 0 ? void 0 : _b.setFlowOwner(value);
|
|
1667
1083
|
}
|
|
1668
1084
|
get liveSubAgentTrace() {
|
|
1669
1085
|
var _a, _b;
|
|
@@ -1744,83 +1160,47 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1744
1160
|
return this.sessionUsage.costUsd;
|
|
1745
1161
|
}
|
|
1746
1162
|
/**
|
|
1747
|
-
*
|
|
1748
|
-
*
|
|
1749
|
-
*
|
|
1750
|
-
* tiers that happen to resolve to the same model reports it once per tier, and the chips
|
|
1751
|
-
* rendered it once per tier too. Which tier a model came from is not what these chips are for.
|
|
1163
|
+
* The cost ledger's derived surface (GENC-1475). Every member below delegates to
|
|
1164
|
+
* `CostLedgerController` — the names are this element's public API and the
|
|
1165
|
+
* settings modal's bindings, so they stay put while the accounting moves.
|
|
1752
1166
|
*
|
|
1753
|
-
*
|
|
1754
|
-
*
|
|
1755
|
-
* label keeps them from collapsing into one another.
|
|
1167
|
+
* None carries `@volatile`: the decorators live on the controller's getters, and they
|
|
1168
|
+
* reach the binding observer from there — see `docs/code-health.md` §11.4.
|
|
1756
1169
|
*/
|
|
1757
1170
|
get uniqueProviderModels() {
|
|
1758
|
-
|
|
1759
|
-
const seen = new Map();
|
|
1760
|
-
for (const entry of this.providerStatuses) {
|
|
1761
|
-
const model = (_b = (_a = entry.status) === null || _a === void 0 ? void 0 : _a.model) !== null && _b !== void 0 ? _b : entry.name;
|
|
1762
|
-
if (!model)
|
|
1763
|
-
continue;
|
|
1764
|
-
const existing = seen.get(model);
|
|
1765
|
-
if (!existing) {
|
|
1766
|
-
seen.set(model, { model, provider: (_c = entry.status) === null || _c === void 0 ? void 0 : _c.provider });
|
|
1767
|
-
continue;
|
|
1768
|
-
}
|
|
1769
|
-
// Keep whichever tier knew the vendor, so the chip still gets its colour when one slot
|
|
1770
|
-
// reports a provider and another does not.
|
|
1771
|
-
if (existing.provider === undefined && ((_d = entry.status) === null || _d === void 0 ? void 0 : _d.provider) !== undefined) {
|
|
1772
|
-
existing.provider = entry.status.provider;
|
|
1773
|
-
}
|
|
1774
|
-
}
|
|
1775
|
-
return [...seen.values()];
|
|
1171
|
+
return this.costLedger.uniqueProviderModels;
|
|
1776
1172
|
}
|
|
1777
|
-
/**
|
|
1778
|
-
* Build history for display: most recently worked on first.
|
|
1779
|
-
*
|
|
1780
|
-
* The stored order is insertion order (see `upsertRecord`), which leaves a project returned to
|
|
1781
|
-
* sitting wherever it was first seen — so ordering is applied here, on read, where it also
|
|
1782
|
-
* covers rows a host provider supplied.
|
|
1783
|
-
*/
|
|
1173
|
+
/** Build history for display: most recently worked on first. */
|
|
1784
1174
|
get sortedCostSessionHistory() {
|
|
1785
|
-
return
|
|
1175
|
+
return this.costLedger.sortedHistory;
|
|
1786
1176
|
}
|
|
1787
|
-
/**
|
|
1788
|
-
* Lifetime usage across every project in the history scope — cost and all four token
|
|
1789
|
-
* buckets. What the Usage tab's lifetime section renders.
|
|
1790
|
-
*
|
|
1791
|
-
* Rows ONLY — deliberately not rows + live session. Each row holds a project's lifetime
|
|
1792
|
-
* usage and the active project's row is kept current on every transcript change, so
|
|
1793
|
-
* adding the live total would count this project's spend twice. That double-count is what
|
|
1794
|
-
* made the figure grow on every refresh: a page-hide banked a row while the restored
|
|
1795
|
-
* session still carried the same spend.
|
|
1796
|
-
*/
|
|
1177
|
+
/** Lifetime usage across every project in the history scope — rows only, never plus live. */
|
|
1797
1178
|
get cumulativeUsage() {
|
|
1798
|
-
|
|
1799
|
-
void this.chatConfig;
|
|
1800
|
-
// With history off there are no rows to sum, so the live session IS all we know.
|
|
1801
|
-
if (!this.resolvedCostHistory.enabled)
|
|
1802
|
-
return this.sessionUsage;
|
|
1803
|
-
return this.costSessionHistory.reduce((total, record) => addUsage(total, record.usage), emptyUsage());
|
|
1179
|
+
return this.costLedger.cumulativeUsage;
|
|
1804
1180
|
}
|
|
1805
1181
|
/** Lifetime USD across every project in the history scope. */
|
|
1806
1182
|
get cumulativeCostUsd() {
|
|
1807
|
-
return this.
|
|
1183
|
+
return this.costLedger.cumulativeCostUsd;
|
|
1808
1184
|
}
|
|
1809
|
-
/**
|
|
1810
|
-
* Whether the live session figures amount to this project's whole history, which is true
|
|
1811
|
-
* exactly when the transcript is restored on load. Drives the Usage tab's live labels
|
|
1812
|
-
* ("Project cost" vs "Session cost") so they state the scope rather than imply one.
|
|
1813
|
-
*
|
|
1814
|
-
* Note this is NOT what makes the *rows* lifetime — those bank their pre-transcript spend
|
|
1815
|
-
* (see `CostSessionRecord.banked`) and are lifetime either way.
|
|
1816
|
-
*/
|
|
1185
|
+
/** Whether the live session figures amount to this project's whole history. */
|
|
1817
1186
|
get liveUsageIsProjectLifetime() {
|
|
1818
|
-
return this.
|
|
1187
|
+
return this.costLedger.liveUsageIsProjectLifetime;
|
|
1819
1188
|
}
|
|
1820
1189
|
/** Resolved cost tab and build-history settings from `chatConfig.costHistory`. */
|
|
1821
1190
|
get resolvedCostHistory() {
|
|
1822
|
-
|
|
1823
|
-
|
|
1191
|
+
return this.costLedger.config;
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Persisted finalized cost sessions shown on the Usage tab build history list.
|
|
1195
|
+
*
|
|
1196
|
+
* A get/set pair rather than a read-only projection: the banking tests seed rows to
|
|
1197
|
+
* model a page load that found some, and a host provider's rows arrive the same way.
|
|
1198
|
+
*/
|
|
1199
|
+
get costSessionHistory() {
|
|
1200
|
+
return this.costLedger.history;
|
|
1201
|
+
}
|
|
1202
|
+
set costSessionHistory(value) {
|
|
1203
|
+
this.costLedger.history = value;
|
|
1824
1204
|
}
|
|
1825
1205
|
/**
|
|
1826
1206
|
* Total tokens across every chat turn in this session — all four buckets added up.
|
|
@@ -1831,157 +1211,26 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1831
1211
|
}
|
|
1832
1212
|
/** Label shown on the Usage tab for the in-flight session (app title or first user phrase). */
|
|
1833
1213
|
get liveCostSessionTitle() {
|
|
1834
|
-
|
|
1835
|
-
void this.messages;
|
|
1836
|
-
void this.chatConfig;
|
|
1837
|
-
const appTitle = ((_a = this.resolvedCostHistory.title) === null || _a === void 0 ? void 0 : _a.trim()) || ((_b = this._activeCostSessionTitle) === null || _b === void 0 ? void 0 : _b.trim());
|
|
1838
|
-
if (appTitle)
|
|
1839
|
-
return appTitle;
|
|
1840
|
-
return deriveCostSessionTitleFromMessages(this.messages);
|
|
1214
|
+
return this.costLedger.liveTitle;
|
|
1841
1215
|
}
|
|
1842
1216
|
/**
|
|
1843
|
-
* Write this session's usage into persisted build history
|
|
1844
|
-
*
|
|
1845
|
-
*
|
|
1846
|
-
* **Idempotent** — safe to call per turn, on tab close, and on a title change alike.
|
|
1847
|
-
* The name is historical: it does NOT end the session or reset the running totals.
|
|
1848
|
-
* Resetting them is what used to double-count spend, since the row was banked while the
|
|
1849
|
-
* persisted transcript still carried the same usage, so a reload restored it and the
|
|
1850
|
-
* Usage tab counted both.
|
|
1217
|
+
* Write this session's usage into persisted build history as a single row keyed by the
|
|
1218
|
+
* session key, replacing any existing row for that key.
|
|
1851
1219
|
*
|
|
1852
|
-
*
|
|
1853
|
-
*
|
|
1854
|
-
*
|
|
1220
|
+
* **Idempotent** — safe to call per turn, on tab close, and on a title change alike. The
|
|
1221
|
+
* name is historical: it does NOT end the session or reset the running totals. See
|
|
1222
|
+
* `CostLedgerController.finalize` for the full contract and the no-op cases.
|
|
1855
1223
|
*/
|
|
1856
1224
|
finalizeCostSession(options) {
|
|
1857
|
-
|
|
1858
|
-
return;
|
|
1859
|
-
// Wait for the rows before writing: a provider read is async, and banking from a
|
|
1860
|
-
// not-yet-loaded ledger would read as "nothing came before" and rewrite this project's
|
|
1861
|
-
// row down to the current transcript. Skipping is free — this is called on every
|
|
1862
|
-
// transcript change, so the next one writes.
|
|
1863
|
-
if (!this._costHistoryLoaded)
|
|
1864
|
-
return;
|
|
1865
|
-
const live = this.sessionUsage;
|
|
1866
|
-
const tokensConsumed = totalTokens(live);
|
|
1867
|
-
if (live.costUsd <= 0 && tokensConsumed <= 0)
|
|
1868
|
-
return;
|
|
1869
|
-
// The session key identifies the work — a project id, for hosts that key sessions
|
|
1870
|
-
// per project. Without one there is nothing stable to upsert against, so skip
|
|
1871
|
-
// rather than write a row that can never be matched again.
|
|
1872
|
-
const projectKey = this.getStateKey();
|
|
1873
|
-
if (!projectKey)
|
|
1874
|
-
return;
|
|
1875
|
-
const title = resolveCostSessionTitle({
|
|
1876
|
-
explicit: options === null || options === void 0 ? void 0 : options.title,
|
|
1877
|
-
appTitle: this.resolvedCostHistory.title,
|
|
1878
|
-
messages: this.messages,
|
|
1879
|
-
headerTitle: this.headerTitle,
|
|
1880
|
-
});
|
|
1881
|
-
// Lifetime = what came before this transcript + what this transcript proves. Both
|
|
1882
|
-
// terms are derived from state, never incremented, so the two element instances that
|
|
1883
|
-
// share a session write the same row rather than each adding their own contribution.
|
|
1884
|
-
const banked = this.bankedUsageFor(projectKey);
|
|
1885
|
-
const record = {
|
|
1886
|
-
// Stable per project so repeat upserts replace the row instead of stacking, and
|
|
1887
|
-
// so the delete action keeps targeting the same row across reloads.
|
|
1888
|
-
id: projectKey,
|
|
1889
|
-
projectKey,
|
|
1890
|
-
title,
|
|
1891
|
-
updatedAt: new Date().toISOString(),
|
|
1892
|
-
usage: addUsage(banked, live),
|
|
1893
|
-
banked,
|
|
1894
|
-
models: collectSessionModels(this.messages, this.providerStatuses),
|
|
1895
|
-
};
|
|
1896
|
-
this.persistCostHistory(upsertRecord(this.costSessionHistory, record));
|
|
1897
|
-
// NOTE: the live counters are deliberately NOT reset here. Resetting was what
|
|
1898
|
-
// double-counted spend — the row was banked while the persisted transcript still
|
|
1899
|
-
// carried the same usage, so a reload restored it and the Usage tab added both.
|
|
1900
|
-
// The row is a projection of the session totals, not a transfer out of them.
|
|
1901
|
-
}
|
|
1902
|
-
/**
|
|
1903
|
-
* Spend for `projectKey` that predates the current transcript, resolved once per SESSION and
|
|
1904
|
-
* shared by every element instance wired to it (see `banked-usage-baselines`), so it cannot
|
|
1905
|
-
* drift as the row it came from is rewritten by our own upserts.
|
|
1906
|
-
*
|
|
1907
|
-
* The two cases turn on whether the transcript is authoritative for this project:
|
|
1908
|
-
*
|
|
1909
|
-
* - **Transcript restored** (`persistenceActive`): it already accounts for everything after
|
|
1910
|
-
* the previous row's own banked figure, so carry that figure forward unchanged. Re-banking
|
|
1911
|
-
* `usage` here would add the restored history to itself on every reload.
|
|
1912
|
-
* - **Transcript starts empty**: nothing on screen accounts for the previous total, so the
|
|
1913
|
-
* whole of it becomes banked. Without this the next turn would rewrite the row downwards
|
|
1914
|
-
* to just that turn, losing the project's earlier spend.
|
|
1915
|
-
*
|
|
1916
|
-
* The baseline is deliberately NOT element state. This element is remounted repeatedly inside
|
|
1917
|
-
* a single session (the host re-rendering its layout, popping out, docking) and a fresh
|
|
1918
|
-
* instance re-deriving would take the second branch against rows that already hold the live
|
|
1919
|
-
* transcript — banking the session as its own baseline. Keying on the session instead means a
|
|
1920
|
-
* remount inherits the baseline that transcript was paired with.
|
|
1921
|
-
*/
|
|
1922
|
-
bankedUsageFor(projectKey) {
|
|
1923
|
-
const resolved = getBankedBaseline(projectKey);
|
|
1924
|
-
if (resolved)
|
|
1925
|
-
return resolved;
|
|
1926
|
-
const existing = this.costSessionHistory.find((r) => r.projectKey === projectKey);
|
|
1927
|
-
// `persistenceActive` is what decides whether the transcript speaks for the project's
|
|
1928
|
-
// history: it is on only when a provider is wired AND the user is remembering the chat.
|
|
1929
|
-
const banked = resolveBankedUsage(existing, this.persistenceActive);
|
|
1930
|
-
setBankedBaseline(projectKey, banked);
|
|
1931
|
-
return banked;
|
|
1225
|
+
this.costLedger.finalize(options);
|
|
1932
1226
|
}
|
|
1933
1227
|
/**
|
|
1934
|
-
* Re-baseline this session against an emptied transcript
|
|
1935
|
-
*
|
|
1936
|
-
* reset, since that is the only record of the latter.
|
|
1937
|
-
*
|
|
1938
|
-
* Not the same as dropping the baseline. Dropping it re-derives through `persistenceActive`,
|
|
1939
|
-
* and with persistence ON that carries the row's *old* banked figure forward — discarding
|
|
1940
|
-
* every turn between the last baseline and the clear. The transcript is gone either way, so
|
|
1941
|
-
* the branch that reads it as authoritative no longer applies.
|
|
1942
|
-
*
|
|
1943
|
-
* Derived from the memoised baseline and the live figure rather than from the project's row,
|
|
1944
|
-
* because a row is only as current as the last `finalizeCostSession` — which skips its write
|
|
1945
|
-
* while the ledger is unloaded, and while the session has no usage yet.
|
|
1228
|
+
* Re-baseline this session against an emptied transcript — see
|
|
1229
|
+
* `CostLedgerController.rebaselineOnClearedTranscript`. `discarded` must be read
|
|
1230
|
+
* BEFORE the live figures are reset, since that is the only record of the latter.
|
|
1946
1231
|
*/
|
|
1947
1232
|
rebaselineOnClearedTranscript(key, discarded) {
|
|
1948
|
-
|
|
1949
|
-
// consulting the ledger at all — `bankedUsageFor` would short-circuit on this same value — so
|
|
1950
|
-
// answer before the guard below, which would otherwise discard a figure that is both correct
|
|
1951
|
-
// and independent of the rows. Losing it costs real spend: with a row at `usage: 120,
|
|
1952
|
-
// banked: 90` and 30 on screen, a clear during a reload would drop the memoised 90, and the
|
|
1953
|
-
// next turn would re-derive `row.banked` and rewrite the row BACKWARDS from 120 to 95.
|
|
1954
|
-
const resolved = getBankedBaseline(key);
|
|
1955
|
-
if (resolved) {
|
|
1956
|
-
setBankedBaseline(key, addUsage(resolved, discarded));
|
|
1957
|
-
return;
|
|
1958
|
-
}
|
|
1959
|
-
// Nothing memoised, so a baseline can only come from the rows — and a stamped one is STICKY
|
|
1960
|
-
// (`bankedUsageFor` short-circuits on any stored entry, and a zero usage is a *resolved* value
|
|
1961
|
-
// there, not "unresolved"). So unless the rows are genuinely known, do not guess:
|
|
1962
|
-
//
|
|
1963
|
-
// - `enabled` false: `reloadCostSessionHistory` sets `_costHistoryLoaded` TRUE over a
|
|
1964
|
-
// deliberately empty list, meaning "nothing will be loaded" rather than "rows are known".
|
|
1965
|
-
// The flag alone therefore is not enough. Reachable because `enabled` is
|
|
1966
|
-
// `cost?.enabled === true` — false for an unbound `chatConfig`, and for any host resolving
|
|
1967
|
-
// it from an async setting, exactly as `chatConfigChanged` already expects.
|
|
1968
|
-
// - Provider read in flight: the list is emptied and the flag cleared synchronously, on
|
|
1969
|
-
// connect and again on every `chatConfig` rebind.
|
|
1970
|
-
// - Unreadable: the rows we hold are a fallback, not the ledger.
|
|
1971
|
-
//
|
|
1972
|
-
// In all three, pinning would bury this project's real total the moment it arrived — a later
|
|
1973
|
-
// load does NOT invalidate baselines (deliberately, see `reloadCostSessionHistory`), so the
|
|
1974
|
-
// next write would replace the row off the pinned figure and lose everything before it.
|
|
1975
|
-
//
|
|
1976
|
-
// Dropping instead leaves the next write to re-derive from rows that actually arrived. It
|
|
1977
|
-
// under-banks `discarded` in that window — a bounded loss, and what every clear did before
|
|
1978
|
-
// this method existed. Nothing is written while disabled, so there is no banked value to
|
|
1979
|
-
// preserve in that case either.
|
|
1980
|
-
if (!this.resolvedCostHistory.enabled || !this._costHistoryLoaded || this._ledgerUnreadable) {
|
|
1981
|
-
deleteBankedBaseline(key);
|
|
1982
|
-
return;
|
|
1983
|
-
}
|
|
1984
|
-
setBankedBaseline(key, addUsage(this.bankedUsageFor(key), discarded));
|
|
1233
|
+
this.costLedger.rebaselineOnClearedTranscript(key, discarded);
|
|
1985
1234
|
}
|
|
1986
1235
|
showConfirmationDialog(confirmationMessage, modalParent, onConfirm, onCancel) {
|
|
1987
1236
|
showNotificationDialog({
|
|
@@ -1995,174 +1244,11 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1995
1244
|
}
|
|
1996
1245
|
/** Remove one entry from persisted build history. */
|
|
1997
1246
|
removeCostSessionFromHistory(id) {
|
|
1998
|
-
|
|
1999
|
-
// otherwise the next turn would rewrite the row we just removed, restoring the total the
|
|
2000
|
-
// user asked to be rid of.
|
|
2001
|
-
//
|
|
2002
|
-
// Resolve the row first: `id` is a row id while baselines are keyed by projectKey. They
|
|
2003
|
-
// match only for rows this element wrote (`finalizeCostSession` sets `id: projectKey`); a
|
|
2004
|
-
// row from a host provider can carry any id, and deleting by the wrong namespace would
|
|
2005
|
-
// silently leave the baseline in place.
|
|
2006
|
-
const removed = this.costSessionHistory.find((record) => record.id === id);
|
|
2007
|
-
if (removed)
|
|
2008
|
-
deleteBankedBaseline(removed.projectKey);
|
|
2009
|
-
this.persistCostHistory(this.costSessionHistory.filter((record) => record.id !== id));
|
|
1247
|
+
this.costLedger.remove(id);
|
|
2010
1248
|
}
|
|
2011
1249
|
/** Clear all persisted build history for this assistant scope. */
|
|
2012
1250
|
clearCostSessionHistory() {
|
|
2013
|
-
|
|
2014
|
-
// resurrect the pre-clear total. Dropped per row rather than for the active session alone,
|
|
2015
|
-
// so a project whose row was cleared while another session was displaying it does not keep
|
|
2016
|
-
// a baseline pointing at spend that no longer exists anywhere.
|
|
2017
|
-
for (const record of this.costSessionHistory)
|
|
2018
|
-
deleteBankedBaseline(record.projectKey);
|
|
2019
|
-
// Provider-backed: write an empty ledger through. The `localStorage` path instead
|
|
2020
|
-
// removes its key outright rather than leaving an empty array behind.
|
|
2021
|
-
if (this.costHistoryProvider()) {
|
|
2022
|
-
this.persistCostHistory([]);
|
|
2023
|
-
return;
|
|
2024
|
-
}
|
|
2025
|
-
clearCostSessionHistory(this.getCostHistoryScope());
|
|
2026
|
-
this.costSessionHistory = [];
|
|
2027
|
-
}
|
|
2028
|
-
getCostHistoryScope() {
|
|
2029
|
-
const scope = this.resolvedCostHistory.scope;
|
|
2030
|
-
if (scope)
|
|
2031
|
-
return scope;
|
|
2032
|
-
if (this.id)
|
|
2033
|
-
return this.id;
|
|
2034
|
-
return 'default';
|
|
2035
|
-
}
|
|
2036
|
-
reloadCostSessionHistory() {
|
|
2037
|
-
// NOTE: deliberately does NOT invalidate the banked figure. This runs again whenever
|
|
2038
|
-
// `chatConfig` rebinds — hosts commonly build it in a template expression, so a new
|
|
2039
|
-
// object arrives mid-session — and by then the rows include our own upserts, whose
|
|
2040
|
-
// `usage` already contains the live transcript. Re-deriving from those would add the
|
|
2041
|
-
// current session to itself. `bankedUsageFor` keys its memo on the project instead,
|
|
2042
|
-
// which handles a genuine session switch without trusting reload timing.
|
|
2043
|
-
this._costHistoryLoaded = false;
|
|
2044
|
-
this._ledgerUnreadable = false;
|
|
2045
|
-
if (!this.resolvedCostHistory.enabled) {
|
|
2046
|
-
this.costSessionHistory = [];
|
|
2047
|
-
this._costHistoryLoaded = true;
|
|
2048
|
-
return;
|
|
2049
|
-
}
|
|
2050
|
-
const scope = this.getCostHistoryScope();
|
|
2051
|
-
const provider = this.costHistoryProvider();
|
|
2052
|
-
if (!provider) {
|
|
2053
|
-
this.costSessionHistory = loadCostSessionHistory(scope);
|
|
2054
|
-
this._costHistoryLoaded = true;
|
|
2055
|
-
return;
|
|
2056
|
-
}
|
|
2057
|
-
// Provider reads are async, so the Usage tab renders an empty list for a tick before
|
|
2058
|
-
// this lands. Seeded empty rather than left stale so a scope change can't briefly show
|
|
2059
|
-
// the previous scope's rows.
|
|
2060
|
-
this.costSessionHistory = [];
|
|
2061
|
-
void provider.loadCostHistory(scope)
|
|
2062
|
-
.then((records) => {
|
|
2063
|
-
// Guard against a scope change while the load was in flight — a late result must
|
|
2064
|
-
// not overwrite the rows of whatever scope is current now.
|
|
2065
|
-
if (this.getCostHistoryScope() === scope) {
|
|
2066
|
-
// Filtered exactly as the localStorage path is. A host's rows are as capable of
|
|
2067
|
-
// being malformed as a hand-edited storage entry, and an unchecked one poisons
|
|
2068
|
-
// every total with NaN or throws in the sort during render.
|
|
2069
|
-
this.costSessionHistory = (records !== null && records !== void 0 ? records : []).filter(isCostSessionRecord);
|
|
2070
|
-
this._costHistoryLoaded = true;
|
|
2071
|
-
}
|
|
2072
|
-
})
|
|
2073
|
-
.catch((e) => {
|
|
2074
|
-
logger.warn('Cost history unreadable — recording locally only this page-load', e);
|
|
2075
|
-
// Writes stay blocked, and `persistCostHistory` will not save even if something else
|
|
2076
|
-
// calls it. `saveCostHistory` REPLACES the stored list, so writing an upsert against
|
|
2077
|
-
// the empty list we fell back to would delete every other project's row and reset
|
|
2078
|
-
// this project's banked total — destroying data we simply failed to read. The row
|
|
2079
|
-
// rebuilds from the transcript on the next successful load.
|
|
2080
|
-
this._ledgerUnreadable = true;
|
|
2081
|
-
});
|
|
2082
|
-
}
|
|
2083
|
-
/**
|
|
2084
|
-
* The persistence provider, but only when it implements BOTH cost-history methods.
|
|
2085
|
-
*
|
|
2086
|
-
* Both or neither: a provider that can save but not load would silently accumulate a
|
|
2087
|
-
* ledger nobody ever reads back, so a partial implementation is treated as absent and
|
|
2088
|
-
* falls back to `localStorage`.
|
|
2089
|
-
*/
|
|
2090
|
-
costHistoryProvider() {
|
|
2091
|
-
const provider = this.persistence.provider;
|
|
2092
|
-
return (provider === null || provider === void 0 ? void 0 : provider.loadCostHistory) && provider.saveCostHistory ? provider : undefined;
|
|
2093
|
-
}
|
|
2094
|
-
/**
|
|
2095
|
-
* Adopt `records` as the live list, and schedule a write to whichever backend owns cost
|
|
2096
|
-
* history — the provider when the host implements it, else `localStorage`.
|
|
2097
|
-
*
|
|
2098
|
-
* The list is adopted synchronously because that is what the Usage tab renders; only the
|
|
2099
|
-
* *write* is deferred. This runs on every usage change (about once per LLM call, plus once
|
|
2100
|
-
* per sub-agent turn) and each write serialises the whole ledger, so coalescing cuts a
|
|
2101
|
-
* per-turn `localStorage.setItem` or network round-trip down to one per quiet period. Safe
|
|
2102
|
-
* because the write is a full replace of idempotent state: the newest call wins and no
|
|
2103
|
-
* intermediate state is meaningful. Flushed on `pagehide` and disconnect so nothing is lost.
|
|
2104
|
-
*/
|
|
2105
|
-
persistCostHistory(records) {
|
|
2106
|
-
this.costSessionHistory = records;
|
|
2107
|
-
// A ledger we could not read must never be written back — see reloadCostSessionHistory.
|
|
2108
|
-
// Guarded here rather than only at the finalize gate so it also covers the row-removal and
|
|
2109
|
-
// clear paths, which write directly.
|
|
2110
|
-
if (this._ledgerUnreadable)
|
|
2111
|
-
return;
|
|
2112
|
-
this._ledgerDirty = true;
|
|
2113
|
-
if (this._ledgerFlushTimer !== undefined)
|
|
2114
|
-
clearTimeout(this._ledgerFlushTimer);
|
|
2115
|
-
this._ledgerFlushTimer = setTimeout(() => this.flushCostHistory(), LEDGER_FLUSH_DEBOUNCE_MS);
|
|
2116
|
-
}
|
|
2117
|
-
/**
|
|
2118
|
-
* Write the pending ledger now. Safe to call when nothing is pending.
|
|
2119
|
-
*
|
|
2120
|
-
* Provider writes are fire-and-forget and best-effort: the ledger is accounting, not
|
|
2121
|
-
* conversation content, so a failed write is logged and never surfaced into the chat. On
|
|
2122
|
-
* `pagehide` the `localStorage` path completes synchronously; a provider write is subject to
|
|
2123
|
-
* the same unload race as every other network save the assistant makes.
|
|
2124
|
-
*/
|
|
2125
|
-
flushCostHistory() {
|
|
2126
|
-
if (this._ledgerFlushTimer !== undefined) {
|
|
2127
|
-
clearTimeout(this._ledgerFlushTimer);
|
|
2128
|
-
this._ledgerFlushTimer = undefined;
|
|
2129
|
-
}
|
|
2130
|
-
if (!this._ledgerDirty || this._ledgerUnreadable)
|
|
2131
|
-
return;
|
|
2132
|
-
this._ledgerDirty = false;
|
|
2133
|
-
const records = this.costSessionHistory;
|
|
2134
|
-
const scope = this.getCostHistoryScope();
|
|
2135
|
-
const provider = this.costHistoryProvider();
|
|
2136
|
-
if (!provider) {
|
|
2137
|
-
saveCostSessionHistory(scope, records);
|
|
2138
|
-
return;
|
|
2139
|
-
}
|
|
2140
|
-
void provider.saveCostHistory(scope, records).catch((e) => {
|
|
2141
|
-
logger.warn('Cost history save failed', e);
|
|
2142
|
-
});
|
|
2143
|
-
}
|
|
2144
|
-
/**
|
|
2145
|
-
* Finalize the previous session when the host-provided title changes — a rename should land on
|
|
2146
|
-
* the row under its old name rather than silently relabelling accrued spend.
|
|
2147
|
-
*
|
|
2148
|
-
* Only when the session key still matches the one that title accrued under. Hosts change
|
|
2149
|
-
* `costHistory.title` on project SWITCH too, and if the key has already flipped, this would
|
|
2150
|
-
* upsert the outgoing project's live usage onto the incoming project's row as
|
|
2151
|
-
* `banked + old live`, inflating it. Under the old append model a mis-keyed write left a stray
|
|
2152
|
-
* row; with the upsert it overwrites a real one.
|
|
2153
|
-
*/
|
|
2154
|
-
syncActiveCostSessionTitle() {
|
|
2155
|
-
var _a, _b;
|
|
2156
|
-
const previous = this._activeCostSessionTitle;
|
|
2157
|
-
const next = (_b = (_a = this.resolvedCostHistory.title) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : '';
|
|
2158
|
-
const key = this.getStateKey();
|
|
2159
|
-
if (previous && previous !== next && this._activeCostSessionKey === key) {
|
|
2160
|
-
this.finalizeCostSession({ title: previous });
|
|
2161
|
-
}
|
|
2162
|
-
if (previous !== next) {
|
|
2163
|
-
this._activeCostSessionTitle = next;
|
|
2164
|
-
this._activeCostSessionKey = key;
|
|
2165
|
-
}
|
|
1251
|
+
this.costLedger.clear();
|
|
2166
1252
|
}
|
|
2167
1253
|
/** Active model id resolved from the AIProvider, if exposed. */
|
|
2168
1254
|
get activeModel() {
|
|
@@ -2199,6 +1285,41 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2199
1285
|
var _a;
|
|
2200
1286
|
(_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setInputValue(value);
|
|
2201
1287
|
}
|
|
1288
|
+
/** Attachments pending on the next message. */
|
|
1289
|
+
get attachments() {
|
|
1290
|
+
return this.attachmentCtrl.attachments;
|
|
1291
|
+
}
|
|
1292
|
+
set attachments(value) {
|
|
1293
|
+
this.attachmentCtrl.attachments = value;
|
|
1294
|
+
}
|
|
1295
|
+
/** Rejection messages for files the last pick refused. */
|
|
1296
|
+
get attachmentErrors() {
|
|
1297
|
+
return this.attachmentCtrl.errors;
|
|
1298
|
+
}
|
|
1299
|
+
set attachmentErrors(value) {
|
|
1300
|
+
this.attachmentCtrl.errors = value;
|
|
1301
|
+
}
|
|
1302
|
+
/** Bump to re-render app settings toggles when the host provider notifies a change. */
|
|
1303
|
+
get appSettingsRevision() {
|
|
1304
|
+
return this.settings.appSettingsRevision;
|
|
1305
|
+
}
|
|
1306
|
+
set appSettingsRevision(value) {
|
|
1307
|
+
this.settings.appSettingsRevision = value;
|
|
1308
|
+
}
|
|
1309
|
+
/** Whether the settings modal is open. */
|
|
1310
|
+
get settingsOpen() {
|
|
1311
|
+
return this.settings.open;
|
|
1312
|
+
}
|
|
1313
|
+
set settingsOpen(value) {
|
|
1314
|
+
this.settings.open = value;
|
|
1315
|
+
}
|
|
1316
|
+
/** Active tab inside the settings modal. */
|
|
1317
|
+
get settingsModalTab() {
|
|
1318
|
+
return this.settings.tab;
|
|
1319
|
+
}
|
|
1320
|
+
set settingsModalTab(value) {
|
|
1321
|
+
this.settings.tab = value;
|
|
1322
|
+
}
|
|
2202
1323
|
/**
|
|
2203
1324
|
* Showing and hiding the modal hangs off the PROPERTY, so `persistenceBroken` is the single
|
|
2204
1325
|
* entry point — for this element's own persister callback and for a host reporting a failure in
|
|
@@ -2263,6 +1384,69 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2263
1384
|
(_a = this.persistenceEscapeGuardTarget) === null || _a === void 0 ? void 0 : _a.removeEventListener('cancel', this.onPersistenceModalCancel);
|
|
2264
1385
|
this.persistenceEscapeGuardTarget = undefined;
|
|
2265
1386
|
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Light-DOM nodes assigned to settings modal slots. The template's `slotted()`
|
|
1389
|
+
* bindings write these by property NAME, so each needs a real setter here even
|
|
1390
|
+
* though the controller owns the arrays.
|
|
1391
|
+
*/
|
|
1392
|
+
get settingsModelSlotted() {
|
|
1393
|
+
return this.settings.modelSlotted;
|
|
1394
|
+
}
|
|
1395
|
+
set settingsModelSlotted(value) {
|
|
1396
|
+
this.settings.modelSlotted = value;
|
|
1397
|
+
}
|
|
1398
|
+
get settingsAppSlotted() {
|
|
1399
|
+
return this.settings.appSlotted;
|
|
1400
|
+
}
|
|
1401
|
+
set settingsAppSlotted(value) {
|
|
1402
|
+
this.settings.appSlotted = value;
|
|
1403
|
+
}
|
|
1404
|
+
get settingsCostCurrentSlotted() {
|
|
1405
|
+
return this.settings.costCurrentSlotted;
|
|
1406
|
+
}
|
|
1407
|
+
set settingsCostCurrentSlotted(value) {
|
|
1408
|
+
this.settings.costCurrentSlotted = value;
|
|
1409
|
+
}
|
|
1410
|
+
get settingsCostHistorySlotted() {
|
|
1411
|
+
return this.settings.costHistorySlotted;
|
|
1412
|
+
}
|
|
1413
|
+
set settingsCostHistorySlotted(value) {
|
|
1414
|
+
this.settings.costHistorySlotted = value;
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* This element's driver as an orchestrator, or `undefined` when the session is
|
|
1418
|
+
* running a bare `ChatDriver`.
|
|
1419
|
+
*
|
|
1420
|
+
* The five call sites that used to test the type themselves now read this
|
|
1421
|
+
* (§11.2 constraint 3 of `docs/code-health.md`): the agent pin, the flow-owner
|
|
1422
|
+
* lock, the wire-up in `wireDriver`, and the two `driverKind` diagnostics. The
|
|
1423
|
+
* type test itself lives in `asOrchestrator`, shared with the session
|
|
1424
|
+
* persister.
|
|
1425
|
+
*/
|
|
1426
|
+
get orchestrator() {
|
|
1427
|
+
return asOrchestrator(this.driver);
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* A get/set pair over one of this element's own properties, for a controller
|
|
1431
|
+
* that drives state it does not own. Written as a helper rather than two inline
|
|
1432
|
+
* closures per call: a pair of same-typed closures is easy to transpose by
|
|
1433
|
+
* accident, and a single object cannot be.
|
|
1434
|
+
*/
|
|
1435
|
+
accessor(key) {
|
|
1436
|
+
return {
|
|
1437
|
+
get: () => this[key],
|
|
1438
|
+
set: (value) => {
|
|
1439
|
+
this[key] = value;
|
|
1440
|
+
},
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
/** Explicit composer height once dragged; `null` keeps the default auto-grow. */
|
|
1444
|
+
get composerHeightPx() {
|
|
1445
|
+
return this.composerResize.heightPx;
|
|
1446
|
+
}
|
|
1447
|
+
set composerHeightPx(value) {
|
|
1448
|
+
this.composerResize.heightPx = value;
|
|
1449
|
+
}
|
|
2266
1450
|
syncShowHalo() {
|
|
2267
1451
|
if (!this.busy) {
|
|
2268
1452
|
this.showHalo = 'no';
|
|
@@ -2410,7 +1594,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2410
1594
|
this.driver.loadHistory([...history]);
|
|
2411
1595
|
this.logMeta('driver.created', {
|
|
2412
1596
|
reason: 'agents-changed',
|
|
2413
|
-
driverKind: this.
|
|
1597
|
+
driverKind: this.orchestrator ? 'orchestrating' : 'chat',
|
|
2414
1598
|
});
|
|
2415
1599
|
}
|
|
2416
1600
|
/** Returns a stable fingerprint for an agents array based on agent names and tool handler keys. */
|
|
@@ -2515,6 +1699,11 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2515
1699
|
const { driver } = this;
|
|
2516
1700
|
if (!driver)
|
|
2517
1701
|
return;
|
|
1702
|
+
// Captured, not re-read: the cleanup closures below must remove their listeners
|
|
1703
|
+
// from the object they were added to, even if `this.driver` has been reassigned
|
|
1704
|
+
// by the time `unwireDriver` runs. Same reason `driver` itself is destructured,
|
|
1705
|
+
// and reading both from `this` here means they cannot disagree.
|
|
1706
|
+
const orchestrator = this.orchestrator;
|
|
2518
1707
|
// Idempotent — unwire previous listeners on this component before re-wiring.
|
|
2519
1708
|
this.unwireDriver();
|
|
2520
1709
|
const onHistoryUpdated = (e) => {
|
|
@@ -2635,13 +1824,13 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2635
1824
|
() => driver.removeEventListener('interaction-stop', onInteractionStop),
|
|
2636
1825
|
() => driver.removeEventListener('provider-changed', onProviderChanged),
|
|
2637
1826
|
];
|
|
2638
|
-
if (
|
|
1827
|
+
if (orchestrator) {
|
|
2639
1828
|
// Restore the user pin and flow-owner lock from the session store onto
|
|
2640
1829
|
// the freshly built driver, so pop-in/pop-out and agents-array
|
|
2641
1830
|
// reassignment preserve both.
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
1831
|
+
orchestrator.setPinnedAgent(this.pinnedAgentName);
|
|
1832
|
+
orchestrator.setFlowOwner(this.flowOwnerAgentName);
|
|
1833
|
+
orchestrator.setContextGuard(this.contextGateCtrl.guardPolicy);
|
|
2645
1834
|
const onOrchStart = () => {
|
|
2646
1835
|
this.showHalo = 'orchestrating';
|
|
2647
1836
|
};
|
|
@@ -2694,12 +1883,12 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2694
1883
|
(_h = this._sessionRef) === null || _h === void 0 ? void 0 : _h.actions.aiAssistant.setFlowActivationPrompt(null);
|
|
2695
1884
|
}
|
|
2696
1885
|
};
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
cleanups.push(() =>
|
|
1886
|
+
orchestrator.addEventListener('orchestrating-start', onOrchStart);
|
|
1887
|
+
orchestrator.addEventListener('orchestrating-stop', onOrchStop);
|
|
1888
|
+
orchestrator.addEventListener('agent-changed', onAgentChanged);
|
|
1889
|
+
orchestrator.addEventListener('pinned-changed', onPinnedChanged);
|
|
1890
|
+
orchestrator.addEventListener('flow-owner-changed', onFlowOwnerChanged);
|
|
1891
|
+
cleanups.push(() => orchestrator.removeEventListener('orchestrating-start', onOrchStart), () => orchestrator.removeEventListener('orchestrating-stop', onOrchStop), () => orchestrator.removeEventListener('agent-changed', onAgentChanged), () => orchestrator.removeEventListener('pinned-changed', onPinnedChanged), () => orchestrator.removeEventListener('flow-owner-changed', onFlowOwnerChanged));
|
|
2703
1892
|
}
|
|
2704
1893
|
this.driverCleanup = () => {
|
|
2705
1894
|
for (const fn of cleanups)
|
|
@@ -2820,13 +2009,13 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2820
2009
|
// where state is loading and the input may be hidden or disabled).
|
|
2821
2010
|
if (!this.busy)
|
|
2822
2011
|
this.maybeAutoFocusChatInput();
|
|
2823
|
-
this.
|
|
2824
|
-
this.
|
|
2012
|
+
this.costLedger.reload();
|
|
2013
|
+
this.costLedger.syncActiveTitle();
|
|
2825
2014
|
this.logMeta('assistant.connected', {
|
|
2826
2015
|
store: isNewStore ? 'created' : 'restored',
|
|
2827
2016
|
restoredMessages: this.messages.length,
|
|
2828
2017
|
driver: driverExisted ? 'reused' : 'created',
|
|
2829
|
-
driverKind: this.
|
|
2018
|
+
driverKind: this.orchestrator ? 'orchestrating' : 'chat',
|
|
2830
2019
|
driverBusy: (_f = (_e = this.driver) === null || _e === void 0 ? void 0 : _e.isBusy()) !== null && _f !== void 0 ? _f : false,
|
|
2831
2020
|
});
|
|
2832
2021
|
}
|
|
@@ -2868,7 +2057,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2868
2057
|
void this.loadProviderStatuses();
|
|
2869
2058
|
});
|
|
2870
2059
|
}
|
|
2871
|
-
this.wireAppSettingsProvider();
|
|
2060
|
+
this.settings.wireAppSettingsProvider();
|
|
2872
2061
|
// Latch the backend block off the activity bus (GENC-1464). This is the primary
|
|
2873
2062
|
// seam: it fires for a turn this element did not start, for a sub-agent's
|
|
2874
2063
|
// wall (the child inherits the parent's bus and publishes its own
|
|
@@ -2880,7 +2069,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2880
2069
|
// Pass the figures through: this seam wins the latch for an in-loop wall (the
|
|
2881
2070
|
// common case), so dropping them here would render the generic banner copy in
|
|
2882
2071
|
// exactly the path `ChatDriverResult.budget` was widened to serve.
|
|
2883
|
-
this.
|
|
2072
|
+
this.budget.latchFrom(d === null || d === void 0 ? void 0 : d.failureReason, undefined, d === null || d === void 0 ? void 0 : d.budget, d === null || d === void 0 ? void 0 : d.vendor));
|
|
2884
2073
|
if (this.messagesEl) {
|
|
2885
2074
|
this._scrollListener = () => {
|
|
2886
2075
|
this._userScrolledAway =
|
|
@@ -2976,13 +2165,14 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2976
2165
|
disconnectedCallback() {
|
|
2977
2166
|
var _a, _b, _c, _d, _e, _f, _g;
|
|
2978
2167
|
window.removeEventListener('pagehide', this._handlePageHide);
|
|
2979
|
-
//
|
|
2980
|
-
//
|
|
2981
|
-
//
|
|
2982
|
-
this.
|
|
2168
|
+
// Drops the menus' document listeners and finalises any in-flight close
|
|
2169
|
+
// animation. Ordering matters and is satisfied here: `finalize()` writes the
|
|
2170
|
+
// closed state through the store, so it has to run while `_sessionRef` is still
|
|
2171
|
+
// set (cleared at the end of this method) or the panel re-mounts open.
|
|
2172
|
+
this.composerMenus.dispose();
|
|
2983
2173
|
// Write out anything the debounce is still holding — the timer would otherwise fire against
|
|
2984
2174
|
// a torn-down element, or not at all.
|
|
2985
|
-
this.
|
|
2175
|
+
this.costLedger.flush();
|
|
2986
2176
|
super.disconnectedCallback();
|
|
2987
2177
|
this._sessionActive = false;
|
|
2988
2178
|
this.stopLoadingTimer();
|
|
@@ -3000,7 +2190,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
3000
2190
|
this._unsubBudgetLatch = undefined;
|
|
3001
2191
|
(_c = this.unsubProviderRegistry) === null || _c === void 0 ? void 0 : _c.call(this);
|
|
3002
2192
|
this.unsubProviderRegistry = undefined;
|
|
3003
|
-
this.unwireAppSettingsProvider();
|
|
2193
|
+
this.settings.unwireAppSettingsProvider();
|
|
3004
2194
|
(_d = this._executionCompletionUnsub) === null || _d === void 0 ? void 0 : _d.call(this);
|
|
3005
2195
|
this._executionCompletionUnsub = undefined;
|
|
3006
2196
|
// Note: the restore-core autosave AND diagnostics-append timers live on the
|
|
@@ -3014,15 +2204,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
3014
2204
|
this.removeEventListener('request-scroll-to-bottom', this._handleRequestScrollToBottom);
|
|
3015
2205
|
document.removeEventListener('pointerdown', this._handleGlobalInteraction, true);
|
|
3016
2206
|
document.removeEventListener('focusin', this._handleGlobalInteraction, true);
|
|
3017
|
-
// Finalise any in-flight panel-close animations before clearing
|
|
3018
|
-
// _sessionRef, otherwise the closed state never makes it to the store and
|
|
3019
|
-
// the panel re-mounts open.
|
|
3020
|
-
this._agentPickerToggle.finalize();
|
|
3021
2207
|
if (this.settingsOpen) {
|
|
3022
2208
|
(_e = this.settingsModal) === null || _e === void 0 ? void 0 : _e.close();
|
|
3023
2209
|
this.settingsOpen = false;
|
|
3024
2210
|
}
|
|
3025
|
-
this._sessionMenuToggle.finalize();
|
|
3026
2211
|
// Capture before clearing — `wasBusy` reads the driver, which is dropped below.
|
|
3027
2212
|
this.logMeta('assistant.disconnected', { wasBusy: (_g = (_f = this.driver) === null || _f === void 0 ? void 0 : _f.isBusy()) !== null && _g !== void 0 ? _g : false });
|
|
3028
2213
|
// Clear local references only — driver and store stay in their registries.
|
|
@@ -3066,27 +2251,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
3066
2251
|
*/
|
|
3067
2252
|
syncContextGuard() {
|
|
3068
2253
|
var _a, _b;
|
|
3069
|
-
(_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.setContextGuard) === null || _b === void 0 ? void 0 : _b.call(_a, this.
|
|
3070
|
-
}
|
|
3071
|
-
/**
|
|
3072
|
-
* Guard policy pushed to the driver, or `undefined` when the host has switched
|
|
3073
|
-
* the gate off — so `enabled: false` really does mean no local blocking of any
|
|
3074
|
-
* kind, UI or mid-loop.
|
|
3075
|
-
*
|
|
3076
|
-
* A MARGIN, not a threshold: the driver resolves the window of the provider
|
|
3077
|
-
* each call actually goes to. The fallback window rides along because the
|
|
3078
|
-
* driver cannot learn it any other way — without it, a host that knows its
|
|
3079
|
-
* window while its provider does not report one had `fallbackContextLimit`
|
|
3080
|
-
* protecting the composer and nothing else.
|
|
3081
|
-
*/
|
|
3082
|
-
get contextGuardPolicy() {
|
|
3083
|
-
var _a;
|
|
3084
|
-
if (!this.contextBudget.enabled)
|
|
3085
|
-
return undefined;
|
|
3086
|
-
return {
|
|
3087
|
-
marginTokens: CONTEXT_ABORT_MARGIN_TOKENS,
|
|
3088
|
-
fallbackLimit: (_a = this.chatConfig.context) === null || _a === void 0 ? void 0 : _a.fallbackContextLimit,
|
|
3089
|
-
};
|
|
2254
|
+
(_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.setContextGuard) === null || _b === void 0 ? void 0 : _b.call(_a, this.contextGateCtrl.guardPolicy);
|
|
3090
2255
|
}
|
|
3091
2256
|
loadProviderStatuses() {
|
|
3092
2257
|
return __awaiter(this, void 0, void 0, function* () {
|
|
@@ -3104,8 +2269,8 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
3104
2269
|
// the store is fresh and the user hasn't already toggled something.
|
|
3105
2270
|
this.applyPreferenceBaseline();
|
|
3106
2271
|
this.syncShowingSplash();
|
|
3107
|
-
this.
|
|
3108
|
-
this.
|
|
2272
|
+
this.costLedger.syncActiveTitle();
|
|
2273
|
+
this.costLedger.reload();
|
|
3109
2274
|
// `chatConfig.context` decides whether the mid-loop guard runs at all, so a
|
|
3110
2275
|
// late or changed binding has to reach the driver — otherwise switching the
|
|
3111
2276
|
// gate off leaves the guard armed from whatever the config said at connect.
|
|
@@ -3380,130 +2545,19 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
3380
2545
|
: 'standalone';
|
|
3381
2546
|
recordMetaEvent(key, type, Object.assign({ placement }, detail));
|
|
3382
2547
|
}
|
|
3383
|
-
closeOpenComposerMenus() {
|
|
3384
|
-
if (this.agentPickerOpen)
|
|
3385
|
-
this.toggleAgentPicker();
|
|
3386
|
-
if (this.sessionMenuOpen)
|
|
3387
|
-
this.toggleSessionMenu();
|
|
3388
|
-
}
|
|
3389
2548
|
/**
|
|
3390
|
-
*
|
|
3391
|
-
*
|
|
3392
|
-
*
|
|
3393
|
-
*
|
|
3394
|
-
* anchor is a button several levels down the bar, and no selector can read a
|
|
3395
|
-
* sibling's offset. So the left edge is measured on open and written as a
|
|
3396
|
-
* custom property.
|
|
2549
|
+
* Document-level dismissal for whichever composer menu is open. Bound only
|
|
2550
|
+
* while one IS open — a permanently-attached listener on a component that can
|
|
2551
|
+
* be mounted twice (bubble + popout panel) is a cost every click on the page
|
|
2552
|
+
* pays for a menu that is usually closed.
|
|
3397
2553
|
*
|
|
3398
|
-
*
|
|
3399
|
-
*
|
|
3400
|
-
*
|
|
3401
|
-
*
|
|
3402
|
-
* which is the whole point of anchoring it there.
|
|
2554
|
+
* Uses `composedPath()` rather than `event.target`: the click lands on a node
|
|
2555
|
+
* inside this element's shadow root, which retargets to the host, so a plain
|
|
2556
|
+
* `contains()` check cannot tell "clicked the menu" from "clicked the
|
|
2557
|
+
* transcript". The path crosses the boundary and carries both.
|
|
3403
2558
|
*/
|
|
3404
|
-
positionComposerMenu(panelSelector, triggerSelector) {
|
|
3405
|
-
const root = this.shadowRoot;
|
|
3406
|
-
const composer = root === null || root === void 0 ? void 0 : root.querySelector('.composer');
|
|
3407
|
-
const panel = root === null || root === void 0 ? void 0 : root.querySelector(panelSelector);
|
|
3408
|
-
const trigger = root === null || root === void 0 ? void 0 : root.querySelector(triggerSelector);
|
|
3409
|
-
if (!composer || !panel || !trigger)
|
|
3410
|
-
return;
|
|
3411
|
-
const box = composer.getBoundingClientRect();
|
|
3412
|
-
const anchor = trigger.getBoundingClientRect();
|
|
3413
|
-
// An absolutely-positioned child is placed against its containing block's
|
|
3414
|
-
// PADDING box, while getBoundingClientRect reports the border box — so every
|
|
3415
|
-
// offset below is taken from the padding edge. Skipping this is a 1px drift
|
|
3416
|
-
// that reads as the menu being very slightly out of line with its button.
|
|
3417
|
-
const style = getComputedStyle(composer);
|
|
3418
|
-
const originLeft = box.left + (parseFloat(style.borderLeftWidth) || 0);
|
|
3419
|
-
const originBottom = box.bottom - (parseFloat(style.borderBottomWidth) || 0);
|
|
3420
|
-
// A menu wider than the composer clamps to 0 rather than a negative offset:
|
|
3421
|
-
// Math.min picks the negative headroom, Math.max floors it at the left edge.
|
|
3422
|
-
const left = Math.max(0, Math.min(anchor.left - originLeft, composer.clientWidth - panel.offsetWidth));
|
|
3423
|
-
panel.style.setProperty('--composer-menu-left', `${left}px`);
|
|
3424
|
-
panel.style.setProperty('--composer-menu-origin', `${anchor.left + anchor.width / 2 - originLeft - left}px`);
|
|
3425
|
-
// Sit on the button rather than on top of the whole composer. The gap stays
|
|
3426
|
-
// in CSS so it can be expressed in design units; this is the raw distance
|
|
3427
|
-
// from the padding edge up to the button's top.
|
|
3428
|
-
panel.style.setProperty('--composer-menu-bottom', `${originBottom - anchor.top}px`);
|
|
3429
|
-
// Starting on the button rather than on the composer means less room above,
|
|
3430
|
-
// and the assistant can be short enough that a full-height menu would be
|
|
3431
|
-
// clipped by the wrapper's overflow. Cap it at what is actually available;
|
|
3432
|
-
// the menu scrolls inside whatever it gets.
|
|
3433
|
-
const wrapper = root === null || root === void 0 ? void 0 : root.querySelector('.chat-wrapper');
|
|
3434
|
-
if (wrapper) {
|
|
3435
|
-
const available = anchor.top -
|
|
3436
|
-
wrapper.getBoundingClientRect().top -
|
|
3437
|
-
FoundationAiAssistant_1.MENU_HEADROOM_RESERVE;
|
|
3438
|
-
panel.style.setProperty('--composer-menu-max-height', `${Math.max(COMPOSER_MENU_MIN_HEIGHT, available)}px`);
|
|
3439
|
-
}
|
|
3440
|
-
}
|
|
3441
|
-
/** Re-place whichever menu is open. Cheap enough to run from a resize. */
|
|
3442
|
-
repositionOpenComposerMenu() {
|
|
3443
|
-
if (this.agentPickerOpen) {
|
|
3444
|
-
this.positionComposerMenu('.agent-picker-panel', '.agent-toggle-button');
|
|
3445
|
-
}
|
|
3446
|
-
if (this.sessionMenuOpen) {
|
|
3447
|
-
this.positionComposerMenu('.session-menu-panel', '.session-menu-button');
|
|
3448
|
-
}
|
|
3449
|
-
}
|
|
3450
|
-
syncComposerMenuDismissal() {
|
|
3451
|
-
const shouldListen = this.agentPickerOpen || this.sessionMenuOpen;
|
|
3452
|
-
if (shouldListen === !!this._dismissPointerHandler)
|
|
3453
|
-
return;
|
|
3454
|
-
if (!shouldListen) {
|
|
3455
|
-
this.teardownComposerMenuDismissal();
|
|
3456
|
-
return;
|
|
3457
|
-
}
|
|
3458
|
-
this._dismissPointerHandler = (e) => {
|
|
3459
|
-
var _a;
|
|
3460
|
-
// The composer owns both the menus and the buttons that open them, so a
|
|
3461
|
-
// path that reaches it is an inside click — including the toggle itself,
|
|
3462
|
-
// whose own handler closes the menu. Closing here too would immediately
|
|
3463
|
-
// reopen-then-close and the menu would never appear.
|
|
3464
|
-
const composer = (_a = this.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector('.composer');
|
|
3465
|
-
if (composer && e.composedPath().includes(composer))
|
|
3466
|
-
return;
|
|
3467
|
-
this.closeOpenComposerMenus();
|
|
3468
|
-
};
|
|
3469
|
-
this._dismissKeyHandler = (e) => {
|
|
3470
|
-
var _a;
|
|
3471
|
-
if (e.key !== 'Escape')
|
|
3472
|
-
return;
|
|
3473
|
-
this.closeOpenComposerMenus();
|
|
3474
|
-
// Return focus to the input rather than leaving it on a button that has
|
|
3475
|
-
// just disappeared from under the user.
|
|
3476
|
-
(_a = this.chatInputEl) === null || _a === void 0 ? void 0 : _a.focus();
|
|
3477
|
-
};
|
|
3478
|
-
this._dismissResizeHandler = () => this.repositionOpenComposerMenu();
|
|
3479
|
-
document.addEventListener('pointerdown', this._dismissPointerHandler, true);
|
|
3480
|
-
document.addEventListener('keydown', this._dismissKeyHandler, true);
|
|
3481
|
-
window.addEventListener('resize', this._dismissResizeHandler);
|
|
3482
|
-
}
|
|
3483
|
-
teardownComposerMenuDismissal() {
|
|
3484
|
-
if (this._dismissPointerHandler) {
|
|
3485
|
-
document.removeEventListener('pointerdown', this._dismissPointerHandler, true);
|
|
3486
|
-
this._dismissPointerHandler = undefined;
|
|
3487
|
-
}
|
|
3488
|
-
if (this._dismissKeyHandler) {
|
|
3489
|
-
document.removeEventListener('keydown', this._dismissKeyHandler, true);
|
|
3490
|
-
this._dismissKeyHandler = undefined;
|
|
3491
|
-
}
|
|
3492
|
-
if (this._dismissResizeHandler) {
|
|
3493
|
-
window.removeEventListener('resize', this._dismissResizeHandler);
|
|
3494
|
-
this._dismissResizeHandler = undefined;
|
|
3495
|
-
}
|
|
3496
|
-
}
|
|
3497
2559
|
toggleAgentPicker() {
|
|
3498
|
-
|
|
3499
|
-
if (!this.agentPickerOpen && this.sessionMenuOpen)
|
|
3500
|
-
this.toggleSessionMenu();
|
|
3501
|
-
this.logMeta('panel.toggled', { panel: 'agent-picker', open: !this.agentPickerOpen });
|
|
3502
|
-
this._agentPickerToggle.toggle();
|
|
3503
|
-
// After the render that mounts the panel — it has no box to measure before that.
|
|
3504
|
-
if (this.agentPickerOpen) {
|
|
3505
|
-
void DOM.nextUpdate().then(() => this.positionComposerMenu('.agent-picker-panel', '.agent-toggle-button'));
|
|
3506
|
-
}
|
|
2560
|
+
this.composerMenus.toggleAgentPicker();
|
|
3507
2561
|
}
|
|
3508
2562
|
/**
|
|
3509
2563
|
* Whether the lifecycle-menu button shows. It occupies the stop-button slot,
|
|
@@ -3571,35 +2625,21 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
3571
2625
|
return !!this.persistence.enabled && !!this.persistence.provider;
|
|
3572
2626
|
}
|
|
3573
2627
|
/**
|
|
3574
|
-
*
|
|
3575
|
-
*
|
|
2628
|
+
* The context gate's derived surface (GENC-1567). Every member below delegates to
|
|
2629
|
+
* `ContextGateController` — the names are this element's public API and the
|
|
2630
|
+
* template's bindings, so they stay put while the derivation moves.
|
|
3576
2631
|
*
|
|
3577
2632
|
* The per-agent `contextReserve` IS folded in, via the driver (GENC-1582). It
|
|
3578
2633
|
* used to be described here as a separate, later check made when an agent took
|
|
3579
2634
|
* ownership of a flow — but there is no such check, and by flow entry the send
|
|
3580
|
-
* has already been allowed. Resolving it
|
|
3581
|
-
*
|
|
2635
|
+
* has already been allowed. Resolving it before the send is what makes it a gate
|
|
2636
|
+
* rather than a label.
|
|
3582
2637
|
*
|
|
3583
|
-
*
|
|
3584
|
-
*
|
|
3585
|
-
* `estimateSystemOverhead` only looks at messages BEFORE the first usage-bearing
|
|
3586
|
-
* one, and the two lists agree completely that early — the drift between mirror
|
|
3587
|
-
* and driver history is only ever at the tail.
|
|
2638
|
+
* None carries `@volatile`: the decorators live on the controller's getters, and
|
|
2639
|
+
* they reach the binding observer from there — see `docs/code-health.md` §11.4.
|
|
3588
2640
|
*/
|
|
3589
2641
|
get contextBudget() {
|
|
3590
|
-
|
|
3591
|
-
// so two reads can land on different winners and pair one agent's
|
|
3592
|
-
// `warnAheadTokens` with another's `tokens` — the exact pairing
|
|
3593
|
-
// `ContextReserveResolution` warns against, and the reason this is a local
|
|
3594
|
-
// rather than two property reads (reviewer catch on #2504). It also stops the
|
|
3595
|
-
// orchestrator walking every specialist plus the fallback twice per evaluation.
|
|
3596
|
-
const reserve = this.contextReserve;
|
|
3597
|
-
return resolveContextBudget({
|
|
3598
|
-
chatConfig: this.chatConfig,
|
|
3599
|
-
contextLimit: this.contextLimit,
|
|
3600
|
-
agentReserveTokens: reserve === null || reserve === void 0 ? void 0 : reserve.tokens,
|
|
3601
|
-
agentWarnAheadTokens: reserve === null || reserve === void 0 ? void 0 : reserve.warnAheadTokens,
|
|
3602
|
-
});
|
|
2642
|
+
return this.contextGateCtrl.budget;
|
|
3603
2643
|
}
|
|
3604
2644
|
/**
|
|
3605
2645
|
* Headroom the next turn needs, resolved against how routing is currently
|
|
@@ -3619,6 +2659,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
3619
2659
|
* `contextReserve` is a plain number by design — see `ContextReserveInput` for
|
|
3620
2660
|
* why a resolver form was dropped rather than accepted and ignored.
|
|
3621
2661
|
*
|
|
2662
|
+
* Stays on the element rather than moving into `ContextGateController` because it
|
|
2663
|
+
* reaches the driver, which no extracted controller may do (`docs/code-health.md`
|
|
2664
|
+
* §11.2 constraint 2). The controller receives the resolved value as a callback.
|
|
2665
|
+
*
|
|
3622
2666
|
* @internal
|
|
3623
2667
|
*/
|
|
3624
2668
|
get contextReserve() {
|
|
@@ -3645,250 +2689,46 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
3645
2689
|
basis: 'locked',
|
|
3646
2690
|
};
|
|
3647
2691
|
}
|
|
3648
|
-
/**
|
|
3649
|
-
* What compacting right now would reclaim, or `null` when it would not be
|
|
3650
|
-
* worth it (GENC-1567). Sized against {@link FoundationAiAssistant.contextBudget},
|
|
3651
|
-
* so the plan aims at this model's window rather than a fixed default.
|
|
3652
|
-
*
|
|
3653
|
-
* Reads `messages` to stay reactive as the conversation grows: the driver's
|
|
3654
|
-
* history is not a tracked observable, so every consumer of this keys its
|
|
3655
|
-
* re-evaluation off the Redux mirror.
|
|
3656
|
-
*/
|
|
3657
2692
|
get compactionPlan() {
|
|
3658
|
-
|
|
3659
|
-
if (this.messages.length === 0)
|
|
3660
|
-
return null;
|
|
3661
|
-
const budget = this.contextBudget;
|
|
3662
|
-
return ((_e = (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.getCompactionPlan) === null || _b === void 0 ? void 0 : _b.call(_a, {
|
|
3663
|
-
preambleTokens: (_d = (_c = this.driver) === null || _c === void 0 ? void 0 : _c.getPreambleTokens) === null || _d === void 0 ? void 0 : _d.call(_c),
|
|
3664
|
-
tailTokenBudget: budget.tailTokenBudget,
|
|
3665
|
-
minReclaimTokens: budget.minReclaimTokens,
|
|
3666
|
-
})) !== null && _e !== void 0 ? _e : null);
|
|
2693
|
+
return this.contextGateCtrl.plan;
|
|
3667
2694
|
}
|
|
3668
|
-
/**
|
|
3669
|
-
* Tooltip and hint for the Compact row — why it is or is not available.
|
|
3670
|
-
*
|
|
3671
|
-
* Replaces a fixed "Not enough conversation yet to be worth compacting", which
|
|
3672
|
-
* since GENC-1567 could be flatly wrong: a long conversation whose tail is one
|
|
3673
|
-
* indivisible tool result has plenty of conversation and still cannot be
|
|
3674
|
-
* compacted. Reading the plan means the hint states the actual reason.
|
|
3675
|
-
*
|
|
3676
|
-
* @internal
|
|
3677
|
-
*/
|
|
3678
|
-
get compactHint() {
|
|
3679
|
-
const plan = this.compactionPlan;
|
|
3680
|
-
if (plan)
|
|
3681
|
-
return `Summarize older messages to free up context`;
|
|
3682
|
-
return this.messages.length === 0
|
|
3683
|
-
? 'Nothing to compact yet'
|
|
3684
|
-
: 'There is nothing left to summarize here — the recent messages are too large to shrink further';
|
|
3685
|
-
}
|
|
3686
|
-
/**
|
|
3687
|
-
* How close this conversation is to the model's context window (GENC-1567).
|
|
3688
|
-
*
|
|
3689
|
-
* **Derived, never latched** — the opposite of {@link FoundationAiAssistant.blocked}.
|
|
3690
|
-
* A budget wall stays set because nothing the user does fixes it; this clears
|
|
3691
|
-
* itself the moment the conversation shrinks, which is precisely what the user
|
|
3692
|
-
* is being asked to do. The two must not be conflated: `blocked` is also
|
|
3693
|
-
* deliberately preserved across `resetSession`, and a context gate obviously
|
|
3694
|
-
* must not survive starting a new chat.
|
|
3695
|
-
*
|
|
3696
|
-
* **A flow in progress is never blocked, only warned.** Machine state lives
|
|
3697
|
-
* outside the transcript, so a stateful flow is not desynced by compaction —
|
|
3698
|
-
* but the summary can still drop detail the flow's own prompt leans on, and
|
|
3699
|
-
* blocking mid-journey would strand the user between a flow they cannot finish
|
|
3700
|
-
* and a compaction that is a poor idea right there. Letting the flow complete
|
|
3701
|
-
* and gating on the way out is the lesser harm; the headroom reserve exists so
|
|
3702
|
-
* this is rare rather than routine.
|
|
3703
|
-
*/
|
|
3704
2695
|
/**
|
|
3705
2696
|
* WHY the composer is blocked, when it is (GENC-1582). `undefined` unless
|
|
3706
|
-
* {@link FoundationAiAssistant.contextGate} is `'blocked'`.
|
|
3707
|
-
*
|
|
3708
|
-
*
|
|
3709
|
-
* refused either way — and every consumer switches on the three levels. What
|
|
3710
|
-
* differs is what the user can do about it, which is worth splitting because the
|
|
3711
|
-
* two answers have no remedy in common.
|
|
3712
|
-
*
|
|
3713
|
-
* - `'transcript'` — the conversation has grown into the reserve. Compacting or
|
|
3714
|
-
* clearing helps, and the copy says so.
|
|
3715
|
-
* - `'model'` — the active agent's reserve meets or exceeds the whole window, so
|
|
3716
|
-
* `blockAt` is 0 and EVERY message is refused, including the first of an empty
|
|
3717
|
-
* chat. Neither compacting nor clearing can help: the constraint is the model,
|
|
3718
|
-
* not the transcript. Before this existed the user was told to clear the chat,
|
|
3719
|
-
* did, and hit the identical block with no way to understand why.
|
|
3720
|
-
*
|
|
3721
|
-
* Keyed off `blockAt === 0`, deliberately NOT off whether a compaction plan
|
|
3722
|
-
* exists. A null plan has two unrelated causes — nothing left worth summarizing,
|
|
3723
|
-
* versus a window that cannot host the agent at all — and conflating them is what
|
|
3724
|
-
* produced the misleading copy.
|
|
3725
|
-
*
|
|
3726
|
-
* Note the block itself is correct in both cases and must stay: an agent that
|
|
3727
|
-
* measurably cannot complete a turn in this window would otherwise start one and
|
|
3728
|
-
* die mid-loop on a provider context error, with partial writes and no clean
|
|
3729
|
-
* exit. Refusing up front is the better failure.
|
|
2697
|
+
* {@link FoundationAiAssistant.contextGate} is `'blocked'`. See
|
|
2698
|
+
* `ContextGateController.blockCause` for the `'transcript'` / `'model'` split
|
|
2699
|
+
* and why it is a cause rather than a fourth gate level.
|
|
3730
2700
|
*
|
|
3731
2701
|
* @internal
|
|
3732
2702
|
*/
|
|
3733
2703
|
get contextBlockCause() {
|
|
3734
|
-
|
|
3735
|
-
return undefined;
|
|
3736
|
-
return this.contextBudget.blockAt === 0 ? 'model' : 'transcript';
|
|
2704
|
+
return this.contextGateCtrl.blockCause;
|
|
3737
2705
|
}
|
|
3738
2706
|
get contextGate() {
|
|
3739
|
-
|
|
3740
|
-
const budget = this.contextBudget;
|
|
3741
|
-
if (!budget.enabled)
|
|
3742
|
-
return 'ok';
|
|
3743
|
-
// The figure from the last moment nothing was running, not the live one — see
|
|
3744
|
-
// `_gateContextTokens` for why the gauge and the gate differ here.
|
|
3745
|
-
const tokens = (_a = this._gateContextTokens) !== null && _a !== void 0 ? _a : this.contextTokens;
|
|
3746
|
-
if (tokens == null || budget.blockAt == null || budget.warnAt == null)
|
|
3747
|
-
return 'ok';
|
|
3748
|
-
if (tokens >= budget.blockAt)
|
|
3749
|
-
return this.flowOwnerAgentName !== null ? 'warn' : 'blocked';
|
|
3750
|
-
return tokens >= budget.warnAt ? 'warn' : 'ok';
|
|
2707
|
+
return this.contextGateCtrl.gate;
|
|
3751
2708
|
}
|
|
3752
|
-
/**
|
|
3753
|
-
* What the composer notice says about the context gate.
|
|
3754
|
-
*
|
|
3755
|
-
* The copy is a function of whether compaction can actually clear the gate,
|
|
3756
|
-
* which is why it reads {@link FoundationAiAssistant.compactionPlan} rather
|
|
3757
|
-
* than assuming. Telling someone to compact when the projection says it would
|
|
3758
|
-
* not free enough — a tail that is one indivisible tool result, say — sends
|
|
3759
|
-
* them into a summarizer call that leaves them exactly where they were.
|
|
3760
|
-
*
|
|
3761
|
-
* @internal
|
|
3762
|
-
*/
|
|
3763
2709
|
get contextGateReason() {
|
|
3764
|
-
|
|
3765
|
-
const level = this.contextGate;
|
|
3766
|
-
const canHelp = this.compactionPlan != null;
|
|
3767
|
-
if (level === 'blocked' && this.contextBlockCause === 'model') {
|
|
3768
|
-
// The window cannot host this agent, so neither remedy the other branches
|
|
3769
|
-
// offer can help. Name the agent and both figures instead: the numbers are
|
|
3770
|
-
// what make "switch model" an instruction rather than a shrug, and they are
|
|
3771
|
-
// already to hand.
|
|
3772
|
-
const reserve = this.contextReserve;
|
|
3773
|
-
const need = reserve ? `${reserve.tokens.toLocaleString()} tokens` : 'more headroom';
|
|
3774
|
-
const have = (_a = this.contextLimit) === null || _a === void 0 ? void 0 : _a.toLocaleString();
|
|
3775
|
-
const who = (_b = reserve === null || reserve === void 0 ? void 0 : reserve.agentName) !== null && _b !== void 0 ? _b : 'This task';
|
|
3776
|
-
return (`${who} needs about ${need} of context headroom to complete a turn, and this model has ` +
|
|
3777
|
-
`${have !== null && have !== void 0 ? have : 'less than that'}. Switch to a model with a larger context window, or pick a ` +
|
|
3778
|
-
`different agent — compacting or clearing this conversation will not help.`);
|
|
3779
|
-
}
|
|
3780
|
-
if (level === 'blocked') {
|
|
3781
|
-
// Name the agent whose headroom is binding when routing is automatic
|
|
3782
|
-
// (GENC-1582). The reserve is then the hungriest agent the turn could land
|
|
3783
|
-
// on, so the composer can close while there is plenty of room for the short
|
|
3784
|
-
// question the user was about to type — and a message that only said "not
|
|
3785
|
-
// enough room for more work" would read as a bug rather than a reason.
|
|
3786
|
-
const reserve = this.contextReserve;
|
|
3787
|
-
const cause = (reserve === null || reserve === void 0 ? void 0 : reserve.basis) === 'reachable-max'
|
|
3788
|
-
? `There is not enough room in the model context to start another task — ${reserve.agentName} needs the most of any I could pick up next.`
|
|
3789
|
-
: 'There is not enough room in the model context for more work.';
|
|
3790
|
-
const remedy = canHelp
|
|
3791
|
-
? 'Click the error pill and compact the context to continue working.'
|
|
3792
|
-
: 'Compacting the context will not free enough room, click the error pill to clear and start a new conversation.';
|
|
3793
|
-
return `${cause} ${remedy}`;
|
|
3794
|
-
}
|
|
3795
|
-
if (this.flowOwnerAgentName !== null) {
|
|
3796
|
-
return 'This conversation is nearly full. The task in progress will finish, but compact before starting another.';
|
|
3797
|
-
}
|
|
3798
|
-
return canHelp
|
|
3799
|
-
? 'This conversation is getting long. Compact it to free up room.'
|
|
3800
|
-
: 'This conversation is getting long and is close to the limit.';
|
|
2710
|
+
return this.contextGateCtrl.gateReason;
|
|
3801
2711
|
}
|
|
3802
|
-
/**
|
|
3803
|
-
* Short label the session-menu pill wears while the gate is active — the
|
|
3804
|
-
* primary visible signal, in place of a banner.
|
|
3805
|
-
*
|
|
3806
|
-
* Borrowed from the agent pin, which is the established way this composer says
|
|
3807
|
-
* "a mode is in force": a pill that grows a label rather than a strip of prose
|
|
3808
|
-
* above the input. It states the CONDITION only; the remedy lives in the
|
|
3809
|
-
* placeholder once the composer is actually blocked, which is the moment the
|
|
3810
|
-
* user needs to be told what to do rather than merely warned.
|
|
3811
|
-
*
|
|
3812
|
-
* @internal
|
|
3813
|
-
*/
|
|
3814
2712
|
get contextGateLabel() {
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
}
|
|
2713
|
+
return this.contextGateCtrl.gateLabel;
|
|
2714
|
+
}
|
|
2715
|
+
get compactHint() {
|
|
2716
|
+
return this.contextGateCtrl.compactHint;
|
|
2717
|
+
}
|
|
2718
|
+
get compactable() {
|
|
2719
|
+
return this.contextGateCtrl.compactable;
|
|
3823
2720
|
}
|
|
3824
|
-
/**
|
|
3825
|
-
* Whether the composer notice has anything to show — a budget wall or the
|
|
3826
|
-
* context gate. One region for both, so the live region stays single and
|
|
3827
|
-
* registered (see the banner template's accessibility note).
|
|
3828
|
-
*
|
|
3829
|
-
* @internal
|
|
3830
|
-
*/
|
|
3831
2721
|
get composerNoticeVisible() {
|
|
3832
|
-
return this.
|
|
2722
|
+
return this.contextGateCtrl.noticeVisible;
|
|
3833
2723
|
}
|
|
3834
|
-
/**
|
|
3835
|
-
* Text for that region. A budget wall outranks the context gate: it is the
|
|
3836
|
-
* condition the user cannot clear, so it must not be hidden behind advice
|
|
3837
|
-
* about compacting that would not restore sending anyway.
|
|
3838
|
-
*
|
|
3839
|
-
* For the context gate the region is present and announced but not drawn — see
|
|
3840
|
-
* {@link FoundationAiAssistant.composerNoticeSrOnly}.
|
|
3841
|
-
*
|
|
3842
|
-
* @internal
|
|
3843
|
-
*/
|
|
3844
2724
|
get composerNoticeText() {
|
|
3845
|
-
|
|
3846
|
-
return this.effectiveBlockedReason;
|
|
3847
|
-
return this.contextGate === 'ok' ? '' : this.contextGateReason;
|
|
2725
|
+
return this.contextGateCtrl.noticeText;
|
|
3848
2726
|
}
|
|
3849
|
-
/**
|
|
3850
|
-
* Whether the notice region should be announced but not drawn.
|
|
3851
|
-
*
|
|
3852
|
-
* The context gate shows itself visually through the pill's label, the
|
|
3853
|
-
* composer outline and the placeholder — a strip of prose on top of those
|
|
3854
|
-
* would be a fourth statement of the same thing. But dropping the region
|
|
3855
|
-
* outright would be an accessibility regression, and a documented one: a
|
|
3856
|
-
* `role="status"` region announces MUTATIONS to a region already being
|
|
3857
|
-
* observed, and it is the only announcement available here, because the
|
|
3858
|
-
* composer flips to `disabled` at the same moment and a disabled control is out
|
|
3859
|
-
* of the tab order — so neither the placeholder nor an `aria-label` on it is
|
|
3860
|
-
* ever voiced.
|
|
3861
|
-
*
|
|
3862
|
-
* So the region stays mounted, keeps its text and keeps announcing; only the
|
|
3863
|
-
* visual treatment is withheld. A budget wall still draws its banner, because
|
|
3864
|
-
* that condition has no other visible expression.
|
|
3865
|
-
*
|
|
3866
|
-
* @internal
|
|
3867
|
-
*/
|
|
3868
2727
|
get composerNoticeSrOnly() {
|
|
3869
|
-
return
|
|
3870
|
-
}
|
|
3871
|
-
/**
|
|
3872
|
-
* Whether the menu's "Compact" action would do anything (GENC-1351 §5.7,
|
|
3873
|
-
* GENC-1567). Delegates to the driver's plan so the gate uses the exact same
|
|
3874
|
-
* history `compact()` acts on, judged against the same budget — one source of
|
|
3875
|
-
* truth for both whether a cut is legal and whether it reclaims enough.
|
|
3876
|
-
*/
|
|
3877
|
-
get compactable() {
|
|
3878
|
-
return this.compactionPlan != null;
|
|
2728
|
+
return this.contextGateCtrl.noticeSrOnly;
|
|
3879
2729
|
}
|
|
3880
2730
|
toggleSessionMenu() {
|
|
3881
|
-
|
|
3882
|
-
// (not a leftover Clear-confirm prompt).
|
|
3883
|
-
if (!this.sessionMenuOpen && this.agentPickerOpen)
|
|
3884
|
-
this.toggleAgentPicker();
|
|
3885
|
-
this.confirmingClear = false;
|
|
3886
|
-
this.confirmingCompact = false;
|
|
3887
|
-
this.logMeta('panel.toggled', { panel: 'session-menu', open: !this.sessionMenuOpen });
|
|
3888
|
-
this._sessionMenuToggle.toggle();
|
|
3889
|
-
if (this.sessionMenuOpen) {
|
|
3890
|
-
void DOM.nextUpdate().then(() => this.positionComposerMenu('.session-menu-panel', '.session-menu-button'));
|
|
3891
|
-
}
|
|
2731
|
+
this.composerMenus.toggleSessionMenu();
|
|
3892
2732
|
}
|
|
3893
2733
|
/**
|
|
3894
2734
|
* Set persistence on/off for this session (the menu switch). Reassigns the
|
|
@@ -4009,7 +2849,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
4009
2849
|
if (!(driver === null || driver === void 0 ? void 0 : driver.compact) || driver.isBusy() || this.compacting)
|
|
4010
2850
|
return;
|
|
4011
2851
|
this.confirmingCompact = false;
|
|
4012
|
-
this.
|
|
2852
|
+
this.composerMenus.closeImmediate();
|
|
4013
2853
|
// Swap the transcript for a "Compacting…" indicator while the async summary
|
|
4014
2854
|
// runs. Purely visual: the driver only replaces history on success, so a
|
|
4015
2855
|
// failure just restores the untouched messages when the flag clears — and
|
|
@@ -4108,7 +2948,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
4108
2948
|
// append-only and handled separately below.
|
|
4109
2949
|
yield ((_h = this.persister()) === null || _h === void 0 ? void 0 : _h.clearRestoreCore());
|
|
4110
2950
|
this.confirmingClear = false;
|
|
4111
|
-
this.
|
|
2951
|
+
this.composerMenus.closeImmediate();
|
|
4112
2952
|
this.logMeta('session.cleared', { agentName });
|
|
4113
2953
|
// Diagnostics is append-only, so Clear never wipes the prior log — the
|
|
4114
2954
|
// provider.clear above only drops the restore-core snapshot. Now restart the
|
|
@@ -4158,81 +2998,61 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
4158
2998
|
return false;
|
|
4159
2999
|
return ((_c = this.agents) !== null && _c !== void 0 ? _c : []).some((a) => { var _a; return (_a = a.manualSelection) === null || _a === void 0 ? void 0 : _a.enabled; });
|
|
4160
3000
|
}
|
|
4161
|
-
/**
|
|
3001
|
+
/**
|
|
3002
|
+
* The settings modal's derived surface. Every member below delegates to
|
|
3003
|
+
* `SettingsModalController` — the names are this element's public API and the
|
|
3004
|
+
* 943-line template's bindings, so they stay put while the logic moves into the
|
|
3005
|
+
* `settings-modal.ts` that folder never had (`docs/code-health.md` §3).
|
|
3006
|
+
*
|
|
3007
|
+
* None carries `@volatile`: the decorators live on the controller's getters, and
|
|
3008
|
+
* they reach the binding observer from there — see `docs/code-health.md` §11.4.
|
|
3009
|
+
*/
|
|
4162
3010
|
get settingsModelSectionVisible() {
|
|
4163
|
-
return
|
|
4164
|
-
this.settingsContextUsageVisible ||
|
|
4165
|
-
this.settingsBudgetUsageVisible);
|
|
3011
|
+
return this.settings.modelSectionVisible;
|
|
4166
3012
|
}
|
|
4167
3013
|
/** Built-in context window indicator (generic platform concern). */
|
|
4168
3014
|
get settingsContextUsageVisible() {
|
|
4169
|
-
|
|
4170
|
-
|
|
3015
|
+
return this.settings.contextUsageVisible;
|
|
3016
|
+
}
|
|
3017
|
+
/**
|
|
3018
|
+
* The context indicator's figures, pre-formatted, or `undefined` when there is
|
|
3019
|
+
* nothing measurable to show — see {@link SettingsContextUsage}.
|
|
3020
|
+
*
|
|
3021
|
+
* @internal
|
|
3022
|
+
*/
|
|
3023
|
+
get settingsContextUsage() {
|
|
3024
|
+
return this.settings.contextUsage;
|
|
4171
3025
|
}
|
|
4172
3026
|
/**
|
|
4173
3027
|
* Built-in per-vendor budget meter (GENC-1464) — the money sibling of
|
|
4174
3028
|
* {@link FoundationAiAssistant.settingsContextUsageVisible}, with the same
|
|
4175
|
-
* shape of gate: not configured off, and there is data to show.
|
|
4176
|
-
*
|
|
4177
|
-
*
|
|
4178
|
-
* context indicator's absent case.
|
|
4179
|
-
*
|
|
4180
|
-
* Answered from the RAW slice state rather than from
|
|
4181
|
-
* {@link FoundationAiAssistant.settingsBudgetRows}, whose `.length` would
|
|
4182
|
-
* format every row (a `formatUsd` pair and a `budgetPercentOf` each) only to
|
|
4183
|
-
* ask whether any exist. This is read several times per render pass — the
|
|
4184
|
-
* getter, the section gate, the template's `when` — so it stays as cheap as
|
|
4185
|
-
* the context sibling's `!= null`.
|
|
3029
|
+
* shape of gate: not configured off, and there is data to show. See
|
|
3030
|
+
* `BudgetController.settingsUsageVisible` for why it reads the raw slice
|
|
3031
|
+
* rather than counting the rows.
|
|
4186
3032
|
*
|
|
4187
3033
|
* @internal
|
|
4188
3034
|
*/
|
|
4189
3035
|
get settingsBudgetUsageVisible() {
|
|
4190
|
-
|
|
4191
|
-
const budgets = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.vendorBudgets;
|
|
4192
|
-
return (((_b = this.chatConfig.ui) === null || _b === void 0 ? void 0 : _b.showBudgetUsage) !== false &&
|
|
4193
|
-
budgets != null &&
|
|
4194
|
-
// Keyed off BUDGETED_VENDORS, exactly as the rows are, so a key outside
|
|
4195
|
-
// that list could never surface a meter with no row to show.
|
|
4196
|
-
BUDGETED_VENDORS.some((vendor) => budgets[vendor] != null));
|
|
3036
|
+
return this.budget.settingsUsageVisible;
|
|
4197
3037
|
}
|
|
4198
3038
|
/**
|
|
4199
3039
|
* The meter's rows — one per vendor that currently has figures, in
|
|
4200
|
-
* `BUDGETED_VENDORS` order
|
|
4201
|
-
*
|
|
4202
|
-
*
|
|
3040
|
+
* `BUDGETED_VENDORS` order so the rendering is stable however the host ordered
|
|
3041
|
+
* its feed calls. Pre-formatted; see {@link SettingsBudgetRow} for why the text
|
|
3042
|
+
* is unclamped while the bar is.
|
|
4203
3043
|
*
|
|
4204
3044
|
* @internal
|
|
4205
3045
|
*/
|
|
4206
3046
|
get settingsBudgetRows() {
|
|
4207
|
-
|
|
4208
|
-
const budgets = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.vendorBudgets;
|
|
4209
|
-
if (!budgets)
|
|
4210
|
-
return [];
|
|
4211
|
-
const rows = [];
|
|
4212
|
-
for (const vendor of BUDGETED_VENDORS) {
|
|
4213
|
-
const figures = budgets[vendor];
|
|
4214
|
-
if (!figures)
|
|
4215
|
-
continue;
|
|
4216
|
-
const percent = budgetPercentOf(figures);
|
|
4217
|
-
rows.push({
|
|
4218
|
-
vendor,
|
|
4219
|
-
label: vendorDisplayName(vendor),
|
|
4220
|
-
figures: `${formatUsd(figures.spentUsd)} / ${formatUsd(figures.budgetUsd)} (${percent}%)`,
|
|
4221
|
-
// The same clamp the context indicator applies to its bar. No lower
|
|
4222
|
-
// clamp needed: the boundary refuses negative figures.
|
|
4223
|
-
barValue: Math.min(100, percent),
|
|
4224
|
-
});
|
|
4225
|
-
}
|
|
4226
|
-
return rows;
|
|
3047
|
+
return this.budget.settingsRows;
|
|
4227
3048
|
}
|
|
4228
3049
|
/** Whether the settings modal UI Builder section should render. */
|
|
4229
3050
|
get settingsAppSectionVisible() {
|
|
4230
|
-
return this.
|
|
3051
|
+
return this.settings.appSectionVisible;
|
|
4231
3052
|
}
|
|
4232
3053
|
/** App toggles currently visible per each entry's `visible()` predicate. */
|
|
4233
3054
|
get visibleAppSettingsToggles() {
|
|
4234
|
-
|
|
4235
|
-
return this.appSettingsProvider.toggles.filter((toggle) => { var _a; return ((_a = toggle.visible) === null || _a === void 0 ? void 0 : _a.call(toggle)) !== false; });
|
|
3055
|
+
return this.settings.visibleAppSettingsToggles;
|
|
4236
3056
|
}
|
|
4237
3057
|
/**
|
|
4238
3058
|
* Host-supplied heading for the toggle group, or `undefined` for none — the assistant has no
|
|
@@ -4240,237 +3060,72 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
4240
3060
|
* the last one takes its heading with it rather than leaving a label over nothing.
|
|
4241
3061
|
*/
|
|
4242
3062
|
get appSettingsTogglesHeading() {
|
|
4243
|
-
|
|
4244
|
-
if (this.visibleAppSettingsToggles.length === 0)
|
|
4245
|
-
return undefined;
|
|
4246
|
-
return this.appSettingsProvider.togglesHeading;
|
|
3063
|
+
return this.settings.appSettingsTogglesHeading;
|
|
4247
3064
|
}
|
|
4248
3065
|
getAppSettingValue(id) {
|
|
4249
|
-
|
|
4250
|
-
return this.appSettingsProvider.getValue(id);
|
|
3066
|
+
return this.settings.getAppSettingValue(id);
|
|
4251
3067
|
}
|
|
4252
3068
|
setAppSettingValue(id, value) {
|
|
4253
|
-
this.
|
|
4254
|
-
}
|
|
4255
|
-
wireAppSettingsProvider() {
|
|
4256
|
-
this.unwireAppSettingsProvider();
|
|
4257
|
-
if (!this.appSettingsProvider.subscribe)
|
|
4258
|
-
return;
|
|
4259
|
-
this.unsubAppSettingsProvider = this.appSettingsProvider.subscribe(() => {
|
|
4260
|
-
this.appSettingsRevision += 1;
|
|
4261
|
-
});
|
|
4262
|
-
}
|
|
4263
|
-
unwireAppSettingsProvider() {
|
|
4264
|
-
var _a;
|
|
4265
|
-
(_a = this.unsubAppSettingsProvider) === null || _a === void 0 ? void 0 : _a.call(this);
|
|
4266
|
-
this.unsubAppSettingsProvider = undefined;
|
|
3069
|
+
this.settings.setAppSettingValue(id, value);
|
|
4267
3070
|
}
|
|
4268
3071
|
get settingsCostCurrentSectionVisible() {
|
|
4269
|
-
return this.
|
|
3072
|
+
return this.settings.costCurrentSectionVisible;
|
|
4270
3073
|
}
|
|
4271
3074
|
/**
|
|
4272
3075
|
* Whether the build-history section has anything in it — the built-in row list, or a host's own
|
|
4273
|
-
* markup in the `settings-cost-history` slot.
|
|
4274
|
-
*
|
|
4275
|
-
*
|
|
4276
|
-
* it gates the row writes, the reload, and the lifetime sum — and it defaults to `false`
|
|
4277
|
-
* (`cost?.enabled === true`), while the Usage tab itself shows for `showCost`/`showTokens`, which
|
|
4278
|
-
* default true. So a host can legitimately render its own build history into the slot and never
|
|
4279
|
-
* opt into our `localStorage` ledger. Short-circuiting on `enabled` above the slot check hid that
|
|
4280
|
-
* host's markup outright, since the slot is the only thing in the container.
|
|
3076
|
+
* markup in the `settings-cost-history` slot. See
|
|
3077
|
+
* `SettingsModalController.costHistorySectionVisible` for why slotted content is
|
|
3078
|
+
* checked ahead of `enabled`.
|
|
4281
3079
|
*/
|
|
4282
3080
|
get settingsCostHistorySectionVisible() {
|
|
4283
|
-
|
|
4284
|
-
return true;
|
|
4285
|
-
if (!this.resolvedCostHistory.enabled)
|
|
4286
|
-
return false;
|
|
4287
|
-
return this.costSessionHistory.length > 0;
|
|
3081
|
+
return this.settings.costHistorySectionVisible;
|
|
4288
3082
|
}
|
|
4289
3083
|
/**
|
|
4290
|
-
* Whether the "Build history" heading has anything under it
|
|
4291
|
-
*
|
|
4292
|
-
*
|
|
4293
|
-
* heading also covers the lifetime total, which renders before the first row exists (rows are
|
|
4294
|
-
* written as spend accrues, the lifetime section only needs history switched on). Gating the
|
|
4295
|
-
* heading on the rows alone left that total sitting under nothing.
|
|
3084
|
+
* Whether the "Build history" heading has anything under it — broader than
|
|
3085
|
+
* {@link FoundationAiAssistant.settingsCostHistorySectionVisible}, because the
|
|
3086
|
+
* heading also covers the lifetime total, which renders before the first row exists.
|
|
4296
3087
|
*/
|
|
4297
3088
|
get settingsCostHistoryHeadingVisible() {
|
|
4298
|
-
return this.
|
|
3089
|
+
return this.settings.costHistoryHeadingVisible;
|
|
4299
3090
|
}
|
|
4300
3091
|
/** Whether the built-in Usage tab summary block has anything to show. */
|
|
4301
3092
|
get settingsCostSummaryVisible() {
|
|
4302
|
-
|
|
4303
|
-
void this.chatConfig;
|
|
4304
|
-
const cost = this.resolvedCostHistory;
|
|
4305
|
-
if (cost.showCost)
|
|
4306
|
-
return true;
|
|
4307
|
-
if (cost.showTokens)
|
|
4308
|
-
return true;
|
|
4309
|
-
if (((_a = this.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.showActiveModel) !== false && this.providerStatuses.length > 0) {
|
|
4310
|
-
return true;
|
|
4311
|
-
}
|
|
4312
|
-
return !!this.liveCostSessionTitle;
|
|
3093
|
+
return this.settings.costSummaryVisible;
|
|
4313
3094
|
}
|
|
4314
3095
|
/**
|
|
4315
|
-
* Whether the lifetime-usage section has anything to say.
|
|
4316
|
-
*
|
|
4317
|
-
* Requires build history: with it off there are no rows, so `cumulativeUsage` falls back to
|
|
4318
|
-
* the live session and the section would restate the block directly below it under a
|
|
4319
|
-
* "lifetime" heading it hasn't earned.
|
|
3096
|
+
* Whether the lifetime-usage section has anything to say. Requires build history
|
|
3097
|
+
* AND a resolvable state key — see `SettingsModalController.costLifetimeVisible`.
|
|
4320
3098
|
*/
|
|
4321
3099
|
get settingsCostLifetimeVisible() {
|
|
4322
|
-
|
|
4323
|
-
void this._sessionRef;
|
|
4324
|
-
const cost = this.resolvedCostHistory;
|
|
4325
|
-
if (!cost.enabled || !(cost.showCost || cost.showTokens))
|
|
4326
|
-
return false;
|
|
4327
|
-
// Also requires a state key, because `finalizeCostSession` skips the write without one and the
|
|
4328
|
-
// lifetime figures sum rows only — so the section would show a permanent $0.00 directly above
|
|
4329
|
-
// a live block reporting real spend.
|
|
4330
|
-
//
|
|
4331
|
-
// Narrow in practice: `getStateKey` falls back to `id::headerTitle`, and `headerTitle` has a
|
|
4332
|
-
// non-empty default, so a key is almost always derivable even with no `session-key`. This
|
|
4333
|
-
// catches the remaining case — a host that supplies no session key, no element id, AND blanks
|
|
4334
|
-
// the header title. Cheap, and the alternative is a figure that is simply wrong.
|
|
4335
|
-
return !!this.getStateKey();
|
|
3100
|
+
return this.settings.costLifetimeVisible;
|
|
4336
3101
|
}
|
|
4337
3102
|
/** Whether the Usage tab should appear in the settings modal. */
|
|
4338
3103
|
get settingsCostTabVisible() {
|
|
4339
|
-
|
|
4340
|
-
const cost = this.resolvedCostHistory;
|
|
4341
|
-
return (cost.showCost ||
|
|
4342
|
-
cost.showTokens ||
|
|
4343
|
-
this.settingsCostCurrentSectionVisible ||
|
|
4344
|
-
this.settingsCostHistorySectionVisible);
|
|
3104
|
+
return this.settings.costTabVisible;
|
|
4345
3105
|
}
|
|
4346
3106
|
/** Whether session token usage should render on the Usage tab. */
|
|
4347
3107
|
get settingsSessionTokensVisible() {
|
|
4348
|
-
|
|
4349
|
-
return this.resolvedCostHistory.showTokens;
|
|
3108
|
+
return this.settings.sessionTokensVisible;
|
|
4350
3109
|
}
|
|
4351
3110
|
/** Whether the chat header (and settings cog) should render. */
|
|
4352
3111
|
get settingsCogVisible() {
|
|
4353
|
-
|
|
4354
|
-
const ui = this.chatConfig.ui;
|
|
4355
|
-
const cost = this.resolvedCostHistory;
|
|
4356
|
-
return (!!this.popoutMode ||
|
|
4357
|
-
!!this.headerTitle ||
|
|
4358
|
-
(ui === null || ui === void 0 ? void 0 : ui.showToolCalls) != null ||
|
|
4359
|
-
(ui === null || ui === void 0 ? void 0 : ui.showThinkingSteps) != null ||
|
|
4360
|
-
(ui === null || ui === void 0 ? void 0 : ui.showNarration) != null ||
|
|
4361
|
-
(ui === null || ui === void 0 ? void 0 : ui.showAgentSwitchIndicator) != null ||
|
|
4362
|
-
(ui === null || ui === void 0 ? void 0 : ui.allowDebugDownload) === true ||
|
|
4363
|
-
(ui === null || ui === void 0 ? void 0 : ui.animations) != null ||
|
|
4364
|
-
cost.showCost ||
|
|
4365
|
-
cost.showTokens ||
|
|
4366
|
-
(ui === null || ui === void 0 ? void 0 : ui.showContextUsage) !== false ||
|
|
4367
|
-
(ui === null || ui === void 0 ? void 0 : ui.showActiveModel) !== false ||
|
|
4368
|
-
this.visibleAppSettingsToggles.length > 0);
|
|
3112
|
+
return this.settings.cogVisible;
|
|
4369
3113
|
}
|
|
4370
3114
|
openSettingsModal() {
|
|
4371
|
-
this.
|
|
4372
|
-
// The meter refreshes per turn, which a user staring at the modal cannot
|
|
4373
|
-
// see — ask the host for fresh figures at the moment they start looking.
|
|
4374
|
-
this.requestBudgetRefresh();
|
|
4375
|
-
this.settingsModalTab = 'settings';
|
|
4376
|
-
this.settingsOpen = true;
|
|
4377
|
-
const show = () => {
|
|
4378
|
-
if (!this.settingsOpen)
|
|
4379
|
-
return;
|
|
4380
|
-
if (this.settingsModal) {
|
|
4381
|
-
if (this.settingsModal.open)
|
|
4382
|
-
return;
|
|
4383
|
-
this.settingsModal.show();
|
|
4384
|
-
requestAnimationFrame(() => this.resetSettingsModalTabsOnOpen());
|
|
4385
|
-
}
|
|
4386
|
-
else {
|
|
4387
|
-
DOM.queueUpdate(show);
|
|
4388
|
-
}
|
|
4389
|
-
};
|
|
4390
|
-
DOM.queueUpdate(show);
|
|
3115
|
+
this.settings.openModal();
|
|
4391
3116
|
}
|
|
4392
3117
|
closeSettingsModal() {
|
|
4393
|
-
|
|
4394
|
-
this.logMeta('panel.toggled', { panel: 'settings', open: false });
|
|
4395
|
-
if ((_a = this.settingsModal) === null || _a === void 0 ? void 0 : _a.open) {
|
|
4396
|
-
this.settingsModal.close();
|
|
4397
|
-
return;
|
|
4398
|
-
}
|
|
4399
|
-
this.settingsModalTab = 'settings';
|
|
4400
|
-
this.settingsOpen = false;
|
|
3118
|
+
this.settings.closeModal();
|
|
4401
3119
|
}
|
|
4402
3120
|
onSettingsModalClosed() {
|
|
4403
|
-
this.
|
|
4404
|
-
this.settingsOpen = false;
|
|
4405
|
-
requestAnimationFrame(() => this.resetSettingsModalTabsOnClose());
|
|
3121
|
+
this.settings.onClosed();
|
|
4406
3122
|
}
|
|
4407
3123
|
setSettingsModalTab(tab) {
|
|
4408
|
-
|
|
4409
|
-
return;
|
|
4410
|
-
this.settingsModalTab = tab;
|
|
3124
|
+
this.settings.setTab(tab);
|
|
4411
3125
|
}
|
|
4412
3126
|
/** Sync the active settings modal tab when the design-system tab bar changes. */
|
|
4413
3127
|
onSettingsModalTabsChange(event) {
|
|
4414
|
-
|
|
4415
|
-
const id = (_a = event.detail) === null || _a === void 0 ? void 0 : _a.id;
|
|
4416
|
-
if (id === 'settings' || id === 'cost') {
|
|
4417
|
-
this.setSettingsModalTab(id);
|
|
4418
|
-
}
|
|
4419
|
-
}
|
|
4420
|
-
/** Ensure the settings tab is selected when the modal opens. */
|
|
4421
|
-
resetSettingsModalTabsOnOpen() {
|
|
4422
|
-
this.settingsModalTab = 'settings';
|
|
4423
|
-
const tabs = this.settingsTabsEl;
|
|
4424
|
-
if (!tabs)
|
|
4425
|
-
return;
|
|
4426
|
-
const settingsTab = tabs.querySelector('[id="settings"]');
|
|
4427
|
-
const costTab = tabs.querySelector('[id="cost"]');
|
|
4428
|
-
if (costTab instanceof HTMLElement && costTab.getAttribute('aria-selected') === 'true') {
|
|
4429
|
-
settingsTab instanceof HTMLElement && settingsTab.click();
|
|
4430
|
-
return;
|
|
4431
|
-
}
|
|
4432
|
-
if (settingsTab instanceof HTMLElement &&
|
|
4433
|
-
settingsTab.getAttribute('aria-selected') !== 'true') {
|
|
4434
|
-
settingsTab.click();
|
|
4435
|
-
return;
|
|
4436
|
-
}
|
|
4437
|
-
this.snapSettingsModalTabIndicator();
|
|
4438
|
-
}
|
|
4439
|
-
/** Return to the settings tab when the modal closes on Cost. */
|
|
4440
|
-
resetSettingsModalTabsOnClose() {
|
|
4441
|
-
const tabs = this.settingsTabsEl;
|
|
4442
|
-
if (!tabs)
|
|
4443
|
-
return;
|
|
4444
|
-
const settingsTab = tabs.querySelector('[id="settings"]');
|
|
4445
|
-
const costTab = tabs.querySelector('[id="cost"]');
|
|
4446
|
-
if (costTab instanceof HTMLElement && costTab.getAttribute('aria-selected') === 'true') {
|
|
4447
|
-
settingsTab instanceof HTMLElement && settingsTab.click();
|
|
4448
|
-
}
|
|
4449
|
-
this.snapSettingsModalTabIndicator();
|
|
4450
|
-
}
|
|
4451
|
-
/**
|
|
4452
|
-
* Snap the tab underline to the settings tab without animating from Cost.
|
|
4453
|
-
* Used after hide/show when aria state is correct but the indicator drifted.
|
|
4454
|
-
*/
|
|
4455
|
-
snapSettingsModalTabIndicator(retry = true) {
|
|
4456
|
-
var _a;
|
|
4457
|
-
const tabs = this.settingsTabsEl;
|
|
4458
|
-
if (!tabs)
|
|
4459
|
-
return;
|
|
4460
|
-
const settingsIndex = 0;
|
|
4461
|
-
const indicator = (_a = tabs.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector('.activeIndicator');
|
|
4462
|
-
if (!indicator) {
|
|
4463
|
-
if (retry)
|
|
4464
|
-
DOM.queueUpdate(() => this.snapSettingsModalTabIndicator(false));
|
|
4465
|
-
return;
|
|
4466
|
-
}
|
|
4467
|
-
const internal = tabs;
|
|
4468
|
-
indicator.style.gridColumn = `${settingsIndex + 1}`;
|
|
4469
|
-
indicator.style.transform = 'translateX(0px)';
|
|
4470
|
-
indicator.classList.remove('activeIndicatorTransition');
|
|
4471
|
-
internal.ticking = false;
|
|
4472
|
-
internal.prevActiveTabIndex = settingsIndex;
|
|
4473
|
-
internal.activeTabIndex = settingsIndex;
|
|
3128
|
+
this.settings.onTabsChange(event);
|
|
4474
3129
|
}
|
|
4475
3130
|
/** Hint text for the currently pinned agent, if any. Used in the toggle button tooltip. */
|
|
4476
3131
|
get pinnedAgentHint() {
|
|
@@ -4608,112 +3263,30 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
4608
3263
|
];
|
|
4609
3264
|
}
|
|
4610
3265
|
/**
|
|
4611
|
-
* The
|
|
4612
|
-
*
|
|
4613
|
-
* the forward-capture delta keys on.
|
|
3266
|
+
* The `meta-snapshot` entry for right now. The assembly is a pure transformation
|
|
3267
|
+
* in `buildMetaSnapshot`; this gathers what it reports.
|
|
4614
3268
|
*
|
|
4615
|
-
*
|
|
4616
|
-
*
|
|
4617
|
-
*
|
|
4618
|
-
* `
|
|
3269
|
+
* The live agent comes from the DRIVER, not `this.activeAgent` (the redux-stripped
|
|
3270
|
+
* projection, no functions) and not a lookup in `this.agents` (which may mint
|
|
3271
|
+
* fresh, never-activated configs) — only the driver holds the instance whose
|
|
3272
|
+
* `onActivate` ran, so only its `getDebugSnapshot` closure holds state.
|
|
4619
3273
|
*/
|
|
4620
3274
|
buildMetaSnapshot() {
|
|
4621
|
-
var _a, _b, _c
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
? Math.round((this.contextTokens / this.contextLimit) * 100)
|
|
4637
|
-
: undefined;
|
|
4638
|
-
// The export-time `meta` block, carried on a `meta-snapshot` entry so it lives
|
|
4639
|
-
// in the same forward stream (the latest one wins on reassembly, and the
|
|
4640
|
-
// accumulated history exposes config/state evolution across the lifetime).
|
|
4641
|
-
const metaSnapshot = {
|
|
4642
|
-
kind: 'meta-snapshot',
|
|
4643
|
-
timestamp: new Date().toISOString(),
|
|
4644
|
-
meta: {
|
|
4645
|
-
timestamp,
|
|
4646
|
-
host: window.location.host,
|
|
4647
|
-
// stripAgentHandlers drops every function-valued field (handlers, lifecycle
|
|
4648
|
-
// hooks, onUnresolvedTool, function-form resolvers) AND object handler bags
|
|
4649
|
-
// like an object-form `toolHandlers`, and recurses subAgents — no manual
|
|
4650
|
-
// exclusion list to keep in sync. We only override toolDefinitions
|
|
4651
|
-
// afterwards to expand the fold tree for the log.
|
|
4652
|
-
agentSummary: (_d = this.agents) === null || _d === void 0 ? void 0 : _d.map((a) => {
|
|
4653
|
-
var _a;
|
|
4654
|
-
return (Object.assign(Object.assign({}, stripAgentHandlers(a)), { toolDefinitions: Array.isArray(a.toolDefinitions)
|
|
4655
|
-
? typeof a.toolHandlers === 'function'
|
|
4656
|
-
? // Static defs + dynamic handlers — can't walk fold tree
|
|
4657
|
-
// because the handler map isn't materialized at log time.
|
|
4658
|
-
a.toolDefinitions
|
|
4659
|
-
: expandToolTree(a.toolDefinitions, (_a = a.toolHandlers) !== null && _a !== void 0 ? _a : {})
|
|
4660
|
-
: typeof a.toolDefinitions === 'function'
|
|
4661
|
-
? '<dynamic — resolved per turn>'
|
|
4662
|
-
: [] }));
|
|
4663
|
-
}),
|
|
4664
|
-
activeSystemPrompt: typeof ((_e = this.activeAgent) === null || _e === void 0 ? void 0 : _e.systemPrompt) === 'function'
|
|
4665
|
-
? '<dynamic — resolved per turn>'
|
|
4666
|
-
: (_f = this.activeAgent) === null || _f === void 0 ? void 0 : _f.systemPrompt,
|
|
4667
|
-
activePrimerHistory: (_g = this.activeAgent) === null || _g === void 0 ? void 0 : _g.primerHistory,
|
|
4668
|
-
activeFoldStack: this.driver instanceof ChatDriver ? this.driver.getActiveFoldNames() : undefined,
|
|
4669
|
-
// Context window + cost snapshot. `sessionCostUsd` is the chat-scoped
|
|
4670
|
-
// total (per-message `cost` summed); the transport's lifetime cost is
|
|
4671
|
-
// not included here because it pools other call sites (suggestions,
|
|
4672
|
-
// criteria interpretation, etc.) and would be misleading next to the
|
|
4673
|
-
// chat-only token counts.
|
|
4674
|
-
context: {
|
|
4675
|
-
model: this.activeModel,
|
|
4676
|
-
contextTokens: this.contextTokens,
|
|
4677
|
-
contextLimit: this.contextLimit,
|
|
4678
|
-
contextUsagePercent,
|
|
4679
|
-
sessionCostUsd: this.sessionCostUsd,
|
|
4680
|
-
sessionTokensConsumed: this.sessionTokensConsumed,
|
|
4681
|
-
// The per-bucket split alongside the total. Without it a log shows a large
|
|
4682
|
-
// token count next to a small cost with no way to tell why — the answer is
|
|
4683
|
-
// almost always that most of the prompt was a cache hit.
|
|
4684
|
-
sessionUsage: this.sessionUsage,
|
|
4685
|
-
},
|
|
4686
|
-
// Snapshot captured fresh at log-export time — reflects state NOW, which
|
|
4687
|
-
// may have transitioned since the last LLM call.
|
|
4688
|
-
activeDebugSnapshot,
|
|
4689
|
-
debug: (_h = this.debugStateFactory) === null || _h === void 0 ? void 0 : _h.call(this),
|
|
4690
|
-
},
|
|
4691
|
-
};
|
|
4692
|
-
// Stable dedup signature for the forward-capture stream (GENC-1351 §5.8 / #12):
|
|
4693
|
-
// the near-static config only. Excludes the volatile fields — the export
|
|
4694
|
-
// `timestamp`, the per-turn `context` (tokens/cost), and the live
|
|
4695
|
-
// `activeDebugSnapshot` — whose churn otherwise made the whole-`meta` dedup key
|
|
4696
|
-
// differ on every throttled flush, so a bulky snapshot was re-appended each time
|
|
4697
|
-
// instead of "only when the block actually changed". The volatile evolution is
|
|
4698
|
-
// already in the timeline (turn snapshots + `context.updated` events), so the
|
|
4699
|
-
// persister only needs a fresh meta-snapshot when the config/prompt changes.
|
|
4700
|
-
// Consequence for the download path: the newest STORED snapshot's context/cost
|
|
4701
|
-
// figures are stale, which is why `buildDownloadLog` appends a fresh one.
|
|
4702
|
-
//
|
|
4703
|
-
// DIGESTED rather than stored verbatim (GENC-1566). The projection is right; keeping a
|
|
4704
|
-
// full copy of it was not. It is only ever compared for equality, and `agentSummary`
|
|
4705
|
-
// alone was 159 KB in the session that prompted this — so each snapshot carried the
|
|
4706
|
-
// block plus a stringified copy of most of it, and both went up the wire on every
|
|
4707
|
-
// flush. See {@link configDigest} for the hash and why a collision is harmless here.
|
|
4708
|
-
const m = metaSnapshot.meta;
|
|
4709
|
-
metaSnapshot.dedupSignature = configDigest(JSON.stringify({
|
|
4710
|
-
host: m.host,
|
|
4711
|
-
agentSummary: m.agentSummary,
|
|
4712
|
-
activeSystemPrompt: m.activeSystemPrompt,
|
|
4713
|
-
activePrimerHistory: m.activePrimerHistory,
|
|
4714
|
-
activeFoldStack: m.activeFoldStack,
|
|
4715
|
-
}));
|
|
4716
|
-
return metaSnapshot;
|
|
3275
|
+
var _a, _b, _c;
|
|
3276
|
+
return buildMetaSnapshot({
|
|
3277
|
+
agents: this.agents,
|
|
3278
|
+
activeAgent: this.activeAgent,
|
|
3279
|
+
liveActiveAgent: (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.getActiveAgent) === null || _b === void 0 ? void 0 : _b.call(_a),
|
|
3280
|
+
activeFoldStack: this.driver instanceof ChatDriver ? this.driver.getActiveFoldNames() : undefined,
|
|
3281
|
+
activeModel: this.activeModel,
|
|
3282
|
+
contextTokens: this.contextTokens,
|
|
3283
|
+
contextLimit: this.contextLimit,
|
|
3284
|
+
sessionCostUsd: this.sessionCostUsd,
|
|
3285
|
+
sessionTokensConsumed: this.sessionTokensConsumed,
|
|
3286
|
+
sessionUsage: this.sessionUsage,
|
|
3287
|
+
debugState: (_c = this.debugStateFactory) === null || _c === void 0 ? void 0 : _c.call(this),
|
|
3288
|
+
host: window.location.host,
|
|
3289
|
+
});
|
|
4717
3290
|
}
|
|
4718
3291
|
downloadDebugLog() {
|
|
4719
3292
|
return __awaiter(this, void 0, void 0, function* () {
|
|
@@ -4946,146 +3519,16 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
4946
3519
|
});
|
|
4947
3520
|
}
|
|
4948
3521
|
triggerFileInput() {
|
|
4949
|
-
|
|
4950
|
-
(_b = (_a = this.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector('.file-input')) === null || _b === void 0 ? void 0 : _b.click();
|
|
3522
|
+
this.attachmentCtrl.triggerFileInput();
|
|
4951
3523
|
}
|
|
4952
3524
|
handleFileSelect(e) {
|
|
4953
|
-
|
|
3525
|
+
this.attachmentCtrl.handleFileSelect(e);
|
|
4954
3526
|
}
|
|
4955
3527
|
removeAttachment(attachment) {
|
|
4956
|
-
this.
|
|
3528
|
+
this.attachmentCtrl.remove(attachment);
|
|
4957
3529
|
}
|
|
4958
3530
|
removeAttachmentError(message) {
|
|
4959
|
-
|
|
4960
|
-
if (idx !== -1) {
|
|
4961
|
-
this.attachmentErrors = [
|
|
4962
|
-
...this.attachmentErrors.slice(0, idx),
|
|
4963
|
-
...this.attachmentErrors.slice(idx + 1),
|
|
4964
|
-
];
|
|
4965
|
-
}
|
|
4966
|
-
}
|
|
4967
|
-
isAcceptedFile(file) {
|
|
4968
|
-
var _a, _b, _c;
|
|
4969
|
-
const accepted = (_a = this.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.acceptedFiles;
|
|
4970
|
-
if (!accepted)
|
|
4971
|
-
return false;
|
|
4972
|
-
const acceptedList = accepted.split(',').map((s) => s.trim().toLowerCase());
|
|
4973
|
-
const ext = `.${(_c = (_b = file.name.split('.').pop()) === null || _b === void 0 ? void 0 : _b.toLowerCase()) !== null && _c !== void 0 ? _c : ''}`;
|
|
4974
|
-
return acceptedList.some((a) => a === ext ||
|
|
4975
|
-
a === file.type ||
|
|
4976
|
-
(a.endsWith('/*') && file.type.startsWith(a.replace('/*', '/'))));
|
|
4977
|
-
}
|
|
4978
|
-
// Blob's own promise APIs, not FileReader: UTF-8 only (a `charset` parameter on the blob's
|
|
4979
|
-
// type is ignored, where FileReader would honour it), and they exist in the node test runtime
|
|
4980
|
-
// too — FileReader does not, so neither reader was testable before.
|
|
4981
|
-
readFileAsText(file) {
|
|
4982
|
-
return file.text();
|
|
4983
|
-
}
|
|
4984
|
-
/** The base64 payload of a file, without any `data:` prefix — the raw form
|
|
4985
|
-
* `ChatImageAttachment.data` specifies (transports add their own framing). */
|
|
4986
|
-
readFileAsBase64(file) {
|
|
4987
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
4988
|
-
const bytes = new Uint8Array(yield file.arrayBuffer());
|
|
4989
|
-
let binary = '';
|
|
4990
|
-
const CHUNK = 0x8000;
|
|
4991
|
-
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
4992
|
-
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
4993
|
-
}
|
|
4994
|
-
return btoa(binary);
|
|
4995
|
-
});
|
|
4996
|
-
}
|
|
4997
|
-
processFiles(files_1) {
|
|
4998
|
-
return __awaiter(this, arguments, void 0, function* (files, existingBytes = 0) {
|
|
4999
|
-
// Budget the batch BEFORE reading any of it. `file.size` needs no I/O, so the verdict is
|
|
5000
|
-
// deterministic and in the user's own pick order; deciding inside the parallel map below would
|
|
5001
|
-
// hand the last slot to whichever read happened to finish first. `existingBytes` is what the
|
|
5002
|
-
// caller has already committed to — the picker appends to a running list, so its files have to
|
|
5003
|
-
// count against what is already attached rather than starting from zero each time.
|
|
5004
|
-
let running = existingBytes;
|
|
5005
|
-
const overBudget = new Map();
|
|
5006
|
-
for (const file of files) {
|
|
5007
|
-
if (running + file.size > MAX_ATTACHMENTS_TOTAL_BYTES) {
|
|
5008
|
-
overBudget.set(file, `"${file.name}" would take this message past the ` +
|
|
5009
|
-
`${MAX_ATTACHMENTS_TOTAL_BYTES / BYTES_PER_MB}MB attachment limit ` +
|
|
5010
|
-
`(${(running / BYTES_PER_MB).toFixed(1)}MB already attached).`);
|
|
5011
|
-
continue;
|
|
5012
|
-
}
|
|
5013
|
-
running += file.size;
|
|
5014
|
-
}
|
|
5015
|
-
const results = yield Promise.all(files.map((file) => __awaiter(this, void 0, void 0, function* () {
|
|
5016
|
-
if (!this.isAcceptedFile(file)) {
|
|
5017
|
-
return { ok: false, message: `"${file.name}" is not an accepted file type.` };
|
|
5018
|
-
}
|
|
5019
|
-
const tooMuch = overBudget.get(file);
|
|
5020
|
-
if (tooMuch)
|
|
5021
|
-
return { ok: false, message: tooMuch };
|
|
5022
|
-
try {
|
|
5023
|
-
// An image file becomes a real image attachment — the transports render those as
|
|
5024
|
-
// image blocks the model SEES. Reading it as text (the only path that existed)
|
|
5025
|
-
// produced mojibake the model "read" as prose: an accepted .png was worse than a
|
|
5026
|
-
// rejected one. Text stays the default for everything else.
|
|
5027
|
-
//
|
|
5028
|
-
// Only the types the vendors actually accept take this branch. `image/*` covers far
|
|
5029
|
-
// more than that, and sending one an API rejects fails the whole turn the user is
|
|
5030
|
-
// waiting on — where the text branch merely degrades. It also keeps SVG working:
|
|
5031
|
-
// SVG *is* text, and reading it as such gives the model something it can genuinely
|
|
5032
|
-
// read, which a base64 image block of it would not.
|
|
5033
|
-
if (VENDOR_IMAGE_MIME_TYPES.has(file.type.toLowerCase())) {
|
|
5034
|
-
if (file.size > MAX_IMAGE_ATTACHMENT_BYTES) {
|
|
5035
|
-
return {
|
|
5036
|
-
ok: false,
|
|
5037
|
-
message: `"${file.name}" is ${Math.ceil(file.size / BYTES_PER_MB)}MB; images are limited to ` +
|
|
5038
|
-
`${MAX_IMAGE_ATTACHMENT_BYTES / BYTES_PER_MB}MB.`,
|
|
5039
|
-
};
|
|
5040
|
-
}
|
|
5041
|
-
const data = yield this.readFileAsBase64(file);
|
|
5042
|
-
return {
|
|
5043
|
-
ok: true,
|
|
5044
|
-
attachment: { kind: 'image', name: file.name, mimeType: file.type, data },
|
|
5045
|
-
};
|
|
5046
|
-
}
|
|
5047
|
-
const content = yield this.readFileAsText(file);
|
|
5048
|
-
return {
|
|
5049
|
-
ok: true,
|
|
5050
|
-
attachment: { name: file.name, content, mimeType: file.type || 'text/plain' },
|
|
5051
|
-
};
|
|
5052
|
-
}
|
|
5053
|
-
catch (readError) {
|
|
5054
|
-
logger.error('Failed to read file:', readError);
|
|
5055
|
-
this.logMeta('file.read-failed', {
|
|
5056
|
-
file: file.name,
|
|
5057
|
-
message: readError instanceof Error ? readError.message : String(readError),
|
|
5058
|
-
});
|
|
5059
|
-
return { ok: false, message: `Failed to read "${file.name}".` };
|
|
5060
|
-
}
|
|
5061
|
-
})));
|
|
5062
|
-
return {
|
|
5063
|
-
attachments: results
|
|
5064
|
-
.filter((r) => r.ok)
|
|
5065
|
-
.map((r) => r.attachment),
|
|
5066
|
-
errors: results
|
|
5067
|
-
.filter((r) => !r.ok)
|
|
5068
|
-
.map((r) => r.message),
|
|
5069
|
-
};
|
|
5070
|
-
});
|
|
5071
|
-
}
|
|
5072
|
-
loadSelectedFiles(e) {
|
|
5073
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
5074
|
-
var _a;
|
|
5075
|
-
const input = e.target;
|
|
5076
|
-
const files = Array.from((_a = input.files) !== null && _a !== void 0 ? _a : []);
|
|
5077
|
-
input.value = '';
|
|
5078
|
-
const { attachments, errors } = yield this.processFiles(files, this.attachments.reduce((n, a) => n + attachmentBytes(a), 0));
|
|
5079
|
-
if (attachments.length) {
|
|
5080
|
-
this.attachments = [...this.attachments, ...attachments];
|
|
5081
|
-
this.logMeta('attachment.added', {
|
|
5082
|
-
count: attachments.length,
|
|
5083
|
-
names: attachments.map((a) => a.name),
|
|
5084
|
-
});
|
|
5085
|
-
}
|
|
5086
|
-
if (errors.length)
|
|
5087
|
-
this.attachmentErrors = [...this.attachmentErrors, ...errors];
|
|
5088
|
-
});
|
|
3531
|
+
this.attachmentCtrl.dismissError(message);
|
|
5089
3532
|
}
|
|
5090
3533
|
handleSendClick() {
|
|
5091
3534
|
this.send();
|
|
@@ -5141,7 +3584,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
5141
3584
|
}
|
|
5142
3585
|
let nextAttachments = [];
|
|
5143
3586
|
if ((_a = input.files) === null || _a === void 0 ? void 0 : _a.length) {
|
|
5144
|
-
const { attachments, errors } = yield this.
|
|
3587
|
+
const { attachments, errors } = yield this.attachmentCtrl.process(input.files);
|
|
5145
3588
|
// State may have flipped to 'loading' during the async file processing
|
|
5146
3589
|
// (e.g. a concurrent send via the composer). Re-check before committing
|
|
5147
3590
|
// any composer state. Beyond this point execution is synchronous up to
|
|
@@ -5260,10 +3703,8 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
5260
3703
|
// which in turn triggers the _executionCompletionUnsub subscription on whichever
|
|
5261
3704
|
// element is currently active (e.g. the collapse-mode element after docking).
|
|
5262
3705
|
const capturedSessionRef = this._sessionRef;
|
|
5263
|
-
const pendingAttachments = this.
|
|
3706
|
+
const pendingAttachments = this.attachmentCtrl.take();
|
|
5264
3707
|
this.inputValue = '';
|
|
5265
|
-
this.attachments = [];
|
|
5266
|
-
this.attachmentErrors = [];
|
|
5267
3708
|
this.suggestionsState = { status: 'idle' };
|
|
5268
3709
|
this._userScrolledAway = false;
|
|
5269
3710
|
// Close the picker on send — the toggle button is disabled during loading,
|
|
@@ -5283,10 +3724,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
5283
3724
|
// locks without waiting on the host's next pre-flight. The bus subscription
|
|
5284
3725
|
// in `connectedCallback` covers most walls; this covers the one it cannot —
|
|
5285
3726
|
// a wall hit during multi-agent classification, where no tool loop ran and
|
|
5286
|
-
// so no `tool-loop-end` was published. `
|
|
3727
|
+
// so no `tool-loop-end` was published. `latchFrom` is idempotent, so
|
|
5287
3728
|
// the two seams overlapping is harmless.
|
|
5288
3729
|
if ((result === null || result === void 0 ? void 0 : result.reason) === 'done') {
|
|
5289
|
-
this.
|
|
3730
|
+
this.budget.latchFrom(result.failureReason, capturedSessionRef, result.budget);
|
|
5290
3731
|
}
|
|
5291
3732
|
}
|
|
5292
3733
|
finally {
|
|
@@ -5392,58 +3833,22 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
5392
3833
|
get sendButtonVisible() {
|
|
5393
3834
|
return !this.stopButtonVisible;
|
|
5394
3835
|
}
|
|
5395
|
-
/** Whether the message input shows a drag handle to resize its height.
|
|
5396
|
-
* On by default; hosts opt out via `chatConfig.ui.resizableInput: false`. */
|
|
3836
|
+
/** Whether the message input shows a drag handle to resize its height. */
|
|
5397
3837
|
get isComposerResizable() {
|
|
5398
|
-
|
|
5399
|
-
return ((_a = this.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.resizableInput) !== false;
|
|
3838
|
+
return this.composerResize.resizable;
|
|
5400
3839
|
}
|
|
5401
|
-
/** Inline style
|
|
5402
|
-
* `--composer-height` custom property (read by `.chat-input` + its control in
|
|
5403
|
-
* `main.styles.ts`). Empty until the user drags (or when resizing is disabled),
|
|
5404
|
-
* so the default applies. */
|
|
3840
|
+
/** Inline style carrying the dragged height into `--composer-height`. */
|
|
5405
3841
|
get composerHeightStyle() {
|
|
5406
|
-
return this.
|
|
5407
|
-
? `--composer-height: ${this.composerHeightPx}px`
|
|
5408
|
-
: '';
|
|
3842
|
+
return this.composerResize.heightStyle;
|
|
5409
3843
|
}
|
|
5410
|
-
/** Begin a composer resize drag. Captures the pointer so move/up keep firing
|
|
5411
|
-
* on the handle even when the cursor leaves its 7px band. */
|
|
5412
3844
|
onComposerResizeStart(e) {
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
// First drag (no explicit height yet) measures the natural height — the
|
|
5416
|
-
// floor for shrinking, so the box never goes below where it started.
|
|
5417
|
-
if (this.composerHeightPx == null)
|
|
5418
|
-
this._composerNaturalHeight = measured;
|
|
5419
|
-
this._resizeStartY = e.clientY;
|
|
5420
|
-
this._resizeStartHeight = (_c = this.composerHeightPx) !== null && _c !== void 0 ? _c : measured;
|
|
5421
|
-
// Cap growth to the headroom currently above the composer: the message
|
|
5422
|
-
// list can yield all but a visible strip of its height. The pool (composer
|
|
5423
|
-
// + messages) is fixed for the duration of one drag, so measuring once here
|
|
5424
|
-
// is correct throughout the gesture.
|
|
5425
|
-
const messagesHeight = (_e = (_d = this.messagesEl) === null || _d === void 0 ? void 0 : _d.getBoundingClientRect().height) !== null && _e !== void 0 ? _e : COMPOSER_MAX_HEIGHT_PX;
|
|
5426
|
-
this._resizeMaxHeight = Math.min(COMPOSER_MAX_HEIGHT_PX, this._resizeStartHeight + Math.max(0, messagesHeight - COMPOSER_MIN_MESSAGES_PX));
|
|
5427
|
-
e.currentTarget.setPointerCapture(e.pointerId);
|
|
5428
|
-
}
|
|
5429
|
-
/** Resize while dragging. Dragging up (clientY decreases) grows the composer;
|
|
5430
|
-
* the result is clamped between the natural height (lower) and the available
|
|
5431
|
-
* headroom/max (upper). No-op unless a drag is active (the handle holds pointer
|
|
5432
|
-
* capture), so plain hover-moves are ignored. */
|
|
3845
|
+
this.composerResize.onStart(e);
|
|
3846
|
+
}
|
|
5433
3847
|
onComposerResizeMove(e) {
|
|
5434
|
-
|
|
5435
|
-
if (!handle.hasPointerCapture(e.pointerId))
|
|
5436
|
-
return;
|
|
5437
|
-
const min = Math.max(COMPOSER_MIN_HEIGHT_PX, Math.round(this._composerNaturalHeight));
|
|
5438
|
-
const max = Math.max(min, Math.round(this._resizeMaxHeight));
|
|
5439
|
-
const next = this._resizeStartHeight + (this._resizeStartY - e.clientY);
|
|
5440
|
-
this.composerHeightPx = Math.max(min, Math.min(max, Math.round(next)));
|
|
3848
|
+
this.composerResize.onMove(e);
|
|
5441
3849
|
}
|
|
5442
|
-
/** End a resize drag and release the pointer capture. */
|
|
5443
3850
|
onComposerResizeEnd(e) {
|
|
5444
|
-
|
|
5445
|
-
if (handle.hasPointerCapture(e.pointerId))
|
|
5446
|
-
handle.releasePointerCapture(e.pointerId);
|
|
3851
|
+
this.composerResize.onEnd(e);
|
|
5447
3852
|
}
|
|
5448
3853
|
};
|
|
5449
3854
|
FoundationAiAssistant.SCROLL_BOTTOM_THRESHOLD_PX = 50;
|
|
@@ -5452,16 +3857,6 @@ FoundationAiAssistant.CONTEXT_USAGE_WARN_PERCENT = 80;
|
|
|
5452
3857
|
FoundationAiAssistant.DEFAULT_LOADING_DELAY_S = 0;
|
|
5453
3858
|
FoundationAiAssistant.DEFAULT_SUGGESTION_COUNT = 3;
|
|
5454
3859
|
FoundationAiAssistant.MS_PER_SECOND = 1000;
|
|
5455
|
-
/**
|
|
5456
|
-
* Vertical space an open menu must leave free above the button, covering BOTH
|
|
5457
|
-
* the gap the menu sits off the button by (8px, declared in design units in
|
|
5458
|
-
* main.styles.ts) and the breathing room wanted above it. The two are lumped
|
|
5459
|
-
* together because only the second half is a free choice: the first is fixed by
|
|
5460
|
-
* the stylesheet and simply has to be subtracted from the headroom, and a
|
|
5461
|
-
* constant named for only one of them invites someone to "fix" a gap that is
|
|
5462
|
-
* doing exactly what it should.
|
|
5463
|
-
*/
|
|
5464
|
-
FoundationAiAssistant.MENU_HEADROOM_RESERVE = 20;
|
|
5465
3860
|
__decorate([
|
|
5466
3861
|
AIProviderRegistry
|
|
5467
3862
|
], FoundationAiAssistant.prototype, "providerRegistry", void 0);
|
|
@@ -5528,78 +3923,15 @@ __decorate([
|
|
|
5528
3923
|
__decorate([
|
|
5529
3924
|
volatile
|
|
5530
3925
|
], FoundationAiAssistant.prototype, "busy", null);
|
|
5531
|
-
__decorate([
|
|
5532
|
-
volatile
|
|
5533
|
-
], FoundationAiAssistant.prototype, "blocked", null);
|
|
5534
|
-
__decorate([
|
|
5535
|
-
volatile
|
|
5536
|
-
], FoundationAiAssistant.prototype, "reachableVendors", null);
|
|
5537
|
-
__decorate([
|
|
5538
|
-
observable
|
|
5539
|
-
], FoundationAiAssistant.prototype, "budgetMeterLastFedAt", void 0);
|
|
5540
|
-
__decorate([
|
|
5541
|
-
volatile
|
|
5542
|
-
], FoundationAiAssistant.prototype, "settingsBudgetUpdatedLabel", null);
|
|
5543
|
-
__decorate([
|
|
5544
|
-
volatile
|
|
5545
|
-
], FoundationAiAssistant.prototype, "relevantBlockedVendors", null);
|
|
5546
|
-
__decorate([
|
|
5547
|
-
volatile
|
|
5548
|
-
], FoundationAiAssistant.prototype, "bannerVisible", null);
|
|
5549
|
-
__decorate([
|
|
5550
|
-
volatile
|
|
5551
|
-
], FoundationAiAssistant.prototype, "suggestionsBlocked", null);
|
|
5552
|
-
__decorate([
|
|
5553
|
-
volatile
|
|
5554
|
-
], FoundationAiAssistant.prototype, "activeVendor", null);
|
|
5555
|
-
__decorate([
|
|
5556
|
-
volatile
|
|
5557
|
-
], FoundationAiAssistant.prototype, "effectiveBlockedReason", null);
|
|
5558
3926
|
__decorate([
|
|
5559
3927
|
volatile
|
|
5560
3928
|
], FoundationAiAssistant.prototype, "effectiveChatInputDuringExecution", null);
|
|
5561
|
-
__decorate([
|
|
5562
|
-
volatile
|
|
5563
|
-
], FoundationAiAssistant.prototype, "uniqueProviderModels", null);
|
|
5564
|
-
__decorate([
|
|
5565
|
-
volatile
|
|
5566
|
-
], FoundationAiAssistant.prototype, "sortedCostSessionHistory", null);
|
|
5567
|
-
__decorate([
|
|
5568
|
-
volatile
|
|
5569
|
-
], FoundationAiAssistant.prototype, "cumulativeUsage", null);
|
|
5570
|
-
__decorate([
|
|
5571
|
-
volatile
|
|
5572
|
-
], FoundationAiAssistant.prototype, "cumulativeCostUsd", null);
|
|
5573
|
-
__decorate([
|
|
5574
|
-
volatile
|
|
5575
|
-
], FoundationAiAssistant.prototype, "liveUsageIsProjectLifetime", null);
|
|
5576
|
-
__decorate([
|
|
5577
|
-
volatile
|
|
5578
|
-
], FoundationAiAssistant.prototype, "resolvedCostHistory", null);
|
|
5579
|
-
__decorate([
|
|
5580
|
-
volatile
|
|
5581
|
-
], FoundationAiAssistant.prototype, "liveCostSessionTitle", null);
|
|
5582
|
-
__decorate([
|
|
5583
|
-
observable
|
|
5584
|
-
], FoundationAiAssistant.prototype, "attachments", void 0);
|
|
5585
|
-
__decorate([
|
|
5586
|
-
observable
|
|
5587
|
-
], FoundationAiAssistant.prototype, "attachmentErrors", void 0);
|
|
5588
3929
|
__decorate([
|
|
5589
3930
|
observable
|
|
5590
3931
|
], FoundationAiAssistant.prototype, "showLoadingIndicator", void 0);
|
|
5591
|
-
__decorate([
|
|
5592
|
-
observable
|
|
5593
|
-
], FoundationAiAssistant.prototype, "appSettingsRevision", void 0);
|
|
5594
|
-
__decorate([
|
|
5595
|
-
observable
|
|
5596
|
-
], FoundationAiAssistant.prototype, "settingsOpen", void 0);
|
|
5597
3932
|
__decorate([
|
|
5598
3933
|
observable
|
|
5599
3934
|
], FoundationAiAssistant.prototype, "downloadingDebugLog", void 0);
|
|
5600
|
-
__decorate([
|
|
5601
|
-
observable
|
|
5602
|
-
], FoundationAiAssistant.prototype, "settingsModalTab", void 0);
|
|
5603
3935
|
__decorate([
|
|
5604
3936
|
observable
|
|
5605
3937
|
], FoundationAiAssistant.prototype, "persistenceBroken", void 0);
|
|
@@ -5609,27 +3941,9 @@ __decorate([
|
|
|
5609
3941
|
__decorate([
|
|
5610
3942
|
observable
|
|
5611
3943
|
], FoundationAiAssistant.prototype, "persistenceBrokenDetail", void 0);
|
|
5612
|
-
__decorate([
|
|
5613
|
-
observable
|
|
5614
|
-
], FoundationAiAssistant.prototype, "settingsModelSlotted", void 0);
|
|
5615
|
-
__decorate([
|
|
5616
|
-
observable
|
|
5617
|
-
], FoundationAiAssistant.prototype, "settingsAppSlotted", void 0);
|
|
5618
|
-
__decorate([
|
|
5619
|
-
observable
|
|
5620
|
-
], FoundationAiAssistant.prototype, "settingsCostCurrentSlotted", void 0);
|
|
5621
|
-
__decorate([
|
|
5622
|
-
observable
|
|
5623
|
-
], FoundationAiAssistant.prototype, "settingsCostHistorySlotted", void 0);
|
|
5624
|
-
__decorate([
|
|
5625
|
-
observable
|
|
5626
|
-
], FoundationAiAssistant.prototype, "costSessionHistory", void 0);
|
|
5627
3944
|
__decorate([
|
|
5628
3945
|
observable
|
|
5629
3946
|
], FoundationAiAssistant.prototype, "showingSplash", void 0);
|
|
5630
|
-
__decorate([
|
|
5631
|
-
observable
|
|
5632
|
-
], FoundationAiAssistant.prototype, "composerHeightPx", void 0);
|
|
5633
3947
|
__decorate([
|
|
5634
3948
|
observable
|
|
5635
3949
|
], FoundationAiAssistant.prototype, "isEngaged", void 0);
|
|
@@ -5639,9 +3953,6 @@ __decorate([
|
|
|
5639
3953
|
__decorate([
|
|
5640
3954
|
volatile
|
|
5641
3955
|
], FoundationAiAssistant.prototype, "visibleMessages", null);
|
|
5642
|
-
__decorate([
|
|
5643
|
-
volatile
|
|
5644
|
-
], FoundationAiAssistant.prototype, "contextGuardPolicy", null);
|
|
5645
3956
|
__decorate([
|
|
5646
3957
|
observable
|
|
5647
3958
|
], FoundationAiAssistant.prototype, "confirmingClear", void 0);
|
|
@@ -5663,90 +3974,12 @@ __decorate([
|
|
|
5663
3974
|
__decorate([
|
|
5664
3975
|
volatile
|
|
5665
3976
|
], FoundationAiAssistant.prototype, "persistenceActive", null);
|
|
5666
|
-
__decorate([
|
|
5667
|
-
volatile
|
|
5668
|
-
], FoundationAiAssistant.prototype, "contextBudget", null);
|
|
5669
3977
|
__decorate([
|
|
5670
3978
|
volatile
|
|
5671
3979
|
], FoundationAiAssistant.prototype, "contextReserve", null);
|
|
5672
|
-
__decorate([
|
|
5673
|
-
volatile
|
|
5674
|
-
], FoundationAiAssistant.prototype, "compactionPlan", null);
|
|
5675
|
-
__decorate([
|
|
5676
|
-
volatile
|
|
5677
|
-
], FoundationAiAssistant.prototype, "compactHint", null);
|
|
5678
|
-
__decorate([
|
|
5679
|
-
volatile
|
|
5680
|
-
], FoundationAiAssistant.prototype, "contextBlockCause", null);
|
|
5681
|
-
__decorate([
|
|
5682
|
-
volatile
|
|
5683
|
-
], FoundationAiAssistant.prototype, "contextGate", null);
|
|
5684
|
-
__decorate([
|
|
5685
|
-
volatile
|
|
5686
|
-
], FoundationAiAssistant.prototype, "contextGateReason", null);
|
|
5687
|
-
__decorate([
|
|
5688
|
-
volatile
|
|
5689
|
-
], FoundationAiAssistant.prototype, "contextGateLabel", null);
|
|
5690
|
-
__decorate([
|
|
5691
|
-
volatile
|
|
5692
|
-
], FoundationAiAssistant.prototype, "composerNoticeVisible", null);
|
|
5693
|
-
__decorate([
|
|
5694
|
-
volatile
|
|
5695
|
-
], FoundationAiAssistant.prototype, "composerNoticeText", null);
|
|
5696
|
-
__decorate([
|
|
5697
|
-
volatile
|
|
5698
|
-
], FoundationAiAssistant.prototype, "composerNoticeSrOnly", null);
|
|
5699
|
-
__decorate([
|
|
5700
|
-
volatile
|
|
5701
|
-
], FoundationAiAssistant.prototype, "compactable", null);
|
|
5702
3980
|
__decorate([
|
|
5703
3981
|
volatile
|
|
5704
3982
|
], FoundationAiAssistant.prototype, "agentPickerEnabled", null);
|
|
5705
|
-
__decorate([
|
|
5706
|
-
volatile
|
|
5707
|
-
], FoundationAiAssistant.prototype, "settingsModelSectionVisible", null);
|
|
5708
|
-
__decorate([
|
|
5709
|
-
volatile
|
|
5710
|
-
], FoundationAiAssistant.prototype, "settingsContextUsageVisible", null);
|
|
5711
|
-
__decorate([
|
|
5712
|
-
volatile
|
|
5713
|
-
], FoundationAiAssistant.prototype, "settingsBudgetUsageVisible", null);
|
|
5714
|
-
__decorate([
|
|
5715
|
-
volatile
|
|
5716
|
-
], FoundationAiAssistant.prototype, "settingsBudgetRows", null);
|
|
5717
|
-
__decorate([
|
|
5718
|
-
volatile
|
|
5719
|
-
], FoundationAiAssistant.prototype, "settingsAppSectionVisible", null);
|
|
5720
|
-
__decorate([
|
|
5721
|
-
volatile
|
|
5722
|
-
], FoundationAiAssistant.prototype, "visibleAppSettingsToggles", null);
|
|
5723
|
-
__decorate([
|
|
5724
|
-
volatile
|
|
5725
|
-
], FoundationAiAssistant.prototype, "appSettingsTogglesHeading", null);
|
|
5726
|
-
__decorate([
|
|
5727
|
-
volatile
|
|
5728
|
-
], FoundationAiAssistant.prototype, "settingsCostCurrentSectionVisible", null);
|
|
5729
|
-
__decorate([
|
|
5730
|
-
volatile
|
|
5731
|
-
], FoundationAiAssistant.prototype, "settingsCostHistorySectionVisible", null);
|
|
5732
|
-
__decorate([
|
|
5733
|
-
volatile
|
|
5734
|
-
], FoundationAiAssistant.prototype, "settingsCostHistoryHeadingVisible", null);
|
|
5735
|
-
__decorate([
|
|
5736
|
-
volatile
|
|
5737
|
-
], FoundationAiAssistant.prototype, "settingsCostSummaryVisible", null);
|
|
5738
|
-
__decorate([
|
|
5739
|
-
volatile
|
|
5740
|
-
], FoundationAiAssistant.prototype, "settingsCostLifetimeVisible", null);
|
|
5741
|
-
__decorate([
|
|
5742
|
-
volatile
|
|
5743
|
-
], FoundationAiAssistant.prototype, "settingsCostTabVisible", null);
|
|
5744
|
-
__decorate([
|
|
5745
|
-
volatile
|
|
5746
|
-
], FoundationAiAssistant.prototype, "settingsSessionTokensVisible", null);
|
|
5747
|
-
__decorate([
|
|
5748
|
-
volatile
|
|
5749
|
-
], FoundationAiAssistant.prototype, "settingsCogVisible", null);
|
|
5750
3983
|
__decorate([
|
|
5751
3984
|
volatile
|
|
5752
3985
|
], FoundationAiAssistant.prototype, "pinnedAgentHint", null);
|