@genesislcap/ai-assistant 15.15.2 → 15.16.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 +271 -2
- package/dist/ai-assistant.d.ts +90 -1
- package/dist/chat-driver.cjs +72 -0
- package/dist/chat-driver.cjs.map +2 -2
- package/dist/chat-driver.mjs +72 -0
- package/dist/chat-driver.mjs.map +2 -2
- package/dist/custom-elements.json +221 -97
- package/dist/dts/components/ai-driver/ai-driver.d.ts +11 -0
- package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +31 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.d.ts +10 -0
- package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.d.ts.map +1 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.cost.test.d.ts +2 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.cost.test.d.ts.map +1 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +3 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
- package/dist/dts/index.d.ts +1 -1
- package/dist/dts/index.d.ts.map +1 -1
- package/dist/dts/main/file-attachments.test.d.ts +2 -0
- package/dist/dts/main/file-attachments.test.d.ts.map +1 -0
- package/dist/dts/main/interaction-cost.test.d.ts +2 -0
- package/dist/dts/main/interaction-cost.test.d.ts.map +1 -0
- package/dist/dts/main/main.d.ts +27 -0
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/main/main.template.d.ts.map +1 -1
- package/dist/dts/react.d.ts +2 -1
- package/dist/dts/state/debug-event-log.d.ts +1 -1
- package/dist/dts/state/debug-event-log.d.ts.map +1 -1
- package/dist/dts/types/ai-chat-widget.d.ts +8 -0
- package/dist/dts/types/ai-chat-widget.d.ts.map +1 -1
- package/dist/esm/components/chat-driver/chat-driver.js +75 -1
- package/dist/esm/components/chat-driver/chat-driver.test.js +90 -0
- package/dist/esm/components/chat-interaction-wrapper/chat-interaction-wrapper.js +22 -0
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.cost.test.js +76 -0
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +14 -0
- package/dist/esm/main/file-attachments.test.js +164 -0
- package/dist/esm/main/interaction-cost.test.js +107 -0
- package/dist/esm/main/main.js +193 -21
- package/dist/esm/main/main.template.js +1 -0
- package/dist/esm/state/debug-event-log.js +6 -0
- package/dist/react.cjs +6 -1
- package/dist/react.mjs +6 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +17 -17
- package/src/components/ai-driver/ai-driver.ts +11 -0
- package/src/components/chat-driver/chat-driver.test.ts +109 -0
- package/src/components/chat-driver/chat-driver.ts +78 -1
- package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts +29 -0
- package/src/components/orchestrating-driver/orchestrating-driver.cost.test.ts +107 -0
- package/src/components/orchestrating-driver/orchestrating-driver.ts +17 -0
- package/src/index.ts +4 -1
- package/src/main/file-attachments.test.ts +215 -0
- package/src/main/interaction-cost.test.ts +140 -0
- package/src/main/main.template.ts +1 -0
- package/src/main/main.ts +204 -22
- package/src/state/debug-event-log.ts +8 -0
- package/src/types/ai-chat-widget.ts +8 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { __awaiter } from "tslib";
|
|
2
|
+
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
3
|
+
// Side-effect import — MUST precede `./orchestrating-driver` so the driver
|
|
4
|
+
// subclasses jsdom's EventTarget (CustomEvent dispatch then works in node).
|
|
5
|
+
import '../chat-driver/align-event-globals';
|
|
6
|
+
import { OrchestratingDriver } from './orchestrating-driver';
|
|
7
|
+
// GENC-1508 — `recordExternalCost` is OPTIONAL on `AiDriver`, so a missing delegation here
|
|
8
|
+
// is not a compile error: the host's `driver?.recordExternalCost?.(…)` simply evaluates to
|
|
9
|
+
// undefined and every post-resolve charge from every widget disappears — silently, and only
|
|
10
|
+
// in orchestrated setups. That is the same invisible-when-broken failure the method exists to
|
|
11
|
+
// fix, so the delegation gets a test rather than trust.
|
|
12
|
+
const makeRegistry = (provider) => ({
|
|
13
|
+
get: () => provider,
|
|
14
|
+
default: () => provider,
|
|
15
|
+
defaultName: () => 'test',
|
|
16
|
+
names: () => ['test'],
|
|
17
|
+
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
|
|
18
|
+
listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
|
|
19
|
+
});
|
|
20
|
+
const noToolProvider = () => ({
|
|
21
|
+
chat: (_history, _userMessage, _options) => __awaiter(void 0, void 0, void 0, function* () { return ({ role: 'assistant', content: 'ok' }); }),
|
|
22
|
+
});
|
|
23
|
+
const agent = (overrides) => (Object.assign({ description: 'test agent' }, overrides));
|
|
24
|
+
const makeDriver = () => new OrchestratingDriver(makeRegistry(noToolProvider()), [agent({ name: 'Genesis Assistant' })], {
|
|
25
|
+
sessionKey: 'k',
|
|
26
|
+
});
|
|
27
|
+
/** The orchestrator does not expose `requestInteraction`, so the interaction is ARRANGED on the
|
|
28
|
+
* wrapped driver. Every assertion below goes through the orchestrator's own public surface. */
|
|
29
|
+
const wrapped = (driver) => driver.chatDriver;
|
|
30
|
+
const Suite = createLogicSuite('orchestrating-driver external cost');
|
|
31
|
+
Suite('post-resolve spend delegates to the wrapped driver', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
32
|
+
const driver = makeDriver();
|
|
33
|
+
const inner = wrapped(driver);
|
|
34
|
+
const pending = inner.requestInteraction('planning-question', { question: 'Pick one' });
|
|
35
|
+
const id = driver.getRawHistory().at(-1).interaction.interactionId;
|
|
36
|
+
driver.resolveInteraction(id, { status: 'approved', costUsd: 0.5 });
|
|
37
|
+
yield pending;
|
|
38
|
+
assert.is(driver.recordExternalCost(id, 0.25), true);
|
|
39
|
+
const msg = driver.getRawHistory().find((m) => { var _a; return ((_a = m.interaction) === null || _a === void 0 ? void 0 : _a.interactionId) === id; });
|
|
40
|
+
assert.is(msg === null || msg === void 0 ? void 0 : msg.externalCostUsd, 0.75);
|
|
41
|
+
}));
|
|
42
|
+
Suite('an unknown interaction is reported as unrecorded, not thrown', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
43
|
+
const driver = makeDriver();
|
|
44
|
+
assert.is(driver.recordExternalCost('never-existed', 0.25), false);
|
|
45
|
+
}));
|
|
46
|
+
Suite('the announcement reaches the HOST, which only ever holds this wrapper', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
47
|
+
// The delegation test above passes with the forward missing: the money still lands on the
|
|
48
|
+
// message. What breaks is quieter — `createDriver` hands the host an OrchestratingDriver for any
|
|
49
|
+
// non-empty `agents`, so an event the inner driver raises and the constructor's forward list
|
|
50
|
+
// omits is one the host never sees. The totals then never refresh and the save is never
|
|
51
|
+
// scheduled, and because this flow's interaction is the last thing in the session, "stale until
|
|
52
|
+
// the next history change" means permanently. Asserted on the WRAPPER for that reason.
|
|
53
|
+
const driver = makeDriver();
|
|
54
|
+
const inner = wrapped(driver);
|
|
55
|
+
const pending = inner.requestInteraction('planning-question', { question: 'Pick one' });
|
|
56
|
+
const id = driver.getRawHistory().at(-1).interaction.interactionId;
|
|
57
|
+
driver.resolveInteraction(id, { status: 'approved' });
|
|
58
|
+
yield pending;
|
|
59
|
+
let seen = 0;
|
|
60
|
+
let detail;
|
|
61
|
+
driver.addEventListener('external-cost-recorded', (e) => {
|
|
62
|
+
seen += 1;
|
|
63
|
+
detail = e.detail;
|
|
64
|
+
});
|
|
65
|
+
// …and never on `history-updated`, which would rebuild the reporting widget.
|
|
66
|
+
let historyUpdated = 0;
|
|
67
|
+
driver.addEventListener('history-updated', () => {
|
|
68
|
+
historyUpdated += 1;
|
|
69
|
+
});
|
|
70
|
+
driver.recordExternalCost(id, 0.25);
|
|
71
|
+
assert.is(seen, 1);
|
|
72
|
+
assert.is(detail === null || detail === void 0 ? void 0 : detail.costUsd, 0.25);
|
|
73
|
+
assert.is(detail === null || detail === void 0 ? void 0 : detail.interactionId, id);
|
|
74
|
+
assert.is(historyUpdated, 0);
|
|
75
|
+
}));
|
|
76
|
+
Suite.run();
|
|
@@ -151,10 +151,24 @@ export class OrchestratingDriver extends EventTarget {
|
|
|
151
151
|
this.chatDriver.addEventListener('provider-changed', (e) => {
|
|
152
152
|
this.dispatchEvent(new CustomEvent('provider-changed', { detail: e.detail }));
|
|
153
153
|
});
|
|
154
|
+
// Post-resolve external cost. The host only ever holds THIS wrapper (`createDriver` returns
|
|
155
|
+
// one for any non-empty `agents`), so an event the inner driver raises and this list omits is
|
|
156
|
+
// an event the host never sees: `recordExternalCost` still delegates and the money still lands
|
|
157
|
+
// on the message, but the totals never refresh and the save is never scheduled — and in the
|
|
158
|
+
// flow this exists for the interaction is the last thing in the session, so "stale until the
|
|
159
|
+
// next history change" means permanently. Reviewer catch on #2474.
|
|
160
|
+
this.chatDriver.addEventListener('external-cost-recorded', (e) => {
|
|
161
|
+
this.dispatchEvent(new CustomEvent('external-cost-recorded', { detail: e.detail }));
|
|
162
|
+
});
|
|
154
163
|
}
|
|
155
164
|
resolveInteraction(interactionId, result) {
|
|
156
165
|
this.chatDriver.resolveInteraction(interactionId, result);
|
|
157
166
|
}
|
|
167
|
+
/** Delegated for the same reason as `resolveInteraction`: the history that owns the
|
|
168
|
+
* interaction — and therefore the cost — lives on the wrapped driver, not here. */
|
|
169
|
+
recordExternalCost(interactionId, costUsd) {
|
|
170
|
+
return this.chatDriver.recordExternalCost(interactionId, costUsd);
|
|
171
|
+
}
|
|
158
172
|
getInteractionContext(interactionId) {
|
|
159
173
|
return this.chatDriver.getInteractionContext(interactionId);
|
|
160
174
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { __awaiter } from "tslib";
|
|
2
|
+
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
3
|
+
import { FoundationAiAssistant } from './main';
|
|
4
|
+
// Hold a reference so the custom-element registration isn't tree-shaken.
|
|
5
|
+
FoundationAiAssistant;
|
|
6
|
+
// GENC-1508 — image files become IMAGE attachments, not text.
|
|
7
|
+
//
|
|
8
|
+
// `processFiles` read every accepted file with `readAsText`, so an accepted .png
|
|
9
|
+
// arrived as mojibake the model "read" as prose — an accepted image was worse than a
|
|
10
|
+
// rejected one. The layout-apply flow attaches the picked mockup's PNG beside its
|
|
11
|
+
// spec, which is what forced the branch: images now read as base64 and ship as
|
|
12
|
+
// `{kind:'image'}`, the shape the transports render as real image blocks.
|
|
13
|
+
//
|
|
14
|
+
// Unconnected elements (createElement upgrades but does not connect), same as
|
|
15
|
+
// blocked-state.test.ts — processFiles touches no session state.
|
|
16
|
+
const Suite = createLogicSuite('FoundationAiAssistant file attachments');
|
|
17
|
+
function element(acceptedFiles) {
|
|
18
|
+
const el = document.createElement('foundation-ai-assistant');
|
|
19
|
+
el.chatConfig = { ui: { acceptedFiles } };
|
|
20
|
+
return el;
|
|
21
|
+
}
|
|
22
|
+
/** A tiny valid PNG header is unnecessary — processFiles never parses pixels. */
|
|
23
|
+
const PNG_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
24
|
+
Suite('an accepted image file becomes a kind:image attachment with raw base64', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
25
|
+
const el = element('.md,image/*');
|
|
26
|
+
const { attachments, errors } = yield el.processFiles([
|
|
27
|
+
new File([PNG_BYTES], 'layout-mockup.png', { type: 'image/png' }),
|
|
28
|
+
]);
|
|
29
|
+
assert.equal(errors, []);
|
|
30
|
+
const [img] = attachments;
|
|
31
|
+
assert.is(img.kind, 'image');
|
|
32
|
+
assert.is(img.name, 'layout-mockup.png');
|
|
33
|
+
assert.is(img.mimeType, 'image/png');
|
|
34
|
+
// RAW base64 — a `data:` prefix left on would be sent to the vendor inside the payload.
|
|
35
|
+
assert.ok(img.data && !img.data.startsWith('data:'), 'data must be bare base64');
|
|
36
|
+
assert.is(atob(img.data).length, PNG_BYTES.length);
|
|
37
|
+
}));
|
|
38
|
+
Suite('a text file on the same submit stays a text attachment', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
39
|
+
const el = element('.md,image/*');
|
|
40
|
+
const { attachments } = yield el.processFiles([
|
|
41
|
+
new File(['# spec'], 'layout-spec.md', { type: 'text/markdown' }),
|
|
42
|
+
new File([PNG_BYTES], 'layout-mockup.png', { type: 'image/png' }),
|
|
43
|
+
]);
|
|
44
|
+
const [text, img] = attachments;
|
|
45
|
+
assert.is(text.kind, undefined);
|
|
46
|
+
assert.is(text.content, '# spec');
|
|
47
|
+
assert.is(img.kind, 'image');
|
|
48
|
+
}));
|
|
49
|
+
Suite('an image type the host did not accept is still refused', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
50
|
+
const el = element('.md,.txt');
|
|
51
|
+
const { attachments, errors } = yield el.processFiles([
|
|
52
|
+
new File([PNG_BYTES], 'layout-mockup.png', { type: 'image/png' }),
|
|
53
|
+
]);
|
|
54
|
+
assert.equal(attachments, []);
|
|
55
|
+
assert.is(errors.length, 1);
|
|
56
|
+
assert.match(errors[0], /not an accepted file type/);
|
|
57
|
+
}));
|
|
58
|
+
// A ~70KB image: the base64 loop's chunk size is 32768, so this crosses the boundary twice.
|
|
59
|
+
// The 8-byte fixture above executes the loop body exactly once, which would not notice an
|
|
60
|
+
// off-by-one in the subarray bounds.
|
|
61
|
+
Suite('a multi-chunk image round-trips byte-for-byte across the base64 loop', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
62
|
+
const big = new Uint8Array(70000);
|
|
63
|
+
// A non-trivial pattern — an all-zero buffer survives most encoding mistakes.
|
|
64
|
+
for (let i = 0; i < big.length; i += 1)
|
|
65
|
+
big[i] = (i * 31 + (i >> 8)) & 0xff;
|
|
66
|
+
const el = element('image/*');
|
|
67
|
+
const { attachments, errors } = yield el.processFiles([
|
|
68
|
+
new File([big], 'wide.png', { type: 'image/png' }),
|
|
69
|
+
]);
|
|
70
|
+
assert.equal(errors, []);
|
|
71
|
+
const [img] = attachments;
|
|
72
|
+
const back = atob(img.data);
|
|
73
|
+
assert.is(back.length, big.length);
|
|
74
|
+
let mismatch = -1;
|
|
75
|
+
for (let i = 0; i < big.length; i += 1) {
|
|
76
|
+
if (back.charCodeAt(i) !== big[i]) {
|
|
77
|
+
mismatch = i;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
assert.is(mismatch, -1, 'every byte must survive the chunked encode');
|
|
82
|
+
}));
|
|
83
|
+
Suite('an image over the size cap is refused by NAME and size, not sent to the vendor', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
84
|
+
// Refused here rather than at the API: a vendor 400 lands after the user has waited out the
|
|
85
|
+
// turn, and the encoded blob is persisted meanwhile — one oversized image can blow the storage
|
|
86
|
+
// quota and silently stop the session autosaving.
|
|
87
|
+
const el = element('image/*');
|
|
88
|
+
const { attachments, errors } = yield el.processFiles([
|
|
89
|
+
new File([new Uint8Array(4000000)], 'huge.png', { type: 'image/png' }),
|
|
90
|
+
]);
|
|
91
|
+
assert.equal(attachments, []);
|
|
92
|
+
assert.is(errors.length, 1);
|
|
93
|
+
assert.match(errors[0], /huge\.png/);
|
|
94
|
+
assert.match(errors[0], /limited to/);
|
|
95
|
+
}));
|
|
96
|
+
Suite('an image type the vendors do not accept falls back to TEXT, never a failed turn', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
97
|
+
// `image/*` covers HEIC, BMP, TIFF, SVG — none of which the APIs take, and sending one fails
|
|
98
|
+
// the whole turn. SVG especially: it IS text, so the text branch gives the model something it
|
|
99
|
+
// can actually read.
|
|
100
|
+
const el = element('image/*');
|
|
101
|
+
const { attachments, errors } = yield el.processFiles([
|
|
102
|
+
new File(['<svg xmlns="http://www.w3.org/2000/svg"><rect /></svg>'], 'icon.svg', {
|
|
103
|
+
type: 'image/svg+xml',
|
|
104
|
+
}),
|
|
105
|
+
new File([new Uint8Array([0, 1, 2, 3])], 'photo.heic', { type: 'image/heic' }),
|
|
106
|
+
]);
|
|
107
|
+
assert.equal(errors, []);
|
|
108
|
+
const [svg, heic] = attachments;
|
|
109
|
+
assert.is(svg.kind, undefined, 'SVG stays text — a model can read it');
|
|
110
|
+
assert.match(svg.content, /<rect/);
|
|
111
|
+
assert.is(heic.kind, undefined, 'an unsupported binary degrades rather than failing the turn');
|
|
112
|
+
}));
|
|
113
|
+
Suite('every supported spelling takes the image branch, including image/jpg', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
114
|
+
const el = element('image/*');
|
|
115
|
+
const { attachments } = yield el.processFiles(['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'].map((type, i) => new File([PNG_BYTES], `f${i}`, { type })));
|
|
116
|
+
assert.equal(attachments.map((a) => a.kind), ['image', 'image', 'image', 'image', 'image']);
|
|
117
|
+
}));
|
|
118
|
+
// The TOTAL, not just each file. `loadSelectedFiles` appends across successive picks and the
|
|
119
|
+
// per-file check is blind to what is already attached, so ten files each just under the per-image
|
|
120
|
+
// ceiling used to be ten passes and one ~34MB request — assembled entirely out of files that each
|
|
121
|
+
// passed. Reviewer catch on #2474.
|
|
122
|
+
Suite('the file that crosses the total is refused; the ones that fit are kept', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
123
|
+
const el = element('image/*');
|
|
124
|
+
const mb = (n) => new Uint8Array(n * 1000000);
|
|
125
|
+
const { attachments, errors } = yield el.processFiles([
|
|
126
|
+
new File([mb(3)], 'a.png', { type: 'image/png' }),
|
|
127
|
+
new File([mb(3)], 'b.png', { type: 'image/png' }),
|
|
128
|
+
new File([mb(3)], 'c.png', { type: 'image/png' }), // 9MB total — past the 8MB ceiling
|
|
129
|
+
]);
|
|
130
|
+
assert.is(attachments.length, 2, 'the two that fit are accepted');
|
|
131
|
+
assert.is(errors.length, 1);
|
|
132
|
+
assert.match(errors[0], /c\.png/);
|
|
133
|
+
assert.match(errors[0], /attachment limit/);
|
|
134
|
+
}));
|
|
135
|
+
Suite('the budget counts what is ALREADY attached, not just this pick', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
136
|
+
// The picker's second pick has to see the first one's bytes, or "under the limit" is measured
|
|
137
|
+
// against zero every time and the limit means nothing.
|
|
138
|
+
const el = element('image/*');
|
|
139
|
+
const fresh = yield el.processFiles([
|
|
140
|
+
new File([new Uint8Array(3000000)], 'a.png', { type: 'image/png' }),
|
|
141
|
+
]);
|
|
142
|
+
assert.is(fresh.attachments.length, 1, 'fits on its own');
|
|
143
|
+
const withHistory = yield el.processFiles([new File([new Uint8Array(3000000)], 'b.png', { type: 'image/png' })], 6000000);
|
|
144
|
+
assert.is(withHistory.attachments.length, 0, 'the same file is refused once 6MB is already committed');
|
|
145
|
+
assert.match(withHistory.errors[0], /6\.0MB already attached/);
|
|
146
|
+
}));
|
|
147
|
+
Suite('a refusal skips only the offending file — a later one that still fits is kept', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
148
|
+
// Budgeted synchronously off `file.size`, in the user's own pick order, before anything is read
|
|
149
|
+
// (deciding inside the parallel reads would hand the last slot to whichever finished first).
|
|
150
|
+
// A file that does not fit is refused and the budget carries on, so a smaller one behind it is
|
|
151
|
+
// not punished for its neighbour.
|
|
152
|
+
const el = element('image/*');
|
|
153
|
+
const mb = (n) => new Uint8Array(n * 1000000);
|
|
154
|
+
const { attachments, errors } = yield el.processFiles([
|
|
155
|
+
new File([mb(3)], 'a.png', { type: 'image/png' }), // running 3
|
|
156
|
+
new File([mb(3)], 'b.png', { type: 'image/png' }), // running 6
|
|
157
|
+
new File([mb(3)], 'big.png', { type: 'image/png' }), // 9 > 8 — refused
|
|
158
|
+
new File([mb(1)], 'small.png', { type: 'image/png' }), // 7 — still fits
|
|
159
|
+
]);
|
|
160
|
+
assert.equal(attachments.map((a) => a.name), ['a.png', 'b.png', 'small.png']);
|
|
161
|
+
assert.is(errors.length, 1);
|
|
162
|
+
assert.match(errors[0], /big\.png/);
|
|
163
|
+
}));
|
|
164
|
+
Suite.run();
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
2
|
+
import { getSessionStore } from '../state/session-store';
|
|
3
|
+
import { FoundationAiAssistant } from './main';
|
|
4
|
+
// Hold a reference so the custom-element registration isn't tree-shaken.
|
|
5
|
+
FoundationAiAssistant;
|
|
6
|
+
// GENC-1508 — the host hop of the post-resolve cost channel.
|
|
7
|
+
//
|
|
8
|
+
// The driver method and the orchestrator delegation both have tests; this pins the hop
|
|
9
|
+
// between them, which is the one most likely to break silently: a rename on either side of
|
|
10
|
+
// `handleInteractionCost` is a no-op that every other test still passes, and the "no spinner"
|
|
11
|
+
// guarantee is a design promise stated only in a comment.
|
|
12
|
+
//
|
|
13
|
+
// Unconnected element (createElement upgrades but does not connect), same as
|
|
14
|
+
// blocked-state.test.ts — this handler touches no session state.
|
|
15
|
+
const Suite = createLogicSuite('FoundationAiAssistant interaction cost');
|
|
16
|
+
function host() {
|
|
17
|
+
const el = document.createElement('foundation-ai-assistant');
|
|
18
|
+
const calls = [];
|
|
19
|
+
const state = { spinners: 0 };
|
|
20
|
+
el.driver = {
|
|
21
|
+
recordExternalCost: (id, cost) => {
|
|
22
|
+
calls.push([id, cost]);
|
|
23
|
+
return true;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
el.startLoadingTimer = () => {
|
|
27
|
+
state.spinners += 1;
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
el,
|
|
31
|
+
calls,
|
|
32
|
+
get spinners() {
|
|
33
|
+
return state.spinners;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const costEvent = (detail) => new CustomEvent('interaction-cost', { detail });
|
|
38
|
+
Suite('a cost report reaches the driver', () => {
|
|
39
|
+
const h = host();
|
|
40
|
+
h.el.handleInteractionCost(costEvent({ interactionId: 'abc', costUsd: 0.42 }));
|
|
41
|
+
assert.equal(h.calls, [['abc', 0.42]]);
|
|
42
|
+
});
|
|
43
|
+
Suite('reporting cost NEVER starts the loading spinner', () => {
|
|
44
|
+
// The whole point of the channel: this is bookkeeping about a turn that already finished, so
|
|
45
|
+
// it must be invisible. A spinner would tell the user the assistant is working when nothing
|
|
46
|
+
// is pending — the one thing `handleInteractionCompleted` does that this must not copy.
|
|
47
|
+
const h = host();
|
|
48
|
+
h.el.handleInteractionCost(costEvent({ interactionId: 'abc', costUsd: 0.42 }));
|
|
49
|
+
assert.is(h.spinners, 0);
|
|
50
|
+
});
|
|
51
|
+
Suite('an event with no interactionId is ignored', () => {
|
|
52
|
+
const h = host();
|
|
53
|
+
h.el.handleInteractionCost(costEvent({ costUsd: 0.42 }));
|
|
54
|
+
h.el.handleInteractionCost(costEvent(undefined));
|
|
55
|
+
assert.equal(h.calls, []);
|
|
56
|
+
});
|
|
57
|
+
Suite('a driver that does not implement the optional method is not called into', () => {
|
|
58
|
+
// `recordExternalCost` is optional on `AiDriver`, so a driver predating this — or one that
|
|
59
|
+
// simply does not track spend — must be ignored rather than throw.
|
|
60
|
+
const h = host();
|
|
61
|
+
h.el.driver = {};
|
|
62
|
+
assert.not.throws(() => h.el.handleInteractionCost(costEvent({ interactionId: 'a', costUsd: 1 })));
|
|
63
|
+
h.el.driver = undefined;
|
|
64
|
+
assert.not.throws(() => h.el.handleInteractionCost(costEvent({ interactionId: 'a', costUsd: 1 })));
|
|
65
|
+
});
|
|
66
|
+
Suite('a driver without the method leaves a TRACE, not a silent no-op', () => {
|
|
67
|
+
// Reviewer catch on #2474: every other hop traces its drops. A driver predating
|
|
68
|
+
// `recordExternalCost` made this one a silent no-op — money gone, nothing recorded — which is
|
|
69
|
+
// the invisible gap the whole channel exists to close, reintroduced at the last hop.
|
|
70
|
+
const h = host();
|
|
71
|
+
const meta = [];
|
|
72
|
+
h.el.logMeta = (t, d) => {
|
|
73
|
+
meta.push([t, d]);
|
|
74
|
+
};
|
|
75
|
+
h.el.driver = {};
|
|
76
|
+
h.el.handleInteractionCost(costEvent({ interactionId: 'abc', costUsd: 0.42 }));
|
|
77
|
+
assert.is(meta.length, 1);
|
|
78
|
+
assert.is(meta[0][0], 'interaction.external-cost-dropped');
|
|
79
|
+
assert.is(meta[0][1].costUsd, 0.42);
|
|
80
|
+
assert.is(meta[0][1].interactionId, 'abc');
|
|
81
|
+
});
|
|
82
|
+
// The OUTBOUND half: the driver announced, and the host has to turn that into a moved number.
|
|
83
|
+
// Tested because it is the same shape of hop as the one that already went wrong — the wrapper
|
|
84
|
+
// forward that was missing — and because it deliberately reads the DRIVER's history rather than
|
|
85
|
+
// `this.messages`, which is the part a reader would most likely "simplify" back.
|
|
86
|
+
let storeSeq = 0;
|
|
87
|
+
/** An element wired to its own real session store, so `sessionUsage` round-trips. */
|
|
88
|
+
function costHost() {
|
|
89
|
+
const el = document.createElement('foundation-ai-assistant');
|
|
90
|
+
storeSeq += 1;
|
|
91
|
+
el._sessionRef = getSessionStore(`cost-test-${(storeSeq += 1)}`, false);
|
|
92
|
+
return el;
|
|
93
|
+
}
|
|
94
|
+
Suite('the recorded spend reaches the session total from the DRIVER history', () => {
|
|
95
|
+
const el = costHost();
|
|
96
|
+
const sync = el.syncUsageTotals.bind(el);
|
|
97
|
+
sync([{ role: 'assistant', content: 'a', cost: 0.1, model: 'claude-sonnet-5' }]);
|
|
98
|
+
assert.is(el.sessionCostUsd, 0.1);
|
|
99
|
+
// The post-resolve delta lands on the interaction message as `externalCostUsd`; the total has
|
|
100
|
+
// to fold it in, or every hop before this one was wasted.
|
|
101
|
+
sync([
|
|
102
|
+
{ role: 'assistant', content: 'a', cost: 0.1, model: 'claude-sonnet-5' },
|
|
103
|
+
{ role: 'assistant', content: 'b', externalCostUsd: 0.25 },
|
|
104
|
+
]);
|
|
105
|
+
assert.is(el.sessionCostUsd, 0.35);
|
|
106
|
+
});
|
|
107
|
+
Suite.run();
|
package/dist/esm/main/main.js
CHANGED
|
@@ -107,6 +107,62 @@ const SESSION_MENU_CLOSE_MS = 200;
|
|
|
107
107
|
* assistant is running — the Usage tab renders the in-memory list — so the delay is invisible.
|
|
108
108
|
*/
|
|
109
109
|
const LEDGER_FLUSH_DEBOUNCE_MS = 2000;
|
|
110
|
+
/**
|
|
111
|
+
* The image types BOTH vendors accept, and therefore the only ones sent as image blocks.
|
|
112
|
+
*
|
|
113
|
+
* A host's `acceptedFiles` may well say `image/*`, which covers HEIC, BMP, TIFF, AVIF and SVG —
|
|
114
|
+
* none of which the APIs take. Sending one fails the whole turn the user is waiting on, so
|
|
115
|
+
* anything outside this set falls through to the text branch instead: harmless for a binary, and
|
|
116
|
+
* genuinely useful for SVG, which is text a model can read.
|
|
117
|
+
*
|
|
118
|
+
* `image/jpg` is absent on purpose — `normalizeImageMime` rewrites it to `image/jpeg` at the
|
|
119
|
+
* transport, but a file arriving with that spelling is still a real JPEG, so it is accepted here.
|
|
120
|
+
*/
|
|
121
|
+
const VENDOR_IMAGE_MIME_TYPES = new Set([
|
|
122
|
+
'image/jpeg',
|
|
123
|
+
'image/jpg',
|
|
124
|
+
'image/png',
|
|
125
|
+
'image/gif',
|
|
126
|
+
'image/webp',
|
|
127
|
+
]);
|
|
128
|
+
/**
|
|
129
|
+
* Ceiling on ONE image attachment, under Anthropic's ~5MB-per-image limit with room for the ~33%
|
|
130
|
+
* base64 overhead. Refused here, with the size named, rather than at the vendor: a 400 arrives
|
|
131
|
+
* only after the user has waited out the turn.
|
|
132
|
+
*
|
|
133
|
+
* A VENDOR guard, and only that. An earlier version of this comment also claimed it protected the
|
|
134
|
+
* session's storage quota — it does not, and the arithmetic says so: one file at this ceiling is
|
|
135
|
+
* ~4.7MB of base64, which alone can exceed a 5MB `localStorage` budget before the transcript is
|
|
136
|
+
* counted. Attachments riding into persisted storage is a real and separate problem; it is not
|
|
137
|
+
* this constant's to solve, and saying otherwise hid it.
|
|
138
|
+
*/
|
|
139
|
+
const MAX_IMAGE_ATTACHMENT_BYTES = 3500000;
|
|
140
|
+
/**
|
|
141
|
+
* Ceiling on ALL attachments carried by one message, across every type and every successive pick.
|
|
142
|
+
*
|
|
143
|
+
* The per-file check above is blind to the total: the picker APPENDS (`loadSelectedFiles`), so ten
|
|
144
|
+
* files each just under the per-image limit are ten passes and one ~34MB request. Sized against
|
|
145
|
+
* the vendors' request ceilings — comfortably inside the smaller of them with room for the
|
|
146
|
+
* transcript itself — so a turn that would be rejected wholesale is refused here, where the user
|
|
147
|
+
* can still act on it, naming the file that crossed the line.
|
|
148
|
+
*
|
|
149
|
+
* Like its sibling, this does NOT make attachments safe to persist — 8MB of attachments is still
|
|
150
|
+
* ~10.7MB of base64. What a persisted session should keep of its images is GENC-1531; note the
|
|
151
|
+
* transports also REPLAY history attachments on every later call, so it is a recurring cost as
|
|
152
|
+
* well as a storage one.
|
|
153
|
+
*/
|
|
154
|
+
const MAX_ATTACHMENTS_TOTAL_BYTES = 8000000;
|
|
155
|
+
/** Base64 carries 3 bytes in every 4 characters, so decoded size is 3/4 of the encoded length. */
|
|
156
|
+
const BASE64_DECODED_RATIO = 0.75;
|
|
157
|
+
/** A rough byte size for an already-accepted attachment, for budgeting against the total cap. */
|
|
158
|
+
function attachmentBytes(a) {
|
|
159
|
+
var _a, _b, _c, _d;
|
|
160
|
+
return a.kind === 'image'
|
|
161
|
+
? Math.ceil(((_b = (_a = a.data) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) * BASE64_DECODED_RATIO)
|
|
162
|
+
: ((_d = (_c = a.content) === null || _c === void 0 ? void 0 : _c.length) !== null && _d !== void 0 ? _d : 0);
|
|
163
|
+
}
|
|
164
|
+
/** Bytes per megabyte, for the size the refusal message quotes back to the user. */
|
|
165
|
+
const BYTES_PER_MB = 1000000;
|
|
110
166
|
/**
|
|
111
167
|
* Reporter id for this element's OWN session persister in `brokenSources` (GENC-1511).
|
|
112
168
|
*
|
|
@@ -2432,6 +2488,23 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2432
2488
|
(_b = this.persister()) === null || _b === void 0 ? void 0 : _b.scheduleDiagnostics();
|
|
2433
2489
|
};
|
|
2434
2490
|
driver.addEventListener('history-updated', onHistoryUpdated);
|
|
2491
|
+
// Post-resolve external cost. Deliberately does NOT reassign `messages`: that would rebuild
|
|
2492
|
+
// the trailing interaction row (a `recycle: false` repeat) and blank the widget that just
|
|
2493
|
+
// reported — see `recordExternalCost`. The totals come off the DRIVER's history because the
|
|
2494
|
+
// host's copy holds the pre-update message objects, and the persister already reads the
|
|
2495
|
+
// driver first, so the save picks the field up with no help from here.
|
|
2496
|
+
const onExternalCostRecorded = () => {
|
|
2497
|
+
var _a, _b;
|
|
2498
|
+
if (driver.getRawHistory)
|
|
2499
|
+
this.syncUsageTotals(driver.getRawHistory());
|
|
2500
|
+
(_a = this.persister()) === null || _a === void 0 ? void 0 : _a.scheduleSave();
|
|
2501
|
+
// Diagnostics too, for the same reason `onHistoryUpdated` schedules both: the forward-append
|
|
2502
|
+
// is what carries `interaction.external-cost` (and its `-dropped` sibling) into the persisted
|
|
2503
|
+
// stream. Without it the trace this change adds sits in memory waiting for activity that,
|
|
2504
|
+
// in this flow, never comes — the interaction is the last thing in the session.
|
|
2505
|
+
(_b = this.persister()) === null || _b === void 0 ? void 0 : _b.scheduleDiagnostics();
|
|
2506
|
+
};
|
|
2507
|
+
driver.addEventListener('external-cost-recorded', onExternalCostRecorded);
|
|
2435
2508
|
// Re-seed the rendered list from the driver's current history. `wireDriver`
|
|
2436
2509
|
// runs on (re)connect, popin, and when adopting a sibling's driver
|
|
2437
2510
|
// (GENC-1388); in those cases `messages` would otherwise stay stale until the
|
|
@@ -2510,6 +2583,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2510
2583
|
driver.addEventListener('provider-changed', onProviderChanged);
|
|
2511
2584
|
const cleanups = [
|
|
2512
2585
|
() => driver.removeEventListener('history-updated', onHistoryUpdated),
|
|
2586
|
+
() => driver.removeEventListener('external-cost-recorded', onExternalCostRecorded),
|
|
2513
2587
|
() => driver.removeEventListener('sub-agent-history-updated', onSubAgentHistoryUpdated),
|
|
2514
2588
|
() => driver.removeEventListener('sub-agent-start', onSubAgentStart),
|
|
2515
2589
|
() => driver.removeEventListener('sub-agent-stop', onSubAgentStop),
|
|
@@ -2990,6 +3064,36 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
2990
3064
|
var _a;
|
|
2991
3065
|
this.showingSplash = !!((_a = this.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.showSplash) && this.messages.length === 0;
|
|
2992
3066
|
}
|
|
3067
|
+
/**
|
|
3068
|
+
* Recompute the session's cost and token buckets from a transcript.
|
|
3069
|
+
*
|
|
3070
|
+
* Takes the list rather than reading `this.messages`, because it has two callers with
|
|
3071
|
+
* different sources: the `messages` setter passes the rendered list, and post-resolve external
|
|
3072
|
+
* cost passes the DRIVER's history — spend recorded there must reach the total WITHOUT
|
|
3073
|
+
* reassigning `messages`, which would re-render the live interaction row. See
|
|
3074
|
+
* `onExternalCostRecorded`.
|
|
3075
|
+
*
|
|
3076
|
+
* Recomputing (rather than incrementing on append) keeps the totals correct under any mutation
|
|
3077
|
+
* of the message list — clear-chat, re-render, restore. `sumUsage` recurses into
|
|
3078
|
+
* `toolCall.subAgentTrace`, so work a sub-agent did (possibly on another provider at other
|
|
3079
|
+
* rates) counts, and it reads the usage a compaction banked onto its summary, so shrinking
|
|
3080
|
+
* history no longer shrinks the totals.
|
|
3081
|
+
*/
|
|
3082
|
+
syncUsageTotals(messages) {
|
|
3083
|
+
const running = sumUsage(messages);
|
|
3084
|
+
const current = this.sessionUsage;
|
|
3085
|
+
if (running.costUsd !== current.costUsd ||
|
|
3086
|
+
running.uncachedInputTokens !== current.uncachedInputTokens ||
|
|
3087
|
+
running.cacheReadTokens !== current.cacheReadTokens ||
|
|
3088
|
+
running.cacheWriteTokens !== current.cacheWriteTokens ||
|
|
3089
|
+
running.outputTokens !== current.outputTokens) {
|
|
3090
|
+
this.sessionUsage = running;
|
|
3091
|
+
// Keep this project's history row current as spend accrues, which is what lets
|
|
3092
|
+
// the Usage tab total be the sum of the rows alone. Idempotent (an upsert keyed
|
|
3093
|
+
// by the session key), so calling it per change is safe.
|
|
3094
|
+
this.finalizeCostSession();
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
2993
3097
|
/**
|
|
2994
3098
|
* Runs side effects that were previously in `messagesChanged()`.
|
|
2995
3099
|
* Called from the `messages` setter after dispatching to the store.
|
|
@@ -3028,19 +3132,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
3028
3132
|
// recurses into `toolCall.subAgentTrace`, so work a sub-agent did (possibly on
|
|
3029
3133
|
// another provider at other rates) counts, and it reads the usage a compaction
|
|
3030
3134
|
// banked onto its summary, so shrinking history no longer shrinks the totals.
|
|
3031
|
-
|
|
3032
|
-
const current = this.sessionUsage;
|
|
3033
|
-
if (running.costUsd !== current.costUsd ||
|
|
3034
|
-
running.uncachedInputTokens !== current.uncachedInputTokens ||
|
|
3035
|
-
running.cacheReadTokens !== current.cacheReadTokens ||
|
|
3036
|
-
running.cacheWriteTokens !== current.cacheWriteTokens ||
|
|
3037
|
-
running.outputTokens !== current.outputTokens) {
|
|
3038
|
-
this.sessionUsage = running;
|
|
3039
|
-
// Keep this project's history row current as spend accrues, which is what lets
|
|
3040
|
-
// the Usage tab total be the sum of the rows alone. Idempotent (an upsert keyed
|
|
3041
|
-
// by the session key), so calling it per change is safe.
|
|
3042
|
-
this.finalizeCostSession();
|
|
3043
|
-
}
|
|
3135
|
+
this.syncUsageTotals(this.messages);
|
|
3044
3136
|
// Record a context.updated meta event when the token count changes (≈once
|
|
3045
3137
|
// per LLM call, as a new usage-bearing message arrives), plus a one-shot
|
|
3046
3138
|
// threshold-crossed event the first time usage passes 80%.
|
|
@@ -4271,21 +4363,75 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
4271
4363
|
a === file.type ||
|
|
4272
4364
|
(a.endsWith('/*') && file.type.startsWith(a.replace('/*', '/'))));
|
|
4273
4365
|
}
|
|
4366
|
+
// Blob's own promise APIs, not FileReader: UTF-8 only (a `charset` parameter on the blob's
|
|
4367
|
+
// type is ignored, where FileReader would honour it), and they exist in the node test runtime
|
|
4368
|
+
// too — FileReader does not, so neither reader was testable before.
|
|
4274
4369
|
readFileAsText(file) {
|
|
4275
|
-
return
|
|
4276
|
-
const reader = new FileReader();
|
|
4277
|
-
reader.onload = () => resolve(reader.result);
|
|
4278
|
-
reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`));
|
|
4279
|
-
reader.readAsText(file);
|
|
4280
|
-
});
|
|
4370
|
+
return file.text();
|
|
4281
4371
|
}
|
|
4282
|
-
|
|
4372
|
+
/** The base64 payload of a file, without any `data:` prefix — the raw form
|
|
4373
|
+
* `ChatImageAttachment.data` specifies (transports add their own framing). */
|
|
4374
|
+
readFileAsBase64(file) {
|
|
4283
4375
|
return __awaiter(this, void 0, void 0, function* () {
|
|
4376
|
+
const bytes = new Uint8Array(yield file.arrayBuffer());
|
|
4377
|
+
let binary = '';
|
|
4378
|
+
const CHUNK = 0x8000;
|
|
4379
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
4380
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
4381
|
+
}
|
|
4382
|
+
return btoa(binary);
|
|
4383
|
+
});
|
|
4384
|
+
}
|
|
4385
|
+
processFiles(files_1) {
|
|
4386
|
+
return __awaiter(this, arguments, void 0, function* (files, existingBytes = 0) {
|
|
4387
|
+
// Budget the batch BEFORE reading any of it. `file.size` needs no I/O, so the verdict is
|
|
4388
|
+
// deterministic and in the user's own pick order; deciding inside the parallel map below would
|
|
4389
|
+
// hand the last slot to whichever read happened to finish first. `existingBytes` is what the
|
|
4390
|
+
// caller has already committed to — the picker appends to a running list, so its files have to
|
|
4391
|
+
// count against what is already attached rather than starting from zero each time.
|
|
4392
|
+
let running = existingBytes;
|
|
4393
|
+
const overBudget = new Map();
|
|
4394
|
+
for (const file of files) {
|
|
4395
|
+
if (running + file.size > MAX_ATTACHMENTS_TOTAL_BYTES) {
|
|
4396
|
+
overBudget.set(file, `"${file.name}" would take this message past the ` +
|
|
4397
|
+
`${MAX_ATTACHMENTS_TOTAL_BYTES / BYTES_PER_MB}MB attachment limit ` +
|
|
4398
|
+
`(${(running / BYTES_PER_MB).toFixed(1)}MB already attached).`);
|
|
4399
|
+
continue;
|
|
4400
|
+
}
|
|
4401
|
+
running += file.size;
|
|
4402
|
+
}
|
|
4284
4403
|
const results = yield Promise.all(files.map((file) => __awaiter(this, void 0, void 0, function* () {
|
|
4285
4404
|
if (!this.isAcceptedFile(file)) {
|
|
4286
4405
|
return { ok: false, message: `"${file.name}" is not an accepted file type.` };
|
|
4287
4406
|
}
|
|
4407
|
+
const tooMuch = overBudget.get(file);
|
|
4408
|
+
if (tooMuch)
|
|
4409
|
+
return { ok: false, message: tooMuch };
|
|
4288
4410
|
try {
|
|
4411
|
+
// An image file becomes a real image attachment — the transports render those as
|
|
4412
|
+
// image blocks the model SEES. Reading it as text (the only path that existed)
|
|
4413
|
+
// produced mojibake the model "read" as prose: an accepted .png was worse than a
|
|
4414
|
+
// rejected one. Text stays the default for everything else.
|
|
4415
|
+
//
|
|
4416
|
+
// Only the types the vendors actually accept take this branch. `image/*` covers far
|
|
4417
|
+
// more than that, and sending one an API rejects fails the whole turn the user is
|
|
4418
|
+
// waiting on — where the text branch merely degrades. It also keeps SVG working:
|
|
4419
|
+
// SVG *is* text, and reading it as such gives the model something it can genuinely
|
|
4420
|
+
// read, which a base64 image block of it would not.
|
|
4421
|
+
if (VENDOR_IMAGE_MIME_TYPES.has(file.type.toLowerCase())) {
|
|
4422
|
+
if (file.size > MAX_IMAGE_ATTACHMENT_BYTES) {
|
|
4423
|
+
return {
|
|
4424
|
+
ok: false,
|
|
4425
|
+
message: `"${file.name}" is ${Math.ceil(file.size / BYTES_PER_MB)}MB; images are limited to ` +
|
|
4426
|
+
`${MAX_IMAGE_ATTACHMENT_BYTES / BYTES_PER_MB}MB.`,
|
|
4427
|
+
};
|
|
4428
|
+
}
|
|
4429
|
+
const data = yield this.readFileAsBase64(file);
|
|
4430
|
+
return {
|
|
4431
|
+
ok: true,
|
|
4432
|
+
attachment: { kind: 'image', name: file.name, mimeType: file.type, data },
|
|
4433
|
+
};
|
|
4434
|
+
}
|
|
4289
4435
|
const content = yield this.readFileAsText(file);
|
|
4290
4436
|
return {
|
|
4291
4437
|
ok: true,
|
|
@@ -4317,7 +4463,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
4317
4463
|
const input = e.target;
|
|
4318
4464
|
const files = Array.from((_a = input.files) !== null && _a !== void 0 ? _a : []);
|
|
4319
4465
|
input.value = '';
|
|
4320
|
-
const { attachments, errors } = yield this.processFiles(files);
|
|
4466
|
+
const { attachments, errors } = yield this.processFiles(files, this.attachments.reduce((n, a) => n + attachmentBytes(a), 0));
|
|
4321
4467
|
if (attachments.length) {
|
|
4322
4468
|
this.attachments = [...this.attachments, ...attachments];
|
|
4323
4469
|
this.logMeta('attachment.added', {
|
|
@@ -4575,6 +4721,32 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
|
|
|
4575
4721
|
(_a = this.driver) === null || _a === void 0 ? void 0 : _a.resolveInteraction(interactionId, result);
|
|
4576
4722
|
}
|
|
4577
4723
|
}
|
|
4724
|
+
/**
|
|
4725
|
+
* A widget reporting spend it incurred AFTER resolving.
|
|
4726
|
+
*
|
|
4727
|
+
* Note what this deliberately does NOT do: no `startLoadingTimer()`. This is bookkeeping about a
|
|
4728
|
+
* turn that already finished, so it must be invisible — showing a spinner would tell the user the
|
|
4729
|
+
* assistant is working when nothing is pending.
|
|
4730
|
+
*/
|
|
4731
|
+
handleInteractionCost(e) {
|
|
4732
|
+
var _a;
|
|
4733
|
+
const detail = e.detail;
|
|
4734
|
+
if (!(detail === null || detail === void 0 ? void 0 : detail.interactionId))
|
|
4735
|
+
return;
|
|
4736
|
+
// Optional on `AiDriver`, so call it optionally — a driver that predates this, or one that
|
|
4737
|
+
// simply doesn't track spend, should ignore the report rather than throw on it. TRACED,
|
|
4738
|
+
// because the spend is real and already billed: an untraced no-op here is the same invisible
|
|
4739
|
+
// gap this whole channel exists to close, reintroduced at the last hop. Reviewer catch.
|
|
4740
|
+
if (!((_a = this.driver) === null || _a === void 0 ? void 0 : _a.recordExternalCost)) {
|
|
4741
|
+
this.logMeta('interaction.external-cost-dropped', {
|
|
4742
|
+
interactionId: detail.interactionId,
|
|
4743
|
+
costUsd: detail.costUsd,
|
|
4744
|
+
reason: 'driver does not implement recordExternalCost',
|
|
4745
|
+
});
|
|
4746
|
+
return;
|
|
4747
|
+
}
|
|
4748
|
+
this.driver.recordExternalCost(detail.interactionId, detail.costUsd);
|
|
4749
|
+
}
|
|
4578
4750
|
/**
|
|
4579
4751
|
* The live {@link InteractionContext} for a pending interaction (GENC-1390).
|
|
4580
4752
|
* `AiChatInteractionWrapper` pierces this across the shadow boundary to build the
|