@genesislcap/ai-assistant 15.7.3-alpha-e7d4aa5.0 → 15.8.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 +191 -1
- package/dist/ai-assistant.d.ts +155 -1
- package/dist/custom-elements.json +114 -2
- package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.styles.test.d.ts +2 -0
- package/dist/dts/components/settings-modal/settings-modal.styles.test.d.ts.map +1 -0
- package/dist/dts/components/settings-modal/settings-modal.template.d.ts +22 -0
- package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.template.test.d.ts +2 -0
- package/dist/dts/components/settings-modal/settings-modal.template.test.d.ts.map +1 -0
- package/dist/dts/main/budget-meter.test.d.ts +2 -0
- package/dist/dts/main/budget-meter.test.d.ts.map +1 -0
- package/dist/dts/main/main.d.ts +113 -2
- 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/state/ai-assistant-slice.d.ts +47 -0
- package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
- package/dist/dts/state/session-store.d.ts +4 -0
- package/dist/dts/state/session-store.d.ts.map +1 -1
- package/dist/dts/utils/format-usd.d.ts +24 -0
- package/dist/dts/utils/format-usd.d.ts.map +1 -0
- package/dist/esm/components/settings-modal/settings-modal.styles.js +33 -3
- package/dist/esm/components/settings-modal/settings-modal.styles.test.js +80 -0
- package/dist/esm/components/settings-modal/settings-modal.template.js +64 -14
- package/dist/esm/components/settings-modal/settings-modal.template.test.js +91 -0
- package/dist/esm/main/budget-meter.test.js +317 -0
- package/dist/esm/main/main.js +189 -3
- package/dist/esm/state/ai-assistant-slice.js +38 -0
- package/dist/esm/utils/format-usd.js +23 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/docs/migration-GENC-1464.md +63 -0
- package/docs/styling.md +23 -0
- package/package.json +17 -17
- package/src/components/settings-modal/settings-modal.styles.test.ts +94 -0
- package/src/components/settings-modal/settings-modal.styles.ts +33 -3
- package/src/components/settings-modal/settings-modal.template.test.ts +124 -0
- package/src/components/settings-modal/settings-modal.template.ts +82 -13
- package/src/main/budget-meter.test.ts +427 -0
- package/src/main/main.ts +198 -3
- package/src/main/main.types.ts +26 -0
- package/src/state/ai-assistant-slice.ts +74 -0
- package/src/utils/format-usd.ts +26 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
2
|
+
import { clearAllSessionStores, getSessionStore } from '../state/session-store';
|
|
3
|
+
import { logger } from '../utils/logger';
|
|
4
|
+
import { budgetPercentOf, FoundationAiAssistant } from './main';
|
|
5
|
+
// Hold a reference so the custom-element registration isn't tree-shaken.
|
|
6
|
+
FoundationAiAssistant;
|
|
7
|
+
// GENC-1464 — the settings-modal per-vendor budget meter.
|
|
8
|
+
//
|
|
9
|
+
// Like `blocked-state.test.ts`, this pins the GETTER LOGIC the template bindings
|
|
10
|
+
// depend on rather than the rendered DOM, and for the same reason: mounting the
|
|
11
|
+
// assistant runs `connectedCallback`, which subscribes to `agenticActivityBus`
|
|
12
|
+
// and leaves the runner's event loop open. `document.createElement` only
|
|
13
|
+
// upgrades the element — it does not connect — so nothing subscribes here.
|
|
14
|
+
//
|
|
15
|
+
// The bindings under test in `settings-modal.template.ts` are:
|
|
16
|
+
// ${when((x) => x.settingsBudgetUsageVisible, …)} (the whole meter)
|
|
17
|
+
// ${repeat((x) => x.settingsBudgetRows, …)} (one row per vendor)
|
|
18
|
+
// <…-progress value="${(row) => row.barValue}"> (the clamped bar)
|
|
19
|
+
// plus `settingsModelSectionVisible`, which decides whether the "AI Model
|
|
20
|
+
// Settings" section renders at all.
|
|
21
|
+
const Suite = createLogicSuite('FoundationAiAssistant budget meter');
|
|
22
|
+
let storeSeq = 0;
|
|
23
|
+
/** A fresh (unconnected) element wired to its own real session store. */
|
|
24
|
+
function element() {
|
|
25
|
+
const el = document.createElement('foundation-ai-assistant');
|
|
26
|
+
storeSeq += 1;
|
|
27
|
+
el._sessionRef = getSessionStore(`budget-meter-test-${storeSeq}`, false);
|
|
28
|
+
return el;
|
|
29
|
+
}
|
|
30
|
+
/** The raw stored figures, read the way the rows getter reads them. */
|
|
31
|
+
const storedBudgets = (el) => el._sessionRef.store.aiAssistant
|
|
32
|
+
.vendorBudgets;
|
|
33
|
+
/** The store half of "Clear" / "New chat" — see blocked-state.test.ts. */
|
|
34
|
+
const resetSession = (el) => el._sessionRef.actions.aiAssistant.resetSession();
|
|
35
|
+
/** Count `logger.warn` calls during `run` — the boundary warns ONCE per element. */
|
|
36
|
+
const countWarns = (run) => {
|
|
37
|
+
const original = logger.warn;
|
|
38
|
+
let calls = 0;
|
|
39
|
+
logger.warn = ((...args) => {
|
|
40
|
+
calls += 1;
|
|
41
|
+
void args;
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
run();
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
logger.warn = original;
|
|
48
|
+
}
|
|
49
|
+
return calls;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Count reads of `settingsBudgetRows` during `run`.
|
|
53
|
+
*
|
|
54
|
+
* Building the rows formats every vendor's money text — a `formatUsd` pair and
|
|
55
|
+
* a `budgetPercentOf` each — so a gate that answers THROUGH the rows pays for
|
|
56
|
+
* all of it. Counting reads of the getter is the direct probe of that; the spy
|
|
57
|
+
* goes on the prototype (where the accessor lives) and is put back after.
|
|
58
|
+
*/
|
|
59
|
+
const countRowBuilds = (run) => {
|
|
60
|
+
const proto = FoundationAiAssistant.prototype;
|
|
61
|
+
const original = Object.getOwnPropertyDescriptor(proto, 'settingsBudgetRows');
|
|
62
|
+
let calls = 0;
|
|
63
|
+
Object.defineProperty(proto, 'settingsBudgetRows', Object.assign(Object.assign({}, original), { get() {
|
|
64
|
+
calls += 1;
|
|
65
|
+
return original.get.call(this);
|
|
66
|
+
} }));
|
|
67
|
+
try {
|
|
68
|
+
run();
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
Object.defineProperty(proto, 'settingsBudgetRows', original);
|
|
72
|
+
}
|
|
73
|
+
return calls;
|
|
74
|
+
};
|
|
75
|
+
Suite.after(() => {
|
|
76
|
+
clearAllSessionStores();
|
|
77
|
+
});
|
|
78
|
+
// ── Feeding figures ─────────────────────────────────────────────────────────
|
|
79
|
+
Suite('feeding two vendors renders two rows with the real money text', () => {
|
|
80
|
+
const el = element();
|
|
81
|
+
assert.is(el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 }), true);
|
|
82
|
+
assert.is(el.setVendorBudget('gemini', { budgetUsd: 200, spentUsd: 30.5 }), true);
|
|
83
|
+
const rows = el.settingsBudgetRows;
|
|
84
|
+
assert.is(rows.length, 2);
|
|
85
|
+
assert.is(rows[0].vendor, 'anthropic');
|
|
86
|
+
assert.is(rows[0].label, 'Anthropic', 'display name comes from VENDOR_LABELS');
|
|
87
|
+
assert.is(rows[0].figures, '$45.00 / $50.00 (90%)');
|
|
88
|
+
assert.is(rows[0].barValue, 90);
|
|
89
|
+
assert.is(rows[1].vendor, 'gemini');
|
|
90
|
+
assert.is(rows[1].figures, '$30.50 / $200.00 (15%)', 'two decimals always, percent rounded');
|
|
91
|
+
});
|
|
92
|
+
Suite('rows keep BUDGETED_VENDORS order however the host ordered its feed', () => {
|
|
93
|
+
// The feed loops over a server response whose order is the server's business;
|
|
94
|
+
// the meter must not reorder itself between turns because of it.
|
|
95
|
+
const el = element();
|
|
96
|
+
el.setVendorBudget('gemini', { budgetUsd: 10, spentUsd: 1 });
|
|
97
|
+
el.setVendorBudget('anthropic', { budgetUsd: 10, spentUsd: 2 });
|
|
98
|
+
assert.equal(el.settingsBudgetRows.map((r) => r.vendor), ['anthropic', 'gemini']);
|
|
99
|
+
});
|
|
100
|
+
Suite('a vendor with no figures gets no row — that is how "unlimited" renders', () => {
|
|
101
|
+
// Display rule: hosts simply do not feed a vendor the server marks
|
|
102
|
+
// `unlimited: true` / `limitUsd: null`, and the meter shows only what it was
|
|
103
|
+
// fed. No "∞" row, no empty bar.
|
|
104
|
+
const el = element();
|
|
105
|
+
el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 15 });
|
|
106
|
+
const rows = el.settingsBudgetRows;
|
|
107
|
+
assert.is(rows.length, 1);
|
|
108
|
+
assert.is(rows[0].vendor, 'anthropic');
|
|
109
|
+
});
|
|
110
|
+
// ── Overshoot: the text tells the truth, the bar clamps ─────────────────────
|
|
111
|
+
Suite('spend past the cap shows the real figures but clamps the bar at 100', () => {
|
|
112
|
+
// Real overshoot exists — the last allowed call can cross the cap (the demo
|
|
113
|
+
// "Exhaust" lands at $62.34 of $50 on purpose). A bar past its end renders
|
|
114
|
+
// nonsense, so only the bar clamps.
|
|
115
|
+
const el = element();
|
|
116
|
+
el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 62.34 });
|
|
117
|
+
const [row] = el.settingsBudgetRows;
|
|
118
|
+
assert.is(row.figures, '$62.34 / $50.00 (125%)', 'the text is the truth, unclamped');
|
|
119
|
+
assert.is(row.barValue, 100, 'the bar is full, not broken');
|
|
120
|
+
});
|
|
121
|
+
Suite('a $0 cap reads as a full meter, not a division by zero', () => {
|
|
122
|
+
// The platform treats a $0 budget as "no spend allowed" (not "unlimited"), so
|
|
123
|
+
// it has no headroom by definition.
|
|
124
|
+
assert.is(budgetPercentOf({ budgetUsd: 0, spentUsd: 0 }), 100);
|
|
125
|
+
assert.is(budgetPercentOf({ budgetUsd: 0, spentUsd: 3 }), 100);
|
|
126
|
+
const el = element();
|
|
127
|
+
el.setVendorBudget('gemini', { budgetUsd: 0, spentUsd: 0 });
|
|
128
|
+
assert.is(el.settingsBudgetRows[0].figures, '$0.00 / $0.00 (100%)');
|
|
129
|
+
assert.is(el.settingsBudgetRows[0].barValue, 100);
|
|
130
|
+
});
|
|
131
|
+
// ── Clearing ────────────────────────────────────────────────────────────────
|
|
132
|
+
Suite('setVendorBudget(vendor, null) clears that row and only that row', () => {
|
|
133
|
+
const el = element();
|
|
134
|
+
el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 });
|
|
135
|
+
el.setVendorBudget('gemini', { budgetUsd: 50, spentUsd: 15 });
|
|
136
|
+
assert.is(el.setVendorBudget('anthropic', null), true);
|
|
137
|
+
assert.equal(el.settingsBudgetRows.map((r) => r.vendor), ['gemini']);
|
|
138
|
+
});
|
|
139
|
+
Suite('clearing a vendor that has no figures is a true no-op', () => {
|
|
140
|
+
const el = element();
|
|
141
|
+
assert.is(el.setVendorBudget('anthropic', null), true, 'clearing nothing is not an error');
|
|
142
|
+
assert.is(el.settingsBudgetRows.length, 0);
|
|
143
|
+
});
|
|
144
|
+
Suite("undefined clears too — an untyped host's missing lookup is not a crash", () => {
|
|
145
|
+
// The `| null` parameter type is for typed callers; the runtime is more
|
|
146
|
+
// forgiving on purpose. Untyped JS hosts feed this straight from
|
|
147
|
+
// GET /api/budget, where a per-vendor lookup that MISSES yields `undefined`,
|
|
148
|
+
// not `null` — and a boundary whose whole contract is "return false, never
|
|
149
|
+
// throw" must not TypeError on it. "No figures" means "no row", either way.
|
|
150
|
+
const el = element();
|
|
151
|
+
el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 });
|
|
152
|
+
el.setVendorBudget('gemini', { budgetUsd: 50, spentUsd: 15 });
|
|
153
|
+
const warns = countWarns(() => {
|
|
154
|
+
assert.is(el.setVendorBudget('anthropic', undefined), true, 'accepted, not refused');
|
|
155
|
+
});
|
|
156
|
+
assert.is(warns, 0, 'a missing lookup is not garbage input — nothing to warn about');
|
|
157
|
+
assert.equal(el.settingsBudgetRows.map((r) => r.vendor), ['gemini'], 'it cleared that vendor and only that vendor, exactly as null does');
|
|
158
|
+
});
|
|
159
|
+
// ── Invalid input: false + warn once, never a throw, never a write ──────────
|
|
160
|
+
Suite('an unbudgeted vendor is refused with false and no state change', () => {
|
|
161
|
+
const el = element();
|
|
162
|
+
el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 15 });
|
|
163
|
+
// 'none' is the no-provider sentinel; 'chrome' is on-device (no pot to
|
|
164
|
+
// meter); 'openai' is refused by the proxy up front — none can carry figures.
|
|
165
|
+
for (const vendor of ['none', 'chrome', 'openai', 'acme-ai']) {
|
|
166
|
+
assert.is(el.setVendorBudget(vendor, { budgetUsd: 1, spentUsd: 0 }), false, vendor);
|
|
167
|
+
}
|
|
168
|
+
assert.equal(el.settingsBudgetRows.map((r) => r.vendor), ['anthropic'], 'nothing was stored for any of them');
|
|
169
|
+
});
|
|
170
|
+
Suite('non-finite or negative figures are refused with false and no state change', () => {
|
|
171
|
+
const el = element();
|
|
172
|
+
el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 15 });
|
|
173
|
+
const bad = [
|
|
174
|
+
{ budgetUsd: NaN, spentUsd: 1 },
|
|
175
|
+
{ budgetUsd: 1, spentUsd: NaN },
|
|
176
|
+
{ budgetUsd: Infinity, spentUsd: 1 },
|
|
177
|
+
{ budgetUsd: 1, spentUsd: -0.01 },
|
|
178
|
+
{ budgetUsd: -50, spentUsd: 1 },
|
|
179
|
+
// The endpoint's `limitUsd: null` (unlimited) must be dropped by the host,
|
|
180
|
+
// not forwarded; forwarded anyway, it is garbage, not a clear.
|
|
181
|
+
{ budgetUsd: null, spentUsd: 1 },
|
|
182
|
+
];
|
|
183
|
+
for (const figures of bad) {
|
|
184
|
+
assert.is(el.setVendorBudget('anthropic', figures), false, JSON.stringify(figures));
|
|
185
|
+
}
|
|
186
|
+
assert.is(el.settingsBudgetRows[0].figures, '$15.00 / $50.00 (30%)', 'the good figures survive');
|
|
187
|
+
});
|
|
188
|
+
Suite('garbage warns once per element, not once per call', () => {
|
|
189
|
+
// The documented feed re-runs after every turn, so a host bug would repeat
|
|
190
|
+
// the same warning on every send for the life of the page.
|
|
191
|
+
const el = element();
|
|
192
|
+
const warns = countWarns(() => {
|
|
193
|
+
assert.is(el.setVendorBudget('openai', { budgetUsd: 1, spentUsd: 0 }), false);
|
|
194
|
+
assert.is(el.setVendorBudget('openai', { budgetUsd: 1, spentUsd: 0 }), false);
|
|
195
|
+
assert.is(el.setVendorBudget('anthropic', { budgetUsd: NaN, spentUsd: 0 }), false);
|
|
196
|
+
});
|
|
197
|
+
assert.is(warns, 1, 'one warning covers the whole broken feed');
|
|
198
|
+
});
|
|
199
|
+
// ── Not ready yet: the other reason a feed is refused ───────────────────────
|
|
200
|
+
Suite('a feed before the element has a session store is refused, not silently dropped', () => {
|
|
201
|
+
// The lifecycle window: a host feeding ahead of the first append, or during a
|
|
202
|
+
// pop-out remount, has no store to write to. The dispatch no-ops there, so
|
|
203
|
+
// returning true reported a write that never happened and the caller — which
|
|
204
|
+
// reads the boolean as "accepted" — would never retry.
|
|
205
|
+
const el = document.createElement('foundation-ai-assistant');
|
|
206
|
+
const warns = countWarns(() => {
|
|
207
|
+
assert.is(el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 }), false);
|
|
208
|
+
});
|
|
209
|
+
assert.is(warns, 0, 'a lifecycle window is not bad input — the warn-once channel is for garbage');
|
|
210
|
+
assert.is(el.settingsBudgetRows.length, 0, 'nothing was stored');
|
|
211
|
+
assert.is(el.settingsBudgetUsageVisible, false, 'and nothing is shown');
|
|
212
|
+
// The recovery is the documented per-turn re-feed, which lands as soon as the
|
|
213
|
+
// store exists — the connected path is unchanged.
|
|
214
|
+
const ready = element();
|
|
215
|
+
assert.is(ready.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 }), true);
|
|
216
|
+
assert.is(ready.settingsBudgetRows.length, 1);
|
|
217
|
+
});
|
|
218
|
+
// ── Idempotence ─────────────────────────────────────────────────────────────
|
|
219
|
+
Suite('re-feeding identical figures leaves the stored object untouched', () => {
|
|
220
|
+
// The feed re-runs unconditionally after every turn; identical dollars must
|
|
221
|
+
// not publish a new store reference (and re-render an unchanged meter).
|
|
222
|
+
const el = element();
|
|
223
|
+
el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 15 });
|
|
224
|
+
const before = storedBudgets(el).anthropic;
|
|
225
|
+
assert.is(el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 15 }), true);
|
|
226
|
+
assert.is(storedBudgets(el).anthropic, before, 'same dollars, same object');
|
|
227
|
+
});
|
|
228
|
+
Suite('the store keeps its own copy, not the host object', () => {
|
|
229
|
+
// The store must not adopt an object the host still owns: immer finalizes
|
|
230
|
+
// whatever the reducer assigns into the draft and FREEZES it — so adoption
|
|
231
|
+
// would freeze state the host believes is its own, turning the host's next
|
|
232
|
+
// `figures.spentUsd += x` into a strict-mode TypeError (or a silent no-op).
|
|
233
|
+
// The frozen-ness probe is the observable that catches adoption directly;
|
|
234
|
+
// the mutation-isolation assertion is the user-visible consequence.
|
|
235
|
+
const el = element();
|
|
236
|
+
const hostFigures = { budgetUsd: 50, spentUsd: 15 };
|
|
237
|
+
el.setVendorBudget('anthropic', hostFigures);
|
|
238
|
+
assert.is(Object.isFrozen(hostFigures), false, 'the host object was not adopted-and-frozen');
|
|
239
|
+
hostFigures.spentUsd = 999;
|
|
240
|
+
assert.is(el.settingsBudgetRows[0].figures, '$15.00 / $50.00 (30%)');
|
|
241
|
+
});
|
|
242
|
+
// ── Lifetime ────────────────────────────────────────────────────────────────
|
|
243
|
+
Suite('the figures survive "Clear" / "New chat" — budget is not conversation state', () => {
|
|
244
|
+
// Same side of the resetSession preserve-list as the blocked latch: a new
|
|
245
|
+
// chat refills no budget, so wiping the meter would blank real dollars until
|
|
246
|
+
// the host's next re-feed. Opposite side from contextTokens, which IS
|
|
247
|
+
// conversation state (a fresh chat genuinely has an empty context window).
|
|
248
|
+
const el = element();
|
|
249
|
+
el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 });
|
|
250
|
+
el.messages = [{ id: '1', role: 'user', content: 'hello' }];
|
|
251
|
+
resetSession(el);
|
|
252
|
+
assert.is(el.messages.length, 0, 'the transcript is gone');
|
|
253
|
+
assert.is(el.contextTokens, undefined, 'context usage reset with the conversation');
|
|
254
|
+
assert.is(el.settingsBudgetRows[0].figures, '$45.00 / $50.00 (90%)', 'the meter is not');
|
|
255
|
+
});
|
|
256
|
+
Suite('the figures are per session store — a different stateKey starts empty', () => {
|
|
257
|
+
const first = element();
|
|
258
|
+
const second = element();
|
|
259
|
+
first.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 });
|
|
260
|
+
assert.is(first.settingsBudgetRows.length, 1);
|
|
261
|
+
assert.is(second.settingsBudgetRows.length, 0, 'a different session store is unaffected');
|
|
262
|
+
});
|
|
263
|
+
// ── Visibility ──────────────────────────────────────────────────────────────
|
|
264
|
+
Suite('showBudgetUsage: false hides the meter even with figures fed', () => {
|
|
265
|
+
const el = element();
|
|
266
|
+
el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 });
|
|
267
|
+
assert.is(el.settingsBudgetUsageVisible, true, 'default ON — undefined shows');
|
|
268
|
+
el.chatConfig = { ui: { showBudgetUsage: false } };
|
|
269
|
+
assert.is(el.settingsBudgetUsageVisible, false);
|
|
270
|
+
assert.is(el.settingsBudgetRows.length, 1, 'the state itself is kept, only the render is off');
|
|
271
|
+
el.chatConfig = { ui: { showBudgetUsage: true } };
|
|
272
|
+
assert.is(el.settingsBudgetUsageVisible, true, 'explicit true shows too');
|
|
273
|
+
});
|
|
274
|
+
Suite('no figures → the meter behaves like the context indicator with no data', () => {
|
|
275
|
+
// The absent case mirrors settingsContextUsageVisible exactly: nothing to
|
|
276
|
+
// show means no meter AND no "AI Model Settings" section on its account.
|
|
277
|
+
const el = element();
|
|
278
|
+
assert.is(el.settingsContextUsageVisible, false, 'the sibling gate this one mirrors');
|
|
279
|
+
assert.is(el.settingsBudgetUsageVisible, false);
|
|
280
|
+
assert.is(el.settingsModelSectionVisible, false, 'no slot content, no data — no section');
|
|
281
|
+
});
|
|
282
|
+
Suite('clearing the last vendor takes the meter back out of view', () => {
|
|
283
|
+
// The gate reads the RAW slice state rather than counting formatted rows, so
|
|
284
|
+
// this is the case that keeps the two readings honest: a vendor that was fed
|
|
285
|
+
// and then cleared must leave NO trace the gate could mistake for data.
|
|
286
|
+
const el = element();
|
|
287
|
+
el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 });
|
|
288
|
+
assert.is(el.settingsBudgetUsageVisible, true);
|
|
289
|
+
el.setVendorBudget('anthropic', null);
|
|
290
|
+
assert.is(el.settingsBudgetUsageVisible, false, 'cleared is invisible, not an empty meter');
|
|
291
|
+
assert.is(el.settingsBudgetRows.length, 0, 'the rows agree');
|
|
292
|
+
assert.is(el.settingsModelSectionVisible, false, 'and the section goes with it');
|
|
293
|
+
});
|
|
294
|
+
Suite('the visibility gate answers without building a single row', () => {
|
|
295
|
+
// The gate is read several times per render pass — this getter, the section
|
|
296
|
+
// gate, the template's `when` — and answering it through
|
|
297
|
+
// `settingsBudgetRows.length` formatted every vendor's money text each time
|
|
298
|
+
// just to ask whether any rows exist. It reads the raw slice state instead;
|
|
299
|
+
// the context sibling it mirrors gates on a bare `!= null` for the same
|
|
300
|
+
// reason. The rendering path still builds rows, of course — the control below.
|
|
301
|
+
const el = element();
|
|
302
|
+
el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 });
|
|
303
|
+
el.setVendorBudget('gemini', { budgetUsd: 200, spentUsd: 30.5 });
|
|
304
|
+
assert.is(countRowBuilds(() => void el.settingsBudgetUsageVisible), 0, 'the gate builds nothing');
|
|
305
|
+
assert.is(countRowBuilds(() => void el.settingsBudgetRows), 1, 'the rendering path does (the control that the probe can see a read at all)');
|
|
306
|
+
});
|
|
307
|
+
Suite('figures alone are enough to surface the AI Model Settings section', () => {
|
|
308
|
+
// The wiring into settingsModelSectionVisible, the same way
|
|
309
|
+
// settingsContextUsageVisible is wired in.
|
|
310
|
+
const el = element();
|
|
311
|
+
assert.is(el.settingsModelSectionVisible, false);
|
|
312
|
+
el.setVendorBudget('gemini', { budgetUsd: 50, spentUsd: 15 });
|
|
313
|
+
assert.is(el.settingsModelSectionVisible, true);
|
|
314
|
+
el.chatConfig = { ui: { showBudgetUsage: false } };
|
|
315
|
+
assert.is(el.settingsModelSectionVisible, false, 'and the config flag takes it back out');
|
|
316
|
+
});
|
|
317
|
+
Suite.run();
|
package/dist/esm/main/main.js
CHANGED
|
@@ -57,6 +57,7 @@ import { deleteBankedBaseline, getBankedBaseline, setBankedBaseline, } from '../
|
|
|
57
57
|
import { collectSessionModels } from '../utils/collect-session-models';
|
|
58
58
|
import { clearCostSessionHistory, isCostSessionRecord, loadCostSessionHistory, resolveBankedUsage, saveCostSessionHistory, sortRecordsByRecency, upsertRecord, } from '../utils/cost-session-history';
|
|
59
59
|
import { deriveCostSessionTitleFromMessages, resolveCostSessionTitle, } from '../utils/derive-cost-session-title';
|
|
60
|
+
import { formatUsd } from '../utils/format-usd';
|
|
60
61
|
import { logger } from '../utils/logger';
|
|
61
62
|
import { filterVisibleMessages, trailingInteractionRow } from '../utils/message-partition';
|
|
62
63
|
import { resolveCostHistoryConfig, } from '../utils/resolve-cost-history-config';
|
|
@@ -143,7 +144,7 @@ const RAISE_LIMITS_ACTION = 'Contact your administrator to raise them.';
|
|
|
143
144
|
export function formatBlockedReason(budget, vendor) {
|
|
144
145
|
if (!budget || (budget.budgetUsd == null && budget.spentUsd == null))
|
|
145
146
|
return undefined;
|
|
146
|
-
const money = (v) => (v == null ? 'an unknown amount' :
|
|
147
|
+
const money = (v) => (v == null ? 'an unknown amount' : formatUsd(v));
|
|
147
148
|
const figures = `(${money(budget.spentUsd)} of ${money(budget.budgetUsd)})`;
|
|
148
149
|
// A per-vendor statement carries NO action clause, because the right action is
|
|
149
150
|
// not knowable at latch time: whether "switch vendor" or "contact your
|
|
@@ -189,6 +190,24 @@ function formatVendorList(vendors) {
|
|
|
189
190
|
return (_a = names[0]) !== null && _a !== void 0 ? _a : '';
|
|
190
191
|
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
191
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Whole-number percentage of a budget consumed, for the meter (GENC-1464).
|
|
195
|
+
*
|
|
196
|
+
* Deliberately unclamped — spend can genuinely overshoot the cap (the last
|
|
197
|
+
* allowed call may cross it), and the meter's TEXT shows the truth; only the
|
|
198
|
+
* bar clamps, at the render site. The one special case is a `$0` cap, which the
|
|
199
|
+
* platform treats as "no spend allowed" (not "unlimited"): it has no headroom
|
|
200
|
+
* by definition, so the meter reads full rather than dividing by zero.
|
|
201
|
+
*
|
|
202
|
+
* Exported for the unit test that pins the arithmetic; not element API.
|
|
203
|
+
*
|
|
204
|
+
* @internal
|
|
205
|
+
*/
|
|
206
|
+
export function budgetPercentOf(figures) {
|
|
207
|
+
if (figures.budgetUsd <= 0)
|
|
208
|
+
return 100;
|
|
209
|
+
return Math.round((figures.spentUsd / figures.budgetUsd) * 100);
|
|
210
|
+
}
|
|
192
211
|
// Register supporting components when the main component module is imported.
|
|
193
212
|
avoidTreeShaking(AiChatMarkdown, AiChatInteractionWrapper, AiHaloOverlay, AiWavesIndicator, AiFlowingWavesIndicator, AiPlasmaOrbIndicator, AiChatBubble, AiActivityHalo, ChatSuggestions, AgentPicker);
|
|
194
213
|
/**
|
|
@@ -239,6 +258,13 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
239
258
|
this.persistence = {};
|
|
240
259
|
/** When set, enables Redux DevTools for this instance's session store. */
|
|
241
260
|
this.debugRedux = false;
|
|
261
|
+
/**
|
|
262
|
+
* One warning per element for garbage fed to
|
|
263
|
+
* {@link FoundationAiAssistant.setVendorBudget}. Once, not per call, because
|
|
264
|
+
* the documented feed re-runs after every turn — a host bug would otherwise
|
|
265
|
+
* repeat the same warning on every send for the life of the page.
|
|
266
|
+
*/
|
|
267
|
+
this.vendorBudgetWarningIssued = false;
|
|
242
268
|
// ---- Transient UI state (stays as @observable on the component) ----
|
|
243
269
|
this._suggestionsGeneration = 0;
|
|
244
270
|
this.attachments = [];
|
|
@@ -862,6 +888,102 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
862
888
|
}
|
|
863
889
|
(_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setVendorBlocked({ vendor, blocked, reason });
|
|
864
890
|
}
|
|
891
|
+
warnVendorBudgetOnce(message) {
|
|
892
|
+
if (this.vendorBudgetWarningIssued)
|
|
893
|
+
return;
|
|
894
|
+
this.vendorBudgetWarningIssued = true;
|
|
895
|
+
logger.warn(`FoundationAiAssistant.setVendorBudget: ${message}`);
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Feed (or clear) one vendor's AI-spend figures for the settings-modal budget
|
|
899
|
+
* meter — the numeric companion to
|
|
900
|
+
* {@link FoundationAiAssistant.setVendorBlocked}, and like it a host-driven
|
|
901
|
+
* write: the element meters nothing itself.
|
|
902
|
+
*
|
|
903
|
+
* The meter renders a row for exactly the vendors that currently HAVE
|
|
904
|
+
* figures. An **unlimited** vendor (the budget endpoint reports
|
|
905
|
+
* `unlimited: true` / `limitUsd: null`) is expressed by never feeding it —
|
|
906
|
+
* there is deliberately no "unlimited" figure shape, because a bar with no
|
|
907
|
+
* cap has nothing truthful to fill to. `spentUsd` MAY exceed `budgetUsd`
|
|
908
|
+
* (the last allowed call can overshoot the cap): the row's text shows the
|
|
909
|
+
* real figures and only the bar clamps.
|
|
910
|
+
*
|
|
911
|
+
* Returns `false` — and warns once, not per call — instead of throwing when
|
|
912
|
+
* `vendor` is not a budgeted vendor (`BUDGETED_VENDORS`, the proxy's own
|
|
913
|
+
* metered list) or either figure is not a finite number `>= 0`. Invalid input
|
|
914
|
+
* changes no state. Idempotent: re-feeding a vendor's current figures is a
|
|
915
|
+
* no-op, so hosts can re-feed unconditionally after every turn.
|
|
916
|
+
*
|
|
917
|
+
* `false` ALSO means "not stored, because the element is not ready yet": a
|
|
918
|
+
* feed that lands before the element has its session store (a host calling
|
|
919
|
+
* ahead of the first append, or during a pop-out remount) stores nothing and
|
|
920
|
+
* says so, rather than reporting a write that never happened. That case is
|
|
921
|
+
* transient and needs no host handling — the documented per-turn re-feed is
|
|
922
|
+
* what recovers it — so it is deliberately NOT warned about; the warn-once
|
|
923
|
+
* channel is for garbage input only.
|
|
924
|
+
*
|
|
925
|
+
* `figures` is nullish-checked at run time, so `undefined` behaves exactly
|
|
926
|
+
* like `null` and CLEARS that vendor's row. Untyped JS hosts feed this
|
|
927
|
+
* straight from `GET /api/budget`, where a per-vendor lookup that misses
|
|
928
|
+
* yields `undefined`, not `null` — and "no figures" coherently means "no
|
|
929
|
+
* row". The `| null` parameter type is unchanged for typed callers; the
|
|
930
|
+
* runtime is deliberately the more forgiving of the two.
|
|
931
|
+
*
|
|
932
|
+
* The intended feed is the platform budget endpoint:
|
|
933
|
+
*
|
|
934
|
+
* ```ts
|
|
935
|
+
* // `vendors` is an OBJECT keyed by vendor id, not an array.
|
|
936
|
+
* const { vendors } = await (await fetch('/api/budget')).json();
|
|
937
|
+
* for (const v of Object.values(vendors)) {
|
|
938
|
+
* if (v.unlimited || v.limitUsd == null) {
|
|
939
|
+
* assistantEl.setVendorBudget(v.vendor, null); // clear stale figures after a cap is lifted
|
|
940
|
+
* continue;
|
|
941
|
+
* }
|
|
942
|
+
* assistantEl.setVendorBudget(v.vendor, { budgetUsd: v.limitUsd, spentUsd: v.spentUsd });
|
|
943
|
+
* }
|
|
944
|
+
* ```
|
|
945
|
+
*
|
|
946
|
+
* Re-run it on mount and after each turn. Scope and lifetime follow the rest
|
|
947
|
+
* of the session slice (per `stateKey`, survives pop-in/out and "Clear" /
|
|
948
|
+
* "New chat") — except that the figures are NOT persisted to the session
|
|
949
|
+
* snapshot: the server is authoritative and the host re-feeds at boot, so a
|
|
950
|
+
* persisted copy could only show stale dollars after a reload.
|
|
951
|
+
*
|
|
952
|
+
* @beta
|
|
953
|
+
*/
|
|
954
|
+
setVendorBudget(vendor, figures) {
|
|
955
|
+
if (!BUDGETED_VENDORS.includes(vendor)) {
|
|
956
|
+
this.warnVendorBudgetOnce(`'${vendor}' is not a budgeted vendor — ignoring. Only the proxy-metered vendors ` +
|
|
957
|
+
`(${BUDGETED_VENDORS.join(', ')}) can carry budget figures.`);
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
960
|
+
// Nullish, not `!== null`: an untyped host's missing lookup arrives as
|
|
961
|
+
// `undefined`, and dereferencing it here would throw at a boundary whose
|
|
962
|
+
// whole contract is to return false instead. `undefined` clears, like null.
|
|
963
|
+
if (figures != null) {
|
|
964
|
+
const validUsd = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
|
965
|
+
if (!validUsd(figures.budgetUsd) || !validUsd(figures.spentUsd)) {
|
|
966
|
+
this.warnVendorBudgetOnce(`invalid figures for '${vendor}' — ignoring. budgetUsd and spentUsd must both be ` +
|
|
967
|
+
`finite numbers >= 0 (got budgetUsd: ${figures.budgetUsd}, spentUsd: ${figures.spentUsd}).`);
|
|
968
|
+
return false;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
const session = this._sessionRef;
|
|
972
|
+
// No store yet (fed before the first append, or mid pop-out remount): there
|
|
973
|
+
// is nowhere to write, so say so rather than returning an accepted the
|
|
974
|
+
// caller would never retry. Not warned — this is a lifecycle window, not
|
|
975
|
+
// bad input, and the documented per-turn re-feed lands the figures as soon
|
|
976
|
+
// as the store exists.
|
|
977
|
+
if (!session)
|
|
978
|
+
return false;
|
|
979
|
+
session.actions.aiAssistant.setVendorBudget({
|
|
980
|
+
vendor: vendor,
|
|
981
|
+
// A fresh literal, never the caller's object — see the reducer, which
|
|
982
|
+
// must not adopt (and potentially freeze) state the host still owns.
|
|
983
|
+
figures: figures == null ? null : { budgetUsd: figures.budgetUsd, spentUsd: figures.spentUsd },
|
|
984
|
+
});
|
|
985
|
+
return true;
|
|
986
|
+
}
|
|
865
987
|
/** Whether this vendor's wall came from the sweep alone — see the slice's `sweptVendors`. */
|
|
866
988
|
isVendorSwept(vendor) {
|
|
867
989
|
var _a, _b;
|
|
@@ -3107,15 +3229,73 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
3107
3229
|
return false;
|
|
3108
3230
|
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; });
|
|
3109
3231
|
}
|
|
3110
|
-
/** Whether the settings modal AI Model section has slotted app content or
|
|
3232
|
+
/** Whether the settings modal AI Model section has slotted app content, context usage or budget figures. */
|
|
3111
3233
|
get settingsModelSectionVisible() {
|
|
3112
|
-
return this.settingsModelSlotted.length > 0 ||
|
|
3234
|
+
return (this.settingsModelSlotted.length > 0 ||
|
|
3235
|
+
this.settingsContextUsageVisible ||
|
|
3236
|
+
this.settingsBudgetUsageVisible);
|
|
3113
3237
|
}
|
|
3114
3238
|
/** Built-in context window indicator (generic platform concern). */
|
|
3115
3239
|
get settingsContextUsageVisible() {
|
|
3116
3240
|
const ui = this.chatConfig.ui;
|
|
3117
3241
|
return ((ui === null || ui === void 0 ? void 0 : ui.showContextUsage) !== false && this.contextTokens != null && this.contextLimit != null);
|
|
3118
3242
|
}
|
|
3243
|
+
/**
|
|
3244
|
+
* Built-in per-vendor budget meter (GENC-1464) — the money sibling of
|
|
3245
|
+
* {@link FoundationAiAssistant.settingsContextUsageVisible}, with the same
|
|
3246
|
+
* shape of gate: not configured off, and there is data to show. "Data" here
|
|
3247
|
+
* is "at least one vendor has figures", so a host that feeds nothing (or only
|
|
3248
|
+
* unlimited vendors) gets no meter and no empty container — exactly the
|
|
3249
|
+
* context indicator's absent case.
|
|
3250
|
+
*
|
|
3251
|
+
* Answered from the RAW slice state rather than from
|
|
3252
|
+
* {@link FoundationAiAssistant.settingsBudgetRows}, whose `.length` would
|
|
3253
|
+
* format every row (a `formatUsd` pair and a `budgetPercentOf` each) only to
|
|
3254
|
+
* ask whether any exist. This is read several times per render pass — the
|
|
3255
|
+
* getter, the section gate, the template's `when` — so it stays as cheap as
|
|
3256
|
+
* the context sibling's `!= null`.
|
|
3257
|
+
*
|
|
3258
|
+
* @internal
|
|
3259
|
+
*/
|
|
3260
|
+
get settingsBudgetUsageVisible() {
|
|
3261
|
+
var _a, _b;
|
|
3262
|
+
const budgets = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.vendorBudgets;
|
|
3263
|
+
return (((_b = this.chatConfig.ui) === null || _b === void 0 ? void 0 : _b.showBudgetUsage) !== false &&
|
|
3264
|
+
budgets != null &&
|
|
3265
|
+
// Keyed off BUDGETED_VENDORS, exactly as the rows are, so a key outside
|
|
3266
|
+
// that list could never surface a meter with no row to show.
|
|
3267
|
+
BUDGETED_VENDORS.some((vendor) => budgets[vendor] != null));
|
|
3268
|
+
}
|
|
3269
|
+
/**
|
|
3270
|
+
* The meter's rows — one per vendor that currently has figures, in
|
|
3271
|
+
* `BUDGETED_VENDORS` order (the proxy's own metered list) so the rendering is
|
|
3272
|
+
* stable however the host ordered its feed calls. Pre-formatted; see
|
|
3273
|
+
* {@link SettingsBudgetRow} for why the text is unclamped while the bar is.
|
|
3274
|
+
*
|
|
3275
|
+
* @internal
|
|
3276
|
+
*/
|
|
3277
|
+
get settingsBudgetRows() {
|
|
3278
|
+
var _a;
|
|
3279
|
+
const budgets = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.vendorBudgets;
|
|
3280
|
+
if (!budgets)
|
|
3281
|
+
return [];
|
|
3282
|
+
const rows = [];
|
|
3283
|
+
for (const vendor of BUDGETED_VENDORS) {
|
|
3284
|
+
const figures = budgets[vendor];
|
|
3285
|
+
if (!figures)
|
|
3286
|
+
continue;
|
|
3287
|
+
const percent = budgetPercentOf(figures);
|
|
3288
|
+
rows.push({
|
|
3289
|
+
vendor,
|
|
3290
|
+
label: vendorDisplayName(vendor),
|
|
3291
|
+
figures: `${formatUsd(figures.spentUsd)} / ${formatUsd(figures.budgetUsd)} (${percent}%)`,
|
|
3292
|
+
// The same clamp the context indicator applies to its bar. No lower
|
|
3293
|
+
// clamp needed: the boundary refuses negative figures.
|
|
3294
|
+
barValue: Math.min(100, percent),
|
|
3295
|
+
});
|
|
3296
|
+
}
|
|
3297
|
+
return rows;
|
|
3298
|
+
}
|
|
3119
3299
|
/** Whether the settings modal UI Builder section should render. */
|
|
3120
3300
|
get settingsAppSectionVisible() {
|
|
3121
3301
|
return this.settingsAppSlotted.length > 0 || this.visibleAppSettingsToggles.length > 0;
|
|
@@ -4290,6 +4470,12 @@ __decorate([
|
|
|
4290
4470
|
__decorate([
|
|
4291
4471
|
volatile
|
|
4292
4472
|
], FoundationAiAssistant.prototype, "settingsContextUsageVisible", null);
|
|
4473
|
+
__decorate([
|
|
4474
|
+
volatile
|
|
4475
|
+
], FoundationAiAssistant.prototype, "settingsBudgetUsageVisible", null);
|
|
4476
|
+
__decorate([
|
|
4477
|
+
volatile
|
|
4478
|
+
], FoundationAiAssistant.prototype, "settingsBudgetRows", null);
|
|
4293
4479
|
__decorate([
|
|
4294
4480
|
volatile
|
|
4295
4481
|
], FoundationAiAssistant.prototype, "settingsAppSectionVisible", null);
|
|
@@ -34,6 +34,7 @@ export function createDefaultSessionState() {
|
|
|
34
34
|
blockedVendors: [],
|
|
35
35
|
sweptVendors: [],
|
|
36
36
|
blockedVendorReasons: {},
|
|
37
|
+
vendorBudgets: {},
|
|
37
38
|
inputValue: '',
|
|
38
39
|
liveSubAgentTrace: [],
|
|
39
40
|
liveSubAgentName: null,
|
|
@@ -203,6 +204,36 @@ export const aiAssistantSlice = createSlice({
|
|
|
203
204
|
delete state.blockedVendorReasons[vendor];
|
|
204
205
|
}
|
|
205
206
|
},
|
|
207
|
+
/**
|
|
208
|
+
* Store (or clear, with `null`) one vendor's budget figures for the meter.
|
|
209
|
+
*
|
|
210
|
+
* Validation lives at the element boundary (`setVendorBudget` on the
|
|
211
|
+
* element), not here — the reducer is the storage layer and trusts its one
|
|
212
|
+
* caller, exactly as `setVendorBlocked` above trusts its.
|
|
213
|
+
*
|
|
214
|
+
* Idempotent by VALUE, not just by effect: re-feeding the figures a vendor
|
|
215
|
+
* already holds leaves the stored object untouched. The documented feed
|
|
216
|
+
* re-runs after every turn, so without this every turn would publish a new
|
|
217
|
+
* store reference for the same dollars and re-render a meter that has not
|
|
218
|
+
* changed.
|
|
219
|
+
*/
|
|
220
|
+
setVendorBudget(state, action) {
|
|
221
|
+
const { vendor, figures } = action.payload;
|
|
222
|
+
if (figures === null) {
|
|
223
|
+
delete state.vendorBudgets[vendor];
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const existing = state.vendorBudgets[vendor];
|
|
227
|
+
if (existing &&
|
|
228
|
+
existing.budgetUsd === figures.budgetUsd &&
|
|
229
|
+
existing.spentUsd === figures.spentUsd) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// A fresh object rather than the caller's: the store may freeze what it
|
|
233
|
+
// holds, and adopting the host's object would freeze state the host still
|
|
234
|
+
// owns (and let later host mutations bypass the store).
|
|
235
|
+
state.vendorBudgets[vendor] = { budgetUsd: figures.budgetUsd, spentUsd: figures.spentUsd };
|
|
236
|
+
},
|
|
206
237
|
setInputValue(state, action) {
|
|
207
238
|
state.inputValue = action.payload;
|
|
208
239
|
},
|
|
@@ -298,6 +329,13 @@ export const aiAssistantSlice = createSlice({
|
|
|
298
329
|
blockedVendors: state.blockedVendors,
|
|
299
330
|
sweptVendors: state.sweptVendors,
|
|
300
331
|
blockedVendorReasons: state.blockedVendorReasons,
|
|
332
|
+
// Category 2, beside the blocked latch: the budget figures are a fact
|
|
333
|
+
// about the USER'S DEPLOYMENT (what the server meters), not about this
|
|
334
|
+
// conversation — "Clear" refills no budget, so wiping them would blank
|
|
335
|
+
// the meter until the host's next re-feed while the dollars are
|
|
336
|
+
// unchanged. Opposite side from `contextTokens`, which IS conversation
|
|
337
|
+
// state (a new chat genuinely has an empty context window).
|
|
338
|
+
vendorBudgets: state.vendorBudgets,
|
|
301
339
|
};
|
|
302
340
|
Object.assign(state, createDefaultSessionState(), preservedAcrossReset);
|
|
303
341
|
},
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE place this package renders a USD amount as text.
|
|
3
|
+
*
|
|
4
|
+
* Extracted (GENC-1464 budget meter) from the ad-hoc `.toFixed(2)` call sites
|
|
5
|
+
* that had grown in `formatBlockedReason` and the settings modal's cost blocks,
|
|
6
|
+
* so the blocked banner, the session-cost figures and the budget meter cannot
|
|
7
|
+
* drift to different precisions. Two decimals always — money columns jitter
|
|
8
|
+
* when "$5.1" sits above "$5.10".
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
/** Decimal places for every USD figure this package renders. */
|
|
13
|
+
export const USD_DECIMALS = 2;
|
|
14
|
+
/**
|
|
15
|
+
* The bare amount, no currency sign — `(12.3) => "12.30"`.
|
|
16
|
+
*
|
|
17
|
+
* Exists for the settings modal's cost blocks, where the `$` is a separately
|
|
18
|
+
* styled `<span class="prefix">` and folding it into the text would change the
|
|
19
|
+
* DOM those styles target.
|
|
20
|
+
*/
|
|
21
|
+
export const formatUsdAmount = (value) => value.toFixed(USD_DECIMALS);
|
|
22
|
+
/** A signed USD figure — `(12.3) => "$12.30"`. */
|
|
23
|
+
export const formatUsd = (value) => `$${formatUsdAmount(value)}`;
|