@genesislcap/ai-assistant 15.33.1 → 15.34.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 +1021 -6
- package/dist/ai-assistant.d.ts +209 -5
- package/dist/chat-driver.cjs +90 -0
- package/dist/chat-driver.cjs.map +3 -3
- package/dist/chat-driver.mjs +86 -0
- package/dist/chat-driver.mjs.map +3 -3
- package/dist/custom-elements.json +329 -3
- package/dist/dts/chat-driver-node.d.ts +2 -2
- package/dist/dts/chat-driver-node.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
- package/dist/dts/index.d.ts +2 -0
- package/dist/dts/index.d.ts.map +1 -1
- package/dist/dts/main/budget-controller.d.ts +38 -6
- package/dist/dts/main/budget-controller.d.ts.map +1 -1
- package/dist/dts/main/main.d.ts +22 -4
- 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/provider/tiered-provider-switcher.d.ts +123 -0
- package/dist/dts/provider/tiered-provider-switcher.d.ts.map +1 -0
- package/dist/dts/state/ai-assistant-slice.d.ts +34 -0
- package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
- package/dist/dts/state/session-store.d.ts.map +1 -1
- package/dist/esm/chat-driver-node.js +11 -1
- package/dist/esm/components/settings-modal/settings-modal.styles.js +75 -0
- package/dist/esm/components/settings-modal/settings-modal.template.js +56 -4
- package/dist/esm/index.js +1 -0
- package/dist/esm/main/budget-controller.js +178 -6
- package/dist/esm/main/main.js +20 -0
- package/dist/esm/provider/tiered-provider-switcher.js +142 -0
- package/dist/esm/state/ai-assistant-slice.js +17 -3
- package/docs/styling.md +24 -0
- package/package.json +18 -17
- package/sandbox/sandbox.ts +52 -0
- package/scripts/smoke-chat-driver-node.mjs +86 -0
|
@@ -105,6 +105,165 @@ export function budgetPercentOf(figures) {
|
|
|
105
105
|
return 100;
|
|
106
106
|
return Math.round((figures.spentUsd / figures.budgetUsd) * 100);
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* How far a breakdown may add up to MORE than the spend and still count as a breakdown of it
|
|
110
|
+
* (NEW-15): half a cent per segment, plus a margin for float error.
|
|
111
|
+
*
|
|
112
|
+
* Money is shown to the cent, and the obvious host keeps its total at full precision while
|
|
113
|
+
* reporting each source rounded to the cent. Each rounded part can then be up to half a cent
|
|
114
|
+
* high, so a two-part breakdown of a $6.386 total can honestly read $4.79 + $1.60 = $6.39. A
|
|
115
|
+
* float-scale slack rejected that as "a breakdown of some other total" while the row printed
|
|
116
|
+
* $6.39 beside it; whether a host got a stacked bar at all came down to which way its rounding
|
|
117
|
+
* went. Scaled by the segment count because the rounding error accumulates per part.
|
|
118
|
+
*/
|
|
119
|
+
const SEGMENT_HALF_CENT_USD = 0.005;
|
|
120
|
+
const SEGMENT_FLOAT_MARGIN_USD = 1e-9;
|
|
121
|
+
/**
|
|
122
|
+
* The narrowest remainder worth drawing, as a percentage of the track: about 2-3px at the
|
|
123
|
+
* settings modal's widths.
|
|
124
|
+
*
|
|
125
|
+
* Whether a remainder "exists" is a drawing decision, so it is made on what can be seen, not
|
|
126
|
+
* on dollars. Adjacent pieces are separated by a 1px hairline that comes out of each piece's
|
|
127
|
+
* own width, so anything narrower than a couple of pixels renders as nothing but that hairline.
|
|
128
|
+
* A dollar threshold gets this wrong at every scale: $0.004 on a $6 bar and $0.01 on a $1000 bar
|
|
129
|
+
* are both invisible, yet either one would pull the whole bar a tone paler (the named segments
|
|
130
|
+
* give up full strength to a remainder) because of a piece nobody can see.
|
|
131
|
+
*/
|
|
132
|
+
const REMAINDER_MIN_VISIBLE_PERCENT = 0.5;
|
|
133
|
+
/**
|
|
134
|
+
* The id the meter gives unattributed spend. Reserved: a host segment may not use it, so the
|
|
135
|
+
* remainder can never be mistaken for, or collide with, a source the host named.
|
|
136
|
+
*/
|
|
137
|
+
const REMAINDER_SEGMENT_ID = '__remainder__';
|
|
138
|
+
/**
|
|
139
|
+
* The segments of a host's figures, if they hold up — `undefined` when there are none, or
|
|
140
|
+
* when they are not a breakdown of the spend beside them.
|
|
141
|
+
*
|
|
142
|
+
* Dropped rather than rejected: the dollars are still true, so the meter falls back to the
|
|
143
|
+
* single bar rather than losing the vendor's row over a bad breakdown. Logged at debug, not
|
|
144
|
+
* warn, because the figures the user sees remain correct — this is a host-side wiring
|
|
145
|
+
* detail, and the meter is re-fed after every turn.
|
|
146
|
+
*
|
|
147
|
+
* Exported for the unit test that pins the rules; not element API.
|
|
148
|
+
*
|
|
149
|
+
* @internal
|
|
150
|
+
*/
|
|
151
|
+
export function validSegments(figures) {
|
|
152
|
+
const { segments } = figures;
|
|
153
|
+
if (segments == null)
|
|
154
|
+
return undefined;
|
|
155
|
+
if (!Array.isArray(segments) || segments.length === 0) {
|
|
156
|
+
logger.debug('setVendorBudget: segments is not a non-empty array — drawing the single bar.');
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
const malformed = segments.find((s) => !s ||
|
|
160
|
+
typeof s.id !== 'string' ||
|
|
161
|
+
!s.id ||
|
|
162
|
+
typeof s.label !== 'string' ||
|
|
163
|
+
typeof s.spentUsd !== 'number' ||
|
|
164
|
+
!Number.isFinite(s.spentUsd) ||
|
|
165
|
+
s.spentUsd < 0);
|
|
166
|
+
if (malformed !== undefined) {
|
|
167
|
+
logger.debug('setVendorBudget: a segment needs an id, a label and a finite spentUsd >= 0 ' +
|
|
168
|
+
`(got ${JSON.stringify(malformed)}) — drawing the single bar.`);
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
// Ids identify sources, so two sources cannot share one, and none may take the remainder's.
|
|
172
|
+
const ids = segments.map((s) => s.id);
|
|
173
|
+
if (new Set(ids).size !== ids.length || ids.includes(REMAINDER_SEGMENT_ID)) {
|
|
174
|
+
logger.debug(`setVendorBudget: segment ids must be unique and not "${REMAINDER_SEGMENT_ID}" ` +
|
|
175
|
+
`(got ${JSON.stringify(ids)}) — drawing the single bar.`);
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
const sum = segments.reduce((total, s) => total + s.spentUsd, 0);
|
|
179
|
+
const slackUsd = SEGMENT_HALF_CENT_USD * segments.length + SEGMENT_FLOAT_MARGIN_USD;
|
|
180
|
+
if (sum > figures.spentUsd + slackUsd) {
|
|
181
|
+
// A breakdown that adds up to more than the total is a breakdown of some OTHER total,
|
|
182
|
+
// and drawing it would put a bar on screen that disagrees with the text beside it.
|
|
183
|
+
logger.debug(`setVendorBudget: segments total ${sum}, which is more than spentUsd ${figures.spentUsd} ` +
|
|
184
|
+
'— drawing the single bar.');
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
return { segments: segments.map((s) => ({ id: s.id, label: s.label, spentUsd: s.spentUsd })) };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Tone (alpha) per segment, applied to the design system's accent so a stacked bar reads as
|
|
191
|
+
* one measure split into parts rather than a chart of unrelated colours — and so it needs no
|
|
192
|
+
* palette of its own, in a component that is themed by whatever design system hosts it.
|
|
193
|
+
*
|
|
194
|
+
* Positional, and paired with a legend, because tone alone is not a label.
|
|
195
|
+
*
|
|
196
|
+
* Exactly ONE piece is drawn at full strength, and which one depends on the breakdown:
|
|
197
|
+
*
|
|
198
|
+
* - With an unattributed remainder, it goes to the remainder, the plain spend colour, and the
|
|
199
|
+
* named segments start one step down. Otherwise the first segment and the remainder would be
|
|
200
|
+
* indistinguishable, its legend swatch would appear to describe both, and a half-attributed
|
|
201
|
+
* bar would read as wholly that one source.
|
|
202
|
+
* - With the whole spend attributed there is no remainder to reserve it for, so it goes to the
|
|
203
|
+
* first segment. Without that, a fully attributed bar — the normal case for a host that splits
|
|
204
|
+
* all of its spend — would read paler than the same spend drawn as a plain bar.
|
|
205
|
+
*/
|
|
206
|
+
const SEGMENT_TONE_RATIO = 0.62;
|
|
207
|
+
const SEGMENT_TONE_FLOOR = 0.18;
|
|
208
|
+
const FULL_TONE = 1;
|
|
209
|
+
/**
|
|
210
|
+
* The stacked bar's parts, pre-computed like the row text around them.
|
|
211
|
+
*
|
|
212
|
+
* Widths are shares of the TRACK, scaled from the unrounded, clamped spend ratio rather than
|
|
213
|
+
* the whole-percent `barValue` the plain bar uses. Scaling from the rounded figure left a $4
|
|
214
|
+
* spend on a $1000 cap with zero-width pieces under a legend reading $3.00 and $1.00, and drifted
|
|
215
|
+
* the bar up to half a point from its own text everywhere else. When spend overshoots the cap the
|
|
216
|
+
* ratio clamps at 100, so the pieces still add up to exactly the track.
|
|
217
|
+
*
|
|
218
|
+
* A remainder — spend the host did not attribute — takes the leftover width unlabelled, but only
|
|
219
|
+
* when it is wide enough to see (see {@link REMAINDER_MIN_VISIBLE_PERCENT}). When it is not, the
|
|
220
|
+
* named segments are stretched to fill the whole bar instead, so the bar's length always matches
|
|
221
|
+
* the figure beside it.
|
|
222
|
+
*
|
|
223
|
+
* Exported for the unit test that pins the arithmetic; not element API.
|
|
224
|
+
*
|
|
225
|
+
* @internal
|
|
226
|
+
*/
|
|
227
|
+
export function budgetSegmentRows(figures) {
|
|
228
|
+
const segments = figures.segments;
|
|
229
|
+
if (!(segments === null || segments === void 0 ? void 0 : segments.length))
|
|
230
|
+
return undefined;
|
|
231
|
+
const { budgetUsd, spentUsd } = figures;
|
|
232
|
+
// Same rule as the plain bar: a $0 cap has no headroom by definition, so it reads full.
|
|
233
|
+
const ratio = budgetUsd <= 0 ? 100 : Math.min(100, (spentUsd / budgetUsd) * 100);
|
|
234
|
+
const attributedUsd = segments.reduce((total, s) => total + s.spentUsd, 0);
|
|
235
|
+
// With nothing spent there is nothing to attribute, but a $0 cap still draws a full bar — so
|
|
236
|
+
// the whole of it is unattributed. Otherwise adding a breakdown of nothing would empty a bar
|
|
237
|
+
// that the same figures draw full without one.
|
|
238
|
+
const remainderWidth = spentUsd > 0 ? (Math.max(0, spentUsd - attributedUsd) / spentUsd) * ratio : ratio;
|
|
239
|
+
// Decided ONCE and used for both the tones and whether the remainder is drawn, so the two
|
|
240
|
+
// can never disagree about whether there is a remainder.
|
|
241
|
+
const hasRemainder = remainderWidth >= REMAINDER_MIN_VISIBLE_PERCENT;
|
|
242
|
+
const namedWidth = hasRemainder ? ratio - remainderWidth : ratio;
|
|
243
|
+
// Every segment is 0 wide when nothing has been attributed, which is the honest drawing of a
|
|
244
|
+
// breakdown of $0.00 — the legend still says which sources are being watched.
|
|
245
|
+
const perUsd = attributedUsd > 0 ? namedWidth / attributedUsd : 0;
|
|
246
|
+
const firstTone = hasRemainder ? SEGMENT_TONE_RATIO : FULL_TONE;
|
|
247
|
+
const rows = segments.map((segment, i) => ({
|
|
248
|
+
id: segment.id,
|
|
249
|
+
label: segment.label,
|
|
250
|
+
figures: formatUsd(segment.spentUsd),
|
|
251
|
+
widthPercent: segment.spentUsd * perUsd,
|
|
252
|
+
tone: Math.max(SEGMENT_TONE_FLOOR, firstTone * Math.pow(SEGMENT_TONE_RATIO, i)),
|
|
253
|
+
legend: true,
|
|
254
|
+
}));
|
|
255
|
+
if (hasRemainder) {
|
|
256
|
+
rows.push({
|
|
257
|
+
id: REMAINDER_SEGMENT_ID,
|
|
258
|
+
label: '',
|
|
259
|
+
figures: '',
|
|
260
|
+
widthPercent: remainderWidth,
|
|
261
|
+
tone: FULL_TONE,
|
|
262
|
+
legend: false,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return rows;
|
|
266
|
+
}
|
|
108
267
|
/**
|
|
109
268
|
* The AI-spend budget: which vendors are walled, whether that locks the composer,
|
|
110
269
|
* the banner copy that explains it, and the settings-modal meter (GENC-1464).
|
|
@@ -302,7 +461,9 @@ export class BudgetController {
|
|
|
302
461
|
vendor: vendor,
|
|
303
462
|
// A fresh literal, never the caller's object — see the reducer, which
|
|
304
463
|
// must not adopt (and potentially freeze) state the host still owns.
|
|
305
|
-
figures: figures == null
|
|
464
|
+
figures: figures == null
|
|
465
|
+
? null
|
|
466
|
+
: Object.assign({ budgetUsd: figures.budgetUsd, spentUsd: figures.spentUsd }, validSegments(figures)),
|
|
306
467
|
});
|
|
307
468
|
if (figures != null)
|
|
308
469
|
this.meterLastFedAt = Date.now();
|
|
@@ -495,7 +656,7 @@ export class BudgetController {
|
|
|
495
656
|
* when the budget payload names none.
|
|
496
657
|
*/
|
|
497
658
|
latchFrom(reason, ref = this.deps.sessionRef(), budget, vendorHint) {
|
|
498
|
-
var _a, _b;
|
|
659
|
+
var _a, _b, _c, _d;
|
|
499
660
|
if (reason !== 'budget-exhausted')
|
|
500
661
|
return;
|
|
501
662
|
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;
|
|
@@ -512,9 +673,16 @@ export class BudgetController {
|
|
|
512
673
|
// so a teardown race cannot land it in a new session's store.
|
|
513
674
|
let metered = false;
|
|
514
675
|
if (vendor && vendor !== 'none' && (budget === null || budget === void 0 ? void 0 : budget.budgetUsd) != null && (budget === null || budget === void 0 ? void 0 : budget.spentUsd) != null) {
|
|
676
|
+
const fresh = { budgetUsd: budget.budgetUsd, spentUsd: budget.spentUsd };
|
|
677
|
+
// A 402 reports totals, never the host's breakdown — so writing these figures alone
|
|
678
|
+
// would collapse a stacked bar to a single one at the exact moment the user is looking
|
|
679
|
+
// at it, until the next host feed. The stored segments are carried over while they
|
|
680
|
+
// still hold against the new spend (they normally do: spend only grows), and the host's
|
|
681
|
+
// own re-feed replaces them with fresher ones a moment later.
|
|
682
|
+
const stored = (_d = (_c = ref === null || ref === void 0 ? void 0 : ref.store.aiAssistant.vendorBudgets) === null || _c === void 0 ? void 0 : _c[vendor]) === null || _d === void 0 ? void 0 : _d.segments;
|
|
515
683
|
ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBudget({
|
|
516
684
|
vendor,
|
|
517
|
-
figures: {
|
|
685
|
+
figures: Object.assign(Object.assign({}, fresh), validSegments(Object.assign(Object.assign({}, fresh), { segments: stored }))),
|
|
518
686
|
});
|
|
519
687
|
this.meterLastFedAt = Date.now();
|
|
520
688
|
metered = true;
|
|
@@ -698,13 +866,17 @@ export class BudgetController {
|
|
|
698
866
|
if (!figures)
|
|
699
867
|
continue;
|
|
700
868
|
const percent = budgetPercentOf(figures);
|
|
869
|
+
// The same clamp the context indicator applies to its bar. No lower
|
|
870
|
+
// clamp needed: the boundary refuses negative figures.
|
|
871
|
+
const barValue = Math.min(100, percent);
|
|
701
872
|
rows.push({
|
|
702
873
|
vendor,
|
|
703
874
|
label: vendorDisplayName(vendor),
|
|
704
875
|
figures: `${formatUsd(figures.spentUsd)} / ${formatUsd(figures.budgetUsd)} (${percent}%)`,
|
|
705
|
-
|
|
706
|
-
//
|
|
707
|
-
|
|
876
|
+
barValue,
|
|
877
|
+
// Absent unless the host broke the spend down, which is what keeps the unsegmented
|
|
878
|
+
// row on the design system's own progress element rather than a lookalike.
|
|
879
|
+
segments: budgetSegmentRows(figures),
|
|
708
880
|
});
|
|
709
881
|
}
|
|
710
882
|
return rows;
|
package/dist/esm/main/main.js
CHANGED
|
@@ -1018,6 +1018,26 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
1018
1018
|
* }
|
|
1019
1019
|
* ```
|
|
1020
1020
|
*
|
|
1021
|
+
* A host that meters its spend by source can pass an optional `segments` breakdown, and
|
|
1022
|
+
* the vendor's bar is drawn stacked with a legend naming each one (NEW-15):
|
|
1023
|
+
*
|
|
1024
|
+
* ```ts
|
|
1025
|
+
* assistantEl.setVendorBudget('anthropic', {
|
|
1026
|
+
* budgetUsd: 50,
|
|
1027
|
+
* spentUsd: 12.5,
|
|
1028
|
+
* segments: [
|
|
1029
|
+
* { id: 'app', label: 'App', spentUsd: 9 },
|
|
1030
|
+
* { id: 'batch', label: 'Batch jobs', spentUsd: 3.5 },
|
|
1031
|
+
* ],
|
|
1032
|
+
* });
|
|
1033
|
+
* ```
|
|
1034
|
+
*
|
|
1035
|
+
* The labels are yours — the assistant has no vocabulary for what a host spends money on.
|
|
1036
|
+
* Segments may add up to less than `spentUsd` (the remainder draws unlabelled) but never
|
|
1037
|
+
* to more; a breakdown that does, or that is otherwise malformed, is dropped with a debug
|
|
1038
|
+
* log and the single bar renders. Omit `segments` entirely and nothing about the meter
|
|
1039
|
+
* changes.
|
|
1040
|
+
*
|
|
1021
1041
|
* Re-run it on mount and after each turn. Scope and lifetime follow the rest
|
|
1022
1042
|
* of the session slice (per `stateKey`, survives pop-in/out and "Clear" /
|
|
1023
1043
|
* "New chat") — except that the figures are NOT persisted to the session
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { AI_TIER_VENDORS, buildTierProviders, registerAIProviders, } from '@genesislcap/foundation-ai';
|
|
2
|
+
import { Registration } from '@genesislcap/web-core';
|
|
3
|
+
import { AIProviderSwitcher } from './ai-provider-switcher';
|
|
4
|
+
/**
|
|
5
|
+
* Which cloud vendor backs the tier-shaped provider registry, plus the means to move it at
|
|
6
|
+
* runtime (GENC-1610).
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* Upstreamed from Genesis Create, which has run this shape in production: a registry keyed by
|
|
10
|
+
* tier (`low` / `high` / `reasoning`), a vendor switch that rebuilds it in place, and the other
|
|
11
|
+
* vendor kept alive as a standby entry. Agents keep resolving tier names, so a switch never
|
|
12
|
+
* touches agent config.
|
|
13
|
+
*
|
|
14
|
+
* Register it on the {@link (AIProviderSwitcher:interface)} DI token — via
|
|
15
|
+
* {@link registerTieredAIProviders} — and the settings modal's vendor UI (or an app's own
|
|
16
|
+
* control) drives it.
|
|
17
|
+
*
|
|
18
|
+
* @beta
|
|
19
|
+
*/
|
|
20
|
+
export class TieredAIProviderSwitcher {
|
|
21
|
+
constructor(options) {
|
|
22
|
+
var _a, _b;
|
|
23
|
+
this.listeners = new Set();
|
|
24
|
+
// The type already says so; this is for untyped JS hosts, which would otherwise find out
|
|
25
|
+
// one turn at a time, on the vendor they did not boot on.
|
|
26
|
+
if (typeof options.serverEndpoint !== 'function') {
|
|
27
|
+
throw new Error('TieredAIProviderSwitcher: serverEndpoint must be a function of the vendor, e.g. ' +
|
|
28
|
+
'(vendor) => `/gwf/ai-service/${vendor}/chat`. A plain string would route every ' +
|
|
29
|
+
"vendor's requests to one vendor's proxy.");
|
|
30
|
+
}
|
|
31
|
+
this.options = options;
|
|
32
|
+
this.vendors = (_a = options.vendors) !== null && _a !== void 0 ? _a : AI_TIER_VENDORS;
|
|
33
|
+
// Same reasoning as the endpoint check: fail here, not a turn at a time later. Booting on a
|
|
34
|
+
// vendor outside `vendors` serves one the app has just declared it cannot reach, and is a
|
|
35
|
+
// one-way door — `switchTo` refuses to go back to it once anything switches away. An empty
|
|
36
|
+
// list yields a switcher that can never switch at all. The realistic way in is an
|
|
37
|
+
// `initialVendor` read from the environment while `vendors` is narrowed per environment.
|
|
38
|
+
if (this.vendors.length === 0) {
|
|
39
|
+
throw new Error('TieredAIProviderSwitcher: `vendors` is empty — there is nothing to switch.');
|
|
40
|
+
}
|
|
41
|
+
if (!this.vendors.includes(options.initialVendor)) {
|
|
42
|
+
throw new Error(`TieredAIProviderSwitcher: initialVendor '${options.initialVendor}' is not one of ` +
|
|
43
|
+
`vendors [${this.vendors.join(', ')}]. The app would boot on a vendor it cannot ` +
|
|
44
|
+
'switch back to.');
|
|
45
|
+
}
|
|
46
|
+
this.defaultTier = (_b = options.defaultTier) !== null && _b !== void 0 ? _b : 'high';
|
|
47
|
+
this._vendor = options.initialVendor;
|
|
48
|
+
this.registry = registerAIProviders(options.container, this.build(this._vendor), {
|
|
49
|
+
default: this.defaultTier,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
get vendor() {
|
|
53
|
+
return this._vendor;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A user-driven switch: move the registry, then tell the app so it can persist the choice.
|
|
57
|
+
* An unknown vendor is ignored.
|
|
58
|
+
*/
|
|
59
|
+
switchTo(vendor) {
|
|
60
|
+
var _a, _b;
|
|
61
|
+
if (!this.isKnownVendor(vendor))
|
|
62
|
+
return;
|
|
63
|
+
this.apply(vendor);
|
|
64
|
+
(_b = (_a = this.options).onSwitch) === null || _b === void 0 ? void 0 : _b.call(_a, vendor);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Adopt a vendor the app read back from its own store — the same registry move as
|
|
68
|
+
* {@link TieredAIProviderSwitcher.switchTo}, without the `onSwitch` callback, because this
|
|
69
|
+
* value came FROM the store and writing it back would be a pointless round-trip on load.
|
|
70
|
+
*
|
|
71
|
+
* Exists because registration has to stay synchronous: an element that connects before the
|
|
72
|
+
* token is registered caches the platform's no-op fallback, so an app cannot await its
|
|
73
|
+
* stored preference before constructing this. It boots on `initialVendor` and adopts the
|
|
74
|
+
* stored one a moment later, through a registry that is mutable and held by reference.
|
|
75
|
+
*/
|
|
76
|
+
adoptPersistedVendor(vendor) {
|
|
77
|
+
if (!this.isKnownVendor(vendor))
|
|
78
|
+
return;
|
|
79
|
+
this.apply(vendor);
|
|
80
|
+
}
|
|
81
|
+
subscribe(listener) {
|
|
82
|
+
const wrapped = (vendor) => listener(vendor);
|
|
83
|
+
this.listeners.add(wrapped);
|
|
84
|
+
return () => {
|
|
85
|
+
this.listeners.delete(wrapped);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** The registry move both entry points share, and nothing else. */
|
|
89
|
+
apply(vendor) {
|
|
90
|
+
if (vendor === this._vendor)
|
|
91
|
+
return;
|
|
92
|
+
this.registry.update(this.build(vendor), { default: this.defaultTier });
|
|
93
|
+
this._vendor = vendor;
|
|
94
|
+
for (const listener of Array.from(this.listeners))
|
|
95
|
+
listener(vendor);
|
|
96
|
+
}
|
|
97
|
+
build(vendor) {
|
|
98
|
+
const { serverEndpoint, tiers } = this.options;
|
|
99
|
+
const providers = Object.assign({}, buildTierProviders({ vendor, serverEndpoint, tiers }));
|
|
100
|
+
// Every OTHER vendor rides along as a standby entry, so all of them report a status at
|
|
101
|
+
// once. The assistant's `reachableVendors` — and with it the whole partial-wall UX — is
|
|
102
|
+
// fed by registry statuses: with one vendor registered at a time, a budget wall always
|
|
103
|
+
// locks the composer outright and the banner can never honestly offer the switch that
|
|
104
|
+
// the settings modal is about to perform.
|
|
105
|
+
//
|
|
106
|
+
// Agents resolve tier names, so a standby never serves a turn until it becomes the
|
|
107
|
+
// active vendor, and as a status source it makes no network calls. Which tier backs it
|
|
108
|
+
// is arbitrary — `low` simply builds the cheapest object; nothing reads the difference.
|
|
109
|
+
for (const other of this.vendors) {
|
|
110
|
+
if (other === vendor)
|
|
111
|
+
continue;
|
|
112
|
+
providers[`standby-${other}`] = buildTierProviders({
|
|
113
|
+
vendor: other,
|
|
114
|
+
serverEndpoint,
|
|
115
|
+
tiers,
|
|
116
|
+
}).low;
|
|
117
|
+
}
|
|
118
|
+
return providers;
|
|
119
|
+
}
|
|
120
|
+
isKnownVendor(vendor) {
|
|
121
|
+
return this.vendors.includes(vendor);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Build a {@link TieredAIProviderSwitcher} — which registers the tier-shaped provider
|
|
126
|
+
* registry — and put it on the {@link (AIProviderSwitcher:variable)} token. Call once at app
|
|
127
|
+
* bootstrap.
|
|
128
|
+
*
|
|
129
|
+
* @remarks
|
|
130
|
+
* Everything here is synchronous and has to stay that way. `foundation-ai-assistant` resolves
|
|
131
|
+
* the switcher and registry tokens as it connects, and an element that connects first caches
|
|
132
|
+
* the no-op fallback — so an `await` above this call costs the whole page load its providers.
|
|
133
|
+
* Load a stored vendor preference AFTER it, through
|
|
134
|
+
* {@link TieredAIProviderSwitcher.adoptPersistedVendor}.
|
|
135
|
+
*
|
|
136
|
+
* @beta
|
|
137
|
+
*/
|
|
138
|
+
export function registerTieredAIProviders(container, options) {
|
|
139
|
+
const switcher = new TieredAIProviderSwitcher(Object.assign(Object.assign({}, options), { container }));
|
|
140
|
+
container.register(Registration.instance(AIProviderSwitcher, switcher));
|
|
141
|
+
return switcher;
|
|
142
|
+
}
|
|
@@ -44,6 +44,18 @@ export function createDefaultSessionState() {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
export const defaultSessionState = createDefaultSessionState();
|
|
47
|
+
/**
|
|
48
|
+
* Whether two segment lists say the same thing — the segment half of `setVendorBudget`'s
|
|
49
|
+
* by-value idempotence. Hosts re-feed the meter after every turn, so a fresh array holding
|
|
50
|
+
* identical figures must not publish a new store reference and re-render an unchanged bar.
|
|
51
|
+
*/
|
|
52
|
+
function sameSegments(a, b) {
|
|
53
|
+
if (a === b)
|
|
54
|
+
return true;
|
|
55
|
+
if (!a || !b || a.length !== b.length)
|
|
56
|
+
return false;
|
|
57
|
+
return a.every((segment, i) => segment.id === b[i].id && segment.label === b[i].label && segment.spentUsd === b[i].spentUsd);
|
|
58
|
+
}
|
|
47
59
|
export const aiAssistantSlice = createSlice({
|
|
48
60
|
name: 'aiAssistant',
|
|
49
61
|
initialState: defaultSessionState,
|
|
@@ -231,13 +243,15 @@ export const aiAssistantSlice = createSlice({
|
|
|
231
243
|
const existing = state.vendorBudgets[vendor];
|
|
232
244
|
if (existing &&
|
|
233
245
|
existing.budgetUsd === figures.budgetUsd &&
|
|
234
|
-
existing.spentUsd === figures.spentUsd
|
|
246
|
+
existing.spentUsd === figures.spentUsd &&
|
|
247
|
+
sameSegments(existing.segments, figures.segments)) {
|
|
235
248
|
return;
|
|
236
249
|
}
|
|
237
250
|
// A fresh object rather than the caller's: the store may freeze what it
|
|
238
251
|
// holds, and adopting the host's object would freeze state the host still
|
|
239
|
-
// owns (and let later host mutations bypass the store).
|
|
240
|
-
|
|
252
|
+
// owns (and let later host mutations bypass the store). Same for the
|
|
253
|
+
// segment array and each segment in it.
|
|
254
|
+
state.vendorBudgets[vendor] = Object.assign({ budgetUsd: figures.budgetUsd, spentUsd: figures.spentUsd }, (figures.segments ? { segments: figures.segments.map((s) => (Object.assign({}, s))) } : {}));
|
|
241
255
|
},
|
|
242
256
|
setInputValue(state, action) {
|
|
243
257
|
state.inputValue = action.payload;
|
package/docs/styling.md
CHANGED
|
@@ -134,6 +134,22 @@ The per-vendor AI budget meter (GENC-1464) renders in the settings modal's
|
|
|
134
134
|
| --- | --- |
|
|
135
135
|
| `budget-usage` | The meter's container (all vendor rows) |
|
|
136
136
|
| `budget-progress` | One vendor's budget bar (repeats per rendered row) |
|
|
137
|
+
| `budget-segment` | Every slice of a stacked bar, when the host fed `segments` |
|
|
138
|
+
| `budget-segment-<n>` | The slice for `segments[n]` (0-based, the host's own order) |
|
|
139
|
+
| `budget-segment-remainder` | The unattributed remainder, when one is drawn |
|
|
140
|
+
| `budget-swatch` / `budget-swatch-<n>` | The legend swatch(es), numbered to match their slice |
|
|
141
|
+
| `budget-legend` | The legend under a stacked bar |
|
|
142
|
+
| `budget-legend-item` | One legend entry (swatch, label, amount) |
|
|
143
|
+
|
|
144
|
+
`budget-progress` is the design system's progress element for a vendor fed plain
|
|
145
|
+
figures, and the stacked track for a vendor fed `segments` — so a rule that sets
|
|
146
|
+
the bar's height applies to both. The other parts exist only on a segmented row.
|
|
147
|
+
The unattributed remainder is a `budget-segment` with no legend entry, so it has
|
|
148
|
+
no swatch.
|
|
149
|
+
|
|
150
|
+
To style one slice, use its numbered part. Structural pseudo-classes such as
|
|
151
|
+
`:nth-child()` never match after `::part()`, so
|
|
152
|
+
`::part(budget-segment):nth-child(2)` silently does nothing.
|
|
137
153
|
|
|
138
154
|
```css
|
|
139
155
|
/* Tighten the meter's row spacing */
|
|
@@ -145,6 +161,14 @@ foundation-ai-assistant::part(budget-usage) {
|
|
|
145
161
|
foundation-ai-assistant::part(budget-progress) {
|
|
146
162
|
height: 6px;
|
|
147
163
|
}
|
|
164
|
+
|
|
165
|
+
/* Colour the second source's slice and its legend swatch (they default to
|
|
166
|
+
tones of the accent, so drop the tone as well) */
|
|
167
|
+
foundation-ai-assistant::part(budget-segment-1),
|
|
168
|
+
foundation-ai-assistant::part(budget-swatch-1) {
|
|
169
|
+
background: var(--warning-color);
|
|
170
|
+
opacity: 1;
|
|
171
|
+
}
|
|
148
172
|
```
|
|
149
173
|
|
|
150
174
|
### The conversation column (`messages-content`)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genesislcap/ai-assistant",
|
|
3
3
|
"description": "Genesis AI Assistant micro-frontend",
|
|
4
|
-
"version": "15.
|
|
4
|
+
"version": "15.34.0",
|
|
5
5
|
"license": "SEE LICENSE IN license.txt",
|
|
6
6
|
"main": "dist/esm/index.js",
|
|
7
7
|
"types": "dist/ai-assistant.d.ts",
|
|
@@ -50,8 +50,9 @@
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
|
-
"build": "genx build -b ts && node ./scripts/build-chat-driver-node.mjs",
|
|
53
|
+
"build": "genx build -b ts && node ./scripts/build-chat-driver-node.mjs && npm run smoke:chat-driver",
|
|
54
54
|
"build:chat-driver": "node ./scripts/build-chat-driver-node.mjs",
|
|
55
|
+
"smoke:chat-driver": "node ./scripts/smoke-chat-driver-node.mjs",
|
|
55
56
|
"build:webpack": "genx build",
|
|
56
57
|
"build:webpack:stats": "genx analyze",
|
|
57
58
|
"circular": "npx -y madge --extensions ts --circular ./src",
|
|
@@ -74,26 +75,26 @@
|
|
|
74
75
|
}
|
|
75
76
|
},
|
|
76
77
|
"devDependencies": {
|
|
77
|
-
"@genesislcap/foundation-testing": "15.
|
|
78
|
-
"@genesislcap/genx": "15.
|
|
79
|
-
"@genesislcap/rollup-builder": "15.
|
|
80
|
-
"@genesislcap/ts-builder": "15.
|
|
81
|
-
"@genesislcap/uvu-playwright-builder": "15.
|
|
82
|
-
"@genesislcap/vite-builder": "15.
|
|
83
|
-
"@genesislcap/webpack-builder": "15.
|
|
78
|
+
"@genesislcap/foundation-testing": "15.34.0",
|
|
79
|
+
"@genesislcap/genx": "15.34.0",
|
|
80
|
+
"@genesislcap/rollup-builder": "15.34.0",
|
|
81
|
+
"@genesislcap/ts-builder": "15.34.0",
|
|
82
|
+
"@genesislcap/uvu-playwright-builder": "15.34.0",
|
|
83
|
+
"@genesislcap/vite-builder": "15.34.0",
|
|
84
|
+
"@genesislcap/webpack-builder": "15.34.0",
|
|
84
85
|
"@types/dompurify": "^3.0.5",
|
|
85
86
|
"@types/marked": "^5.0.2",
|
|
86
87
|
"esbuild": "0.25.12"
|
|
87
88
|
},
|
|
88
89
|
"dependencies": {
|
|
89
|
-
"@genesislcap/foundation-ai": "15.
|
|
90
|
-
"@genesislcap/foundation-logger": "15.
|
|
91
|
-
"@genesislcap/foundation-notifications": "15.
|
|
92
|
-
"@genesislcap/foundation-redux": "15.
|
|
93
|
-
"@genesislcap/foundation-ui": "15.
|
|
94
|
-
"@genesislcap/foundation-utils": "15.
|
|
95
|
-
"@genesislcap/rapid-design-system": "15.
|
|
96
|
-
"@genesislcap/web-core": "15.
|
|
90
|
+
"@genesislcap/foundation-ai": "15.34.0",
|
|
91
|
+
"@genesislcap/foundation-logger": "15.34.0",
|
|
92
|
+
"@genesislcap/foundation-notifications": "15.34.0",
|
|
93
|
+
"@genesislcap/foundation-redux": "15.34.0",
|
|
94
|
+
"@genesislcap/foundation-ui": "15.34.0",
|
|
95
|
+
"@genesislcap/foundation-utils": "15.34.0",
|
|
96
|
+
"@genesislcap/rapid-design-system": "15.34.0",
|
|
97
|
+
"@genesislcap/web-core": "15.34.0",
|
|
97
98
|
"dompurify": "^3.3.1",
|
|
98
99
|
"marked": "^17.0.3"
|
|
99
100
|
},
|
package/sandbox/sandbox.ts
CHANGED
|
@@ -146,6 +146,8 @@ interface SandboxState {
|
|
|
146
146
|
reserveTokens: number;
|
|
147
147
|
warnMultiplier: number;
|
|
148
148
|
flowInProgress: boolean;
|
|
149
|
+
// Budget meter (GENC-1464, NEW-15)
|
|
150
|
+
budgetMeter: 'off' | 'single' | 'segmented' | 'segmented-remainder';
|
|
149
151
|
// Simulated runtime
|
|
150
152
|
runtime: 'idle' | 'loading' | 'cancelling' | 'blocked' | 'compacting' | 'restoring';
|
|
151
153
|
transcript: TranscriptName;
|
|
@@ -192,6 +194,7 @@ const DEFAULT_STATE: SandboxState = {
|
|
|
192
194
|
reserveTokens: 8_000,
|
|
193
195
|
warnMultiplier: 2,
|
|
194
196
|
flowInProgress: false,
|
|
197
|
+
budgetMeter: 'off',
|
|
195
198
|
runtime: 'idle',
|
|
196
199
|
transcript: 'rich',
|
|
197
200
|
transcriptAge: 'fresh',
|
|
@@ -485,6 +488,35 @@ const applyContext = (s: SandboxState): void => {
|
|
|
485
488
|
assistant.flowOwnerAgentName = s.flowInProgress ? 'Reconciliation' : null;
|
|
486
489
|
};
|
|
487
490
|
|
|
491
|
+
/**
|
|
492
|
+
* Feeds the cog's per-vendor budget meter, including the optional per-source breakdown
|
|
493
|
+
* (NEW-15) — the one part of the meter that cannot be judged from a unit test, since what is
|
|
494
|
+
* being checked is whether two tones and a legend read as one measure at meter size.
|
|
495
|
+
*
|
|
496
|
+
* Anthropic carries the breakdown and Gemini never does, so both drawings are on screen at
|
|
497
|
+
* once: the point of the feature is that a host feeding plain figures sees no change.
|
|
498
|
+
*/
|
|
499
|
+
const applyBudgetMeter = (s: SandboxState): void => {
|
|
500
|
+
if (s.budgetMeter === 'off') {
|
|
501
|
+
assistant.setVendorBudget('anthropic', null);
|
|
502
|
+
assistant.setVendorBudget('gemini', null);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
assistant.setVendorBudget('gemini', { budgetUsd: 200, spentUsd: 30.5 });
|
|
506
|
+
const segments =
|
|
507
|
+
s.budgetMeter === 'single'
|
|
508
|
+
? undefined
|
|
509
|
+
: s.budgetMeter === 'segmented'
|
|
510
|
+
? [
|
|
511
|
+
{ id: 'app', label: 'App', spentUsd: 32 },
|
|
512
|
+
{ id: 'batch', label: 'Batch jobs', spentUsd: 13 },
|
|
513
|
+
]
|
|
514
|
+
: // Only part of the spend attributed: the rest draws unlabelled, which is the case
|
|
515
|
+
// worth looking at because nothing in the legend explains it.
|
|
516
|
+
[{ id: 'app', label: 'App', spentUsd: 27 }];
|
|
517
|
+
assistant.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45, segments });
|
|
518
|
+
};
|
|
519
|
+
|
|
488
520
|
const applyRuntime = (s: SandboxState): void => {
|
|
489
521
|
assistant.compacting = s.runtime === 'compacting';
|
|
490
522
|
assistant.restoring = s.runtime === 'restoring';
|
|
@@ -552,6 +584,7 @@ function apply(): void {
|
|
|
552
584
|
: {};
|
|
553
585
|
bindHistoryListener();
|
|
554
586
|
applyContext(state);
|
|
587
|
+
applyBudgetMeter(state);
|
|
555
588
|
applyRuntime(state);
|
|
556
589
|
applyGradientSend(state.gradientSend);
|
|
557
590
|
applyDivider(state.showDivider);
|
|
@@ -815,6 +848,25 @@ const GROUPS: ReadonlyArray<ControlGroup<SandboxState>> = [
|
|
|
815
848
|
},
|
|
816
849
|
],
|
|
817
850
|
},
|
|
851
|
+
{
|
|
852
|
+
title: 'Budget meter',
|
|
853
|
+
controls: [
|
|
854
|
+
{
|
|
855
|
+
kind: 'select',
|
|
856
|
+
key: 'budgetMeter',
|
|
857
|
+
label: 'Figures fed',
|
|
858
|
+
featured: true,
|
|
859
|
+
hint: 'Open the cog to see it. Gemini always gets plain figures, so the unsegmented bar is beside the segmented one.',
|
|
860
|
+
options: [
|
|
861
|
+
{ value: 'off', label: 'None (no meter)' },
|
|
862
|
+
{ value: 'single', label: 'Plain figures' },
|
|
863
|
+
{ value: 'segmented', label: 'Breakdown (fully attributed)' },
|
|
864
|
+
{ value: 'segmented-remainder', label: 'Breakdown + unattributed spend' },
|
|
865
|
+
],
|
|
866
|
+
},
|
|
867
|
+
{ kind: 'action', label: 'Open settings', run: () => assistant.openSettingsModal() },
|
|
868
|
+
],
|
|
869
|
+
},
|
|
818
870
|
{
|
|
819
871
|
title: 'Simulated state',
|
|
820
872
|
controls: [
|