@genesislcap/ai-assistant 15.33.1 → 15.34.1

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.
Files changed (36) hide show
  1. package/dist/ai-assistant.api.json +1021 -6
  2. package/dist/ai-assistant.d.ts +209 -5
  3. package/dist/chat-driver.cjs +90 -0
  4. package/dist/chat-driver.cjs.map +3 -3
  5. package/dist/chat-driver.mjs +86 -0
  6. package/dist/chat-driver.mjs.map +3 -3
  7. package/dist/custom-elements.json +329 -3
  8. package/dist/dts/chat-driver-node.d.ts +2 -2
  9. package/dist/dts/chat-driver-node.d.ts.map +1 -1
  10. package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
  11. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  12. package/dist/dts/index.d.ts +2 -0
  13. package/dist/dts/index.d.ts.map +1 -1
  14. package/dist/dts/main/budget-controller.d.ts +38 -6
  15. package/dist/dts/main/budget-controller.d.ts.map +1 -1
  16. package/dist/dts/main/main.d.ts +22 -4
  17. package/dist/dts/main/main.d.ts.map +1 -1
  18. package/dist/dts/main/main.types.d.ts +25 -0
  19. package/dist/dts/main/main.types.d.ts.map +1 -1
  20. package/dist/dts/provider/tiered-provider-switcher.d.ts +123 -0
  21. package/dist/dts/provider/tiered-provider-switcher.d.ts.map +1 -0
  22. package/dist/dts/state/ai-assistant-slice.d.ts +34 -0
  23. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  24. package/dist/dts/state/session-store.d.ts.map +1 -1
  25. package/dist/esm/chat-driver-node.js +11 -1
  26. package/dist/esm/components/settings-modal/settings-modal.styles.js +75 -0
  27. package/dist/esm/components/settings-modal/settings-modal.template.js +56 -4
  28. package/dist/esm/index.js +1 -0
  29. package/dist/esm/main/budget-controller.js +178 -6
  30. package/dist/esm/main/main.js +20 -0
  31. package/dist/esm/provider/tiered-provider-switcher.js +142 -0
  32. package/dist/esm/state/ai-assistant-slice.js +17 -3
  33. package/docs/styling.md +24 -0
  34. package/package.json +18 -17
  35. package/sandbox/sandbox.ts +52 -0
  36. 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 ? null : { budgetUsd: figures.budgetUsd, spentUsd: figures.spentUsd },
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: { budgetUsd: budget.budgetUsd, spentUsd: budget.spentUsd },
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
- // The same clamp the context indicator applies to its bar. No lower
706
- // clamp needed: the boundary refuses negative figures.
707
- barValue: Math.min(100, percent),
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;
@@ -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
- state.vendorBudgets[vendor] = { budgetUsd: figures.budgetUsd, spentUsd: figures.spentUsd };
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.33.1",
4
+ "version": "15.34.1",
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.33.1",
78
- "@genesislcap/genx": "15.33.1",
79
- "@genesislcap/rollup-builder": "15.33.1",
80
- "@genesislcap/ts-builder": "15.33.1",
81
- "@genesislcap/uvu-playwright-builder": "15.33.1",
82
- "@genesislcap/vite-builder": "15.33.1",
83
- "@genesislcap/webpack-builder": "15.33.1",
78
+ "@genesislcap/foundation-testing": "15.34.1",
79
+ "@genesislcap/genx": "15.34.1",
80
+ "@genesislcap/rollup-builder": "15.34.1",
81
+ "@genesislcap/ts-builder": "15.34.1",
82
+ "@genesislcap/uvu-playwright-builder": "15.34.1",
83
+ "@genesislcap/vite-builder": "15.34.1",
84
+ "@genesislcap/webpack-builder": "15.34.1",
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.33.1",
90
- "@genesislcap/foundation-logger": "15.33.1",
91
- "@genesislcap/foundation-notifications": "15.33.1",
92
- "@genesislcap/foundation-redux": "15.33.1",
93
- "@genesislcap/foundation-ui": "15.33.1",
94
- "@genesislcap/foundation-utils": "15.33.1",
95
- "@genesislcap/rapid-design-system": "15.33.1",
96
- "@genesislcap/web-core": "15.33.1",
90
+ "@genesislcap/foundation-ai": "15.34.1",
91
+ "@genesislcap/foundation-logger": "15.34.1",
92
+ "@genesislcap/foundation-notifications": "15.34.1",
93
+ "@genesislcap/foundation-redux": "15.34.1",
94
+ "@genesislcap/foundation-ui": "15.34.1",
95
+ "@genesislcap/foundation-utils": "15.34.1",
96
+ "@genesislcap/rapid-design-system": "15.34.1",
97
+ "@genesislcap/web-core": "15.34.1",
97
98
  "dompurify": "^3.3.1",
98
99
  "marked": "^17.0.3"
99
100
  },
@@ -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: [