@artooi/ag-ui-web-component 0.26.1 → 0.28.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/CHANGELOG.md +291 -1
- package/README.md +191 -8
- package/dist/ag-ui-web-component.bundle.js +50 -50
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/core/ag_ui_chat.d.ts +61 -3
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +8 -1
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/conversation_store.d.ts +58 -3
- package/dist/core/conversation_store.d.ts.map +1 -1
- package/dist/core/create_http_agent.d.ts +13 -0
- package/dist/core/create_http_agent.d.ts.map +1 -1
- package/dist/core/remote_conversation_store.d.ts +29 -1
- package/dist/core/remote_conversation_store.d.ts.map +1 -1
- package/dist/core/utils.d.ts +42 -0
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/index.js +602 -104
- package/dist/index.js.map +4 -4
- package/dist/tools/is_destructive.d.ts +8 -2
- package/dist/tools/is_destructive.d.ts.map +1 -1
- package/dist/tools/parse_tool_catalog.d.ts +11 -4
- package/dist/tools/parse_tool_catalog.d.ts.map +1 -1
- package/dist/ui/render_markdown.d.ts +23 -5
- package/dist/ui/render_markdown.d.ts.map +1 -1
- package/dist/ui/resize_handle.d.ts +5 -1
- package/dist/ui/resize_handle.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +13 -7
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/dist/ui/voice_input.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/core/ag_ui_chat.ts +444 -45
- package/src/core/agui_client.ts +43 -2
- package/src/core/conversation_store.ts +146 -49
- package/src/core/create_http_agent.ts +24 -2
- package/src/core/remote_conversation_store.ts +45 -3
- package/src/core/utils.ts +83 -1
- package/src/tools/is_destructive.ts +8 -2
- package/src/tools/parse_tool_catalog.ts +18 -6
- package/src/ui/render_markdown.ts +111 -21
- package/src/ui/resize_handle.ts +32 -2
- package/src/ui/ui_strings.ts +19 -8
- package/src/ui/voice_input.ts +43 -0
- package/src/version.ts +1 -1
package/src/core/agui_client.ts
CHANGED
|
@@ -97,7 +97,14 @@ export interface AgUiClientHandlers {
|
|
|
97
97
|
onActivityChanged(messageId: string, activityType: string, content: unknown): void;
|
|
98
98
|
/** Fired when a reasoning model starts emitting its chain-of-thought. */
|
|
99
99
|
onReasoningStart(): void;
|
|
100
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Fired on every reasoning token, and once more when the block ends.
|
|
102
|
+
*
|
|
103
|
+
* ``buffer`` is the text accumulated *before* the token that triggered the
|
|
104
|
+
* call, which is what the protocol client passes -- so the stream trails by
|
|
105
|
+
* one delta and the final call, at the end of the block, is what completes
|
|
106
|
+
* it. Render the buffer wholesale rather than appending it.
|
|
107
|
+
*/
|
|
101
108
|
onReasoningDelta(buffer: string): void;
|
|
102
109
|
/** Fired when the reasoning block ends (before the answer text streams). */
|
|
103
110
|
onReasoningEnd(): void;
|
|
@@ -408,6 +415,9 @@ export class AgUiClient {
|
|
|
408
415
|
#buildSubscriber(pending: AgUiToolCall[], runState: RunState): AgentSubscriber {
|
|
409
416
|
const h = this.#handlers;
|
|
410
417
|
const closed = this.#closedMessageIds;
|
|
418
|
+
// Read at event time, not captured now: the flag flips mid-run, and the
|
|
419
|
+
// subscriber is built before the run that a later `cancel()` stops.
|
|
420
|
+
const cancelled = (): boolean => this.#cancelled;
|
|
411
421
|
// Charts whose patch has been dispatched but not yet applied. Scoped to the
|
|
412
422
|
// subscriber, so it cannot outlive the run that created it.
|
|
413
423
|
const pendingDeltas = new Set<string>();
|
|
@@ -494,6 +504,16 @@ export class AgUiClient {
|
|
|
494
504
|
onReasoningMessageContentEvent({ reasoningMessageBuffer }) {
|
|
495
505
|
h.onReasoningDelta(reasoningMessageBuffer);
|
|
496
506
|
},
|
|
507
|
+
// The delta callback reports the buffer as it stood *before* the announced
|
|
508
|
+
// delta was appended, so on its own it always trails the stream by one and
|
|
509
|
+
// renders nothing at all for a block that arrives as a single delta. The
|
|
510
|
+
// answer text is spared that because its own end event carries the whole
|
|
511
|
+
// message; this is the reasoning counterpart, and it has to be
|
|
512
|
+
// REASONING_MESSAGE_END rather than REASONING_END, because only the former
|
|
513
|
+
// carries a buffer.
|
|
514
|
+
onReasoningMessageEndEvent({ reasoningMessageBuffer }) {
|
|
515
|
+
h.onReasoningDelta(reasoningMessageBuffer);
|
|
516
|
+
},
|
|
497
517
|
onReasoningEndEvent() {
|
|
498
518
|
h.onReasoningEnd();
|
|
499
519
|
},
|
|
@@ -510,6 +530,15 @@ export class AgUiClient {
|
|
|
510
530
|
onRunErrorEvent({ event }) {
|
|
511
531
|
runState.terminal = true;
|
|
512
532
|
runState.errored = true;
|
|
533
|
+
// Cancelling aborts the response mid-read, and the browser's own words
|
|
534
|
+
// for that can arrive here as a RUN_ERROR — Chrome's is
|
|
535
|
+
// "BodyStreamBuffer was aborted". The run is over either way, but a
|
|
536
|
+
// deliberate stop is not a failure, and reporting it would raise a
|
|
537
|
+
// warning bubble above the stopped note saying the same thing twice.
|
|
538
|
+
// The promise route in `#run` reports the cancellation.
|
|
539
|
+
if (cancelled()) {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
513
542
|
h.onError(event.message);
|
|
514
543
|
},
|
|
515
544
|
onRunFinalized() {
|
|
@@ -532,7 +561,19 @@ interface RunState {
|
|
|
532
561
|
* Whether a rejection came from aborting the run's fetch. Belt-and-suspenders
|
|
533
562
|
* with the `#cancelled` flag: some `@ag-ui/client` versions re-throw the
|
|
534
563
|
* `AbortError` instead of filtering it.
|
|
564
|
+
*
|
|
565
|
+
* Aborting a fetch whose body is mid-read does not always surface as an
|
|
566
|
+
* `AbortError`. Chrome raises `TypeError: BodyStreamBuffer was aborted`, which
|
|
567
|
+
* is the same event wearing a different name, so a message naming the abort is
|
|
568
|
+
* read as one too. Narrow on purpose: only a `TypeError`, and only when it says
|
|
569
|
+
* so — a genuine type error carries no such word, and misreading one as a
|
|
570
|
+
* cancellation would hide a real failure behind a stopped note.
|
|
535
571
|
*/
|
|
536
572
|
function isAbortError(error: unknown): boolean {
|
|
537
|
-
|
|
573
|
+
if (!(error instanceof Error)) {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
return (
|
|
577
|
+
error.name === "AbortError" || (error instanceof TypeError && /abort/i.test(error.message))
|
|
578
|
+
);
|
|
538
579
|
}
|
|
@@ -36,12 +36,24 @@ export interface ThreadMeta {
|
|
|
36
36
|
* is a small local hint a server store can derive from history and no-op.
|
|
37
37
|
*
|
|
38
38
|
* Thread enumeration backs the chat-history drawer; deleting a thread reuses
|
|
39
|
-
* {@link clear} and "new chat"
|
|
40
|
-
*
|
|
39
|
+
* {@link clear}, and "new chat" is {@link newThread} — which leaves the
|
|
40
|
+
* conversation it moves off of intact, for the drawer to offer back.
|
|
41
41
|
*/
|
|
42
42
|
export interface ClientConversationStore {
|
|
43
43
|
/** The active conversation id, generated and persisted on first read. */
|
|
44
44
|
threadId(): string;
|
|
45
|
+
/**
|
|
46
|
+
* Start a fresh conversation, make it active, and return its id.
|
|
47
|
+
*
|
|
48
|
+
* Existing threads are left where they are: "new chat" adds one, and
|
|
49
|
+
* {@link clear} is the only method that takes one away.
|
|
50
|
+
*
|
|
51
|
+
* Optional, so a store written before this method existed still works. The
|
|
52
|
+
* caller then mints the id itself and hands it to {@link setActiveThread},
|
|
53
|
+
* which loses only the store's own record that the thread is new (see
|
|
54
|
+
* {@link isUnsent}).
|
|
55
|
+
*/
|
|
56
|
+
newThread?(): string;
|
|
45
57
|
/** Load the persisted message history, or `null` when none exists. */
|
|
46
58
|
loadMessages(threadId: string): Promise<readonly Message[] | null>;
|
|
47
59
|
/** Persist the message history (and refresh the thread's drawer metadata). */
|
|
@@ -90,6 +102,49 @@ const TITLE_LIMIT = 60;
|
|
|
90
102
|
const PREVIEW_LIMIT = 100;
|
|
91
103
|
const DEFAULT_TITLE = "New conversation";
|
|
92
104
|
|
|
105
|
+
// One warning per page, not one per write. The condition is origin-wide and
|
|
106
|
+
// persistent — a full quota stays full — so a message per persisted turn (or,
|
|
107
|
+
// on the resize path, per keystroke) would bury the one that matters.
|
|
108
|
+
let writeFailureReported = false;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* `sessionStorage.setItem` that survives a store which refuses to write.
|
|
112
|
+
*
|
|
113
|
+
* `setItem` throws on an exhausted quota (a long conversation, or one turn
|
|
114
|
+
* carrying a large tool result) and in privacy modes that deny storage
|
|
115
|
+
* altogether. Every write here is a *durability* concern — surviving a reload —
|
|
116
|
+
* and none of them is worth an exception, because of where they are called
|
|
117
|
+
* from: the element persists the transcript from inside the run loop, so an
|
|
118
|
+
* unguarded throw escapes as a run error and tells the user the agent failed
|
|
119
|
+
* when nothing but the browser's storage did. On the cancel path it escapes as
|
|
120
|
+
* an unhandled rejection instead.
|
|
121
|
+
*
|
|
122
|
+
* So a failed write loses the reload, never the conversation on screen, and
|
|
123
|
+
* says so once. Recovery is in the user's hands already: deleting the oversized
|
|
124
|
+
* thread from the history drawer is a `removeItem`, which frees the quota.
|
|
125
|
+
*/
|
|
126
|
+
export function writeStoredItem(key: string, value: string): void {
|
|
127
|
+
try {
|
|
128
|
+
sessionStorage.setItem(key, value);
|
|
129
|
+
} catch {
|
|
130
|
+
if (writeFailureReported) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
writeFailureReported = true;
|
|
134
|
+
console.warn(
|
|
135
|
+
"<ag-ui-chat>: the browser refused a sessionStorage write — the quota is " +
|
|
136
|
+
"full, or storage is disabled for this context. The conversation " +
|
|
137
|
+
"continues, but it will not survive a page reload. Deleting a long " +
|
|
138
|
+
"conversation from the history drawer frees the quota.",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The storage-key root for a namespace; `""` is the pre-namespacing global root. */
|
|
144
|
+
function rootFor(namespace: string): string {
|
|
145
|
+
return namespace === "" ? KEY_ROOT : `${KEY_ROOT}@${namespace}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
93
148
|
/** The drawer-index entry; `titleCustom` (private) freezes a renamed title. */
|
|
94
149
|
interface StoredThread {
|
|
95
150
|
threadId: string;
|
|
@@ -110,27 +165,70 @@ interface StoredThread {
|
|
|
110
165
|
* An optional `namespace` scopes every key to one element, so two
|
|
111
166
|
* `<ag-ui-chat>` instances on the same origin keep separate active-thread
|
|
112
167
|
* pointers and drawer indexes instead of clobbering each other. The default
|
|
113
|
-
* empty namespace keeps the origin-global keys
|
|
168
|
+
* empty namespace keeps the origin-global keys, which a namespaced store adopts
|
|
169
|
+
* on construction; see {@link SessionStorageStore.adopt}.
|
|
114
170
|
*/
|
|
115
171
|
export class SessionStorageStore implements ClientConversationStore {
|
|
116
172
|
readonly #root: string;
|
|
117
173
|
|
|
118
174
|
constructor(namespace = "") {
|
|
119
|
-
this.#root = namespace
|
|
175
|
+
this.#root = rootFor(namespace);
|
|
120
176
|
if (namespace !== "") {
|
|
121
|
-
|
|
177
|
+
// One-time move of the pre-namespacing global keys, so an existing
|
|
178
|
+
// conversation isn't orphaned by the upgrade. See {@link adopt}.
|
|
179
|
+
SessionStorageStore.adopt("", namespace);
|
|
122
180
|
}
|
|
123
181
|
}
|
|
124
182
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Move every key a store owns out of `from`'s namespace and into `to`'s.
|
|
185
|
+
*
|
|
186
|
+
* Two callers, one move. The constructor adopts the pre-namespacing global
|
|
187
|
+
* keys (`from` = `""`); `<ag-ui-chat>` adopts an element-scoped conversation
|
|
188
|
+
* into a principal-scoped one the first time a `user-key` arrives, which is a
|
|
189
|
+
* host naming the user who was already there rather than a handover.
|
|
190
|
+
*
|
|
191
|
+
* Only this store's own suffixes move — the element's `collapsed` / `size` /
|
|
192
|
+
* `theme` keys share the global root and are deliberately left where they
|
|
193
|
+
* are. A value already present at the destination wins: the destination is
|
|
194
|
+
* the durable record and the source is the stray this move exists to clear.
|
|
195
|
+
*/
|
|
196
|
+
static adopt(from: string, to: string): void {
|
|
197
|
+
const fromRoot = `${rootFor(from)}:`;
|
|
198
|
+
const toRoot = `${rootFor(to)}:`;
|
|
199
|
+
for (const [key, suffix] of ownedKeys(fromRoot)) {
|
|
200
|
+
const value = sessionStorage.getItem(key);
|
|
201
|
+
const destination = toRoot + suffix;
|
|
202
|
+
if (value !== null && sessionStorage.getItem(destination) === null) {
|
|
203
|
+
writeStoredItem(destination, value);
|
|
204
|
+
}
|
|
205
|
+
sessionStorage.removeItem(key);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Forget everything a store holds for `namespace`.
|
|
211
|
+
*
|
|
212
|
+
* The logout primitive: `<ag-ui-chat>` calls it when its `user-key` changes,
|
|
213
|
+
* and a host driving its own store can call it from its own sign-out path.
|
|
214
|
+
* Deliberately narrow — it removes only keys under this exact namespace whose
|
|
215
|
+
* suffix parses as one this store writes, so it can never reach another
|
|
216
|
+
* element's conversation or the host's own `sessionStorage` entries.
|
|
217
|
+
*/
|
|
218
|
+
static purge(namespace: string): void {
|
|
219
|
+
for (const [key] of ownedKeys(`${rootFor(namespace)}:`)) {
|
|
220
|
+
sessionStorage.removeItem(key);
|
|
130
221
|
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
threadId(): string {
|
|
225
|
+
return sessionStorage.getItem(this.#key(THREAD_SUFFIX)) ?? this.newThread();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
newThread(): string {
|
|
131
229
|
const id = randomUUID();
|
|
132
|
-
|
|
133
|
-
|
|
230
|
+
writeStoredItem(this.#key(THREAD_SUFFIX), id);
|
|
231
|
+
writeStoredItem(this.#key(MINTED_SUFFIX + id), "1");
|
|
134
232
|
return id;
|
|
135
233
|
}
|
|
136
234
|
|
|
@@ -146,7 +244,7 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
146
244
|
}
|
|
147
245
|
|
|
148
246
|
saveMessages(threadId: string, messages: readonly Message[]): void {
|
|
149
|
-
|
|
247
|
+
writeStoredItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
|
|
150
248
|
sessionStorage.removeItem(this.#key(MINTED_SUFFIX + threadId));
|
|
151
249
|
this.#touchThread(threadId, messages);
|
|
152
250
|
}
|
|
@@ -161,7 +259,7 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
161
259
|
sessionStorage.removeItem(key);
|
|
162
260
|
return;
|
|
163
261
|
}
|
|
164
|
-
|
|
262
|
+
writeStoredItem(key, JSON.stringify(checkpoint));
|
|
165
263
|
}
|
|
166
264
|
|
|
167
265
|
clear(threadId: string): void {
|
|
@@ -185,7 +283,7 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
185
283
|
}
|
|
186
284
|
|
|
187
285
|
setActiveThread(threadId: string): void {
|
|
188
|
-
|
|
286
|
+
writeStoredItem(this.#key(THREAD_SUFFIX), threadId);
|
|
189
287
|
}
|
|
190
288
|
|
|
191
289
|
renameThread(threadId: string, title: string): void {
|
|
@@ -233,7 +331,7 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
233
331
|
sessionStorage.removeItem(key);
|
|
234
332
|
return;
|
|
235
333
|
}
|
|
236
|
-
|
|
334
|
+
writeStoredItem(key, JSON.stringify(threads));
|
|
237
335
|
}
|
|
238
336
|
|
|
239
337
|
/** This store's fully-qualified key for a suffix (namespaced when set). */
|
|
@@ -241,37 +339,6 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
241
339
|
return `${this.#root}:${suffix}`;
|
|
242
340
|
}
|
|
243
341
|
|
|
244
|
-
/**
|
|
245
|
-
* One-time move of un-namespaced `ag-ui-chat:*` keys into this instance's
|
|
246
|
-
* namespace, so an existing conversation isn't orphaned. Only this store's own
|
|
247
|
-
* keys move — the element's `collapsed` / `theme` keys are left alone. The
|
|
248
|
-
* first namespaced instance to mount adopts the data; a second namespace
|
|
249
|
-
* finds it gone and starts fresh.
|
|
250
|
-
*/
|
|
251
|
-
#migrateLegacyKeys(): void {
|
|
252
|
-
const legacyRoot = `${KEY_ROOT}:`;
|
|
253
|
-
const moves: Array<readonly [string, string]> = [];
|
|
254
|
-
for (let i = 0; i < sessionStorage.length; i += 1) {
|
|
255
|
-
const key = sessionStorage.key(i);
|
|
256
|
-
if (key === null || !key.startsWith(legacyRoot)) {
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
const suffix = key.slice(legacyRoot.length);
|
|
260
|
-
if (isOwnedSuffix(suffix)) {
|
|
261
|
-
moves.push([key, this.#key(suffix)]);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
// Collected first, mutated second — writing while iterating by index skips
|
|
265
|
-
// entries as the key list shifts.
|
|
266
|
-
for (const [from, to] of moves) {
|
|
267
|
-
const value = sessionStorage.getItem(from);
|
|
268
|
-
if (value !== null && sessionStorage.getItem(to) === null) {
|
|
269
|
-
sessionStorage.setItem(to, value);
|
|
270
|
-
}
|
|
271
|
-
sessionStorage.removeItem(from);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
342
|
/** Parse a stored JSON value, returning `null` when absent or corrupt. */
|
|
276
343
|
#readJson<T>(key: string): T | null {
|
|
277
344
|
const raw = sessionStorage.getItem(key);
|
|
@@ -286,13 +353,43 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
286
353
|
}
|
|
287
354
|
}
|
|
288
355
|
|
|
289
|
-
/**
|
|
356
|
+
/**
|
|
357
|
+
* Every `sessionStorage` key under `root` that this store wrote, as
|
|
358
|
+
* `[key, suffix]`.
|
|
359
|
+
*
|
|
360
|
+
* Collected into an array before the caller mutates anything: `sessionStorage`
|
|
361
|
+
* is enumerated by index, and removing an entry mid-loop shifts the ones after
|
|
362
|
+
* it out from under the cursor.
|
|
363
|
+
*
|
|
364
|
+
* The suffix test is what makes {@link SessionStorageStore.purge} safe to point
|
|
365
|
+
* at a namespace. It matters most for the global root, which the element's own
|
|
366
|
+
* `collapsed` / `size` / `theme` keys share — but it also means a namespace
|
|
367
|
+
* whose name happens to be a prefix of another cannot reach into it, since the
|
|
368
|
+
* remainder would have to parse as one of these suffixes.
|
|
369
|
+
*/
|
|
370
|
+
function ownedKeys(root: string): Array<readonly [string, string]> {
|
|
371
|
+
const found: Array<readonly [string, string]> = [];
|
|
372
|
+
for (let index = 0; index < sessionStorage.length; index += 1) {
|
|
373
|
+
const key = sessionStorage.key(index);
|
|
374
|
+
if (key === null || !key.startsWith(root)) {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const suffix = key.slice(root.length);
|
|
378
|
+
if (isOwnedSuffix(suffix)) {
|
|
379
|
+
found.push([key, suffix]);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return found;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Whether a key suffix belongs to the store (vs the element's own keys). */
|
|
290
386
|
function isOwnedSuffix(suffix: string): boolean {
|
|
291
387
|
return (
|
|
292
388
|
suffix === THREAD_SUFFIX ||
|
|
293
389
|
suffix === THREADS_SUFFIX ||
|
|
294
390
|
suffix.startsWith(MESSAGES_SUFFIX) ||
|
|
295
|
-
suffix.startsWith(CHECKPOINT_SUFFIX)
|
|
391
|
+
suffix.startsWith(CHECKPOINT_SUFFIX) ||
|
|
392
|
+
suffix.startsWith(MINTED_SUFFIX)
|
|
296
393
|
);
|
|
297
394
|
}
|
|
298
395
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type AbstractAgent, HttpAgent } from "@ag-ui/client";
|
|
2
2
|
import type { Message } from "@ag-ui/core";
|
|
3
|
-
import { withCredentials } from "./utils.js";
|
|
3
|
+
import { warnOnCrossOriginCredentials, withCredentials } from "./utils.js";
|
|
4
4
|
|
|
5
5
|
/** Config for {@link createHttpAgent}. */
|
|
6
6
|
export interface HttpAgentOptions {
|
|
@@ -32,6 +32,19 @@ export interface HttpAgentOptions {
|
|
|
32
32
|
* server streams `STATE_SNAPSHOT` / `STATE_DELTA`.
|
|
33
33
|
*/
|
|
34
34
|
initialState?: Readonly<Record<string, unknown>>;
|
|
35
|
+
/**
|
|
36
|
+
* Origins, besides the document's own, this agent may carry the host's
|
|
37
|
+
* credentials to.
|
|
38
|
+
*
|
|
39
|
+
* Running the agent on another subdomain is a normal deployment and stays
|
|
40
|
+
* supported, so a cross-origin endpoint is not refused — it is *announced*,
|
|
41
|
+
* once per origin, on the console. Listing an origin here says the
|
|
42
|
+
* destination was chosen deliberately and silences the notice for it.
|
|
43
|
+
*
|
|
44
|
+
* Entries are compared as serialized origins (`https://agent.example.com`,
|
|
45
|
+
* scheme and port included), which is what `URL.origin` produces.
|
|
46
|
+
*/
|
|
47
|
+
trustedOrigins?: readonly string[];
|
|
35
48
|
}
|
|
36
49
|
|
|
37
50
|
/**
|
|
@@ -42,9 +55,11 @@ export interface HttpAgentOptions {
|
|
|
42
55
|
* {@link AbstractAgent}.
|
|
43
56
|
*/
|
|
44
57
|
export function createHttpAgent(options: HttpAgentOptions): AbstractAgent {
|
|
58
|
+
const staticHeaders = options.headers ?? {};
|
|
59
|
+
const warned = new Set<string>();
|
|
45
60
|
return new HttpAgent({
|
|
46
61
|
url: options.endpoint,
|
|
47
|
-
headers:
|
|
62
|
+
headers: staticHeaders,
|
|
48
63
|
initialState: { ...(options.initialState ?? {}) },
|
|
49
64
|
// HttpAgent invokes its configured fetch as a method (`this.fetch(...)`),
|
|
50
65
|
// rebinding the global `fetch` to the agent instance — "Illegal invocation"
|
|
@@ -53,6 +68,13 @@ export function createHttpAgent(options: HttpAgentOptions): AbstractAgent {
|
|
|
53
68
|
// own config having no seam for either.
|
|
54
69
|
fetch: (url, init) => {
|
|
55
70
|
const fresh = options.getHeaders?.();
|
|
71
|
+
// Only the names the *host* supplied. `HttpAgent` adds `Content-Type` and
|
|
72
|
+
// `Accept` to every request and neither is a credential, so reporting the
|
|
73
|
+
// outgoing header set wholesale would cry wolf on every plain request.
|
|
74
|
+
const credentialNames = [
|
|
75
|
+
...new Set([...Object.keys(staticHeaders), ...Object.keys(fresh ?? {})]),
|
|
76
|
+
].sort();
|
|
77
|
+
warnOnCrossOriginCredentials(url, credentialNames, options.trustedOrigins ?? [], warned);
|
|
56
78
|
if (fresh === undefined) {
|
|
57
79
|
return fetch(url, withCredentials(init, options.credentials));
|
|
58
80
|
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
SessionStorageStore,
|
|
6
6
|
type ThreadMeta,
|
|
7
7
|
} from "./conversation_store.js";
|
|
8
|
-
import { withCredentials } from "./utils.js";
|
|
8
|
+
import { mintThread, withCredentials } from "./utils.js";
|
|
9
9
|
|
|
10
10
|
/** One row of the server thread index (django-ag-ui's `ThreadsView` wire shape). */
|
|
11
11
|
interface ServerThreadRow {
|
|
@@ -40,25 +40,50 @@ type CredentialsProvider = () => RequestCredentials | undefined;
|
|
|
40
40
|
* the fallback when a request fails. Rename and delete apply optimistically via
|
|
41
41
|
* a local overlay, so the drawer reflects them before the fire-and-forget
|
|
42
42
|
* round-trip lands.
|
|
43
|
+
*
|
|
44
|
+
* Pass `cacheMessages: false` to keep message bodies out of the browser
|
|
45
|
+
* entirely; see the constructor.
|
|
43
46
|
*/
|
|
44
47
|
export class RemoteConversationStore implements ClientConversationStore {
|
|
45
48
|
readonly #url: string;
|
|
46
49
|
readonly #headers: HeadersProvider;
|
|
47
50
|
readonly #local: ClientConversationStore;
|
|
48
51
|
readonly #credentials: CredentialsProvider;
|
|
52
|
+
readonly #cacheMessages: boolean;
|
|
49
53
|
readonly #dropped = new Set<string>();
|
|
50
54
|
readonly #renamed = new Map<string, string>();
|
|
51
55
|
|
|
56
|
+
/**
|
|
57
|
+
* @param cacheMessages Whether to mirror message bodies into the local store.
|
|
58
|
+
*
|
|
59
|
+
* `true` (the default, and the behaviour this class has always had) keeps a
|
|
60
|
+
* local copy of every turn, so the transcript still replays when the thread
|
|
61
|
+
* endpoint is unreachable. `false` is for the deployment that chose a
|
|
62
|
+
* server-backed store precisely so transcripts do not sit in the browser:
|
|
63
|
+
* regulated content, a shared workstation, an operator who has to be able to
|
|
64
|
+
* say where the conversation lives. It is not the same as passing a local
|
|
65
|
+
* store that does nothing — the local store also owns the active thread id,
|
|
66
|
+
* the navigation checkpoint and the "nothing sent here yet" marker, all of
|
|
67
|
+
* which must keep working — so the opt-out is scoped to the bodies alone.
|
|
68
|
+
*
|
|
69
|
+
* The cost is deliberate and worth stating: with no local copy there is
|
|
70
|
+
* nothing to fall back to, so a failed request shows an empty transcript
|
|
71
|
+
* rather than a stale one, and the drawer's offline list loses its previews
|
|
72
|
+
* (a preview is an excerpt of a message, which is the very thing being kept
|
|
73
|
+
* off the client).
|
|
74
|
+
*/
|
|
52
75
|
constructor(
|
|
53
76
|
url: string,
|
|
54
77
|
headers: HeadersProvider = () => ({}),
|
|
55
78
|
local: ClientConversationStore = new SessionStorageStore(),
|
|
56
79
|
credentials: CredentialsProvider = () => undefined,
|
|
80
|
+
cacheMessages = true,
|
|
57
81
|
) {
|
|
58
82
|
this.#url = url.endsWith("/") ? url : `${url}/`;
|
|
59
83
|
this.#headers = headers;
|
|
60
84
|
this.#local = local;
|
|
61
85
|
this.#credentials = credentials;
|
|
86
|
+
this.#cacheMessages = cacheMessages;
|
|
62
87
|
}
|
|
63
88
|
|
|
64
89
|
threadId(): string {
|
|
@@ -69,14 +94,31 @@ export class RemoteConversationStore implements ClientConversationStore {
|
|
|
69
94
|
this.#local.setActiveThread(threadId);
|
|
70
95
|
}
|
|
71
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Delegated to the local store, which owns the active id — and deliberately
|
|
99
|
+
* silent on the wire: the server learns of a thread when its first message is
|
|
100
|
+
* persisted, so an abandoned new chat costs no round-trip and leaves no row.
|
|
101
|
+
*/
|
|
102
|
+
newThread(): string {
|
|
103
|
+
return mintThread(this.#local);
|
|
104
|
+
}
|
|
105
|
+
|
|
72
106
|
/** Delegated, so wrapping a store does not lose what it knows about its own ids. */
|
|
73
107
|
isUnsent(threadId: string): boolean {
|
|
74
108
|
return this.#local.isUnsent?.(threadId) === true;
|
|
75
109
|
}
|
|
76
110
|
|
|
77
111
|
saveMessages(threadId: string, messages: readonly Message[]): void {
|
|
78
|
-
// The agent run persists server-side; keep a local cache for offline replay
|
|
79
|
-
|
|
112
|
+
// The agent run persists server-side; keep a local cache for offline replay
|
|
113
|
+
// unless the host asked for the bodies to stay off the client.
|
|
114
|
+
//
|
|
115
|
+
// The empty list is not a way of saying "nothing happened". A save is what
|
|
116
|
+
// retires the local store's minted marker, and `loadMessages` skips the
|
|
117
|
+
// server for a thread that store still calls unsent — so dropping the call
|
|
118
|
+
// entirely would leave every thread permanently unsent and its history
|
|
119
|
+
// unreachable after a reload. Saving an empty list records that the thread
|
|
120
|
+
// is real without recording a word of what was said in it.
|
|
121
|
+
this.#local.saveMessages(threadId, this.#cacheMessages ? messages : []);
|
|
80
122
|
}
|
|
81
123
|
|
|
82
124
|
loadCheckpoint(threadId: string): NavigationCheckpoint | null {
|
package/src/core/utils.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
// Non-exported-from-index helpers shared by the core
|
|
1
|
+
// Non-exported-from-index helpers shared by the core modules.
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from "@ag-ui/client";
|
|
4
|
+
import type { ClientConversationStore } from "./conversation_store.js";
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* Overlay a `credentials` mode onto a fetch `init`, or hand the `init` back
|
|
@@ -14,3 +17,82 @@ export function withCredentials(
|
|
|
14
17
|
): RequestInit | undefined {
|
|
15
18
|
return credentials === undefined ? init : { ...init, credentials };
|
|
16
19
|
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Start a new conversation in `store` and return its id.
|
|
23
|
+
*
|
|
24
|
+
* `newThread` is optional on the interface, so a store that predates it is
|
|
25
|
+
* driven the only other way the interface allows: mint an id here and make it
|
|
26
|
+
* active. That path loses the store's own note that the thread is new, so a
|
|
27
|
+
* remote store would go on to ask the server for a conversation that cannot
|
|
28
|
+
* exist yet — which is why every store in this package implements the method.
|
|
29
|
+
*
|
|
30
|
+
* What neither path does is clear the thread being left behind. Starting a
|
|
31
|
+
* conversation is not a reason to destroy the previous one.
|
|
32
|
+
*/
|
|
33
|
+
export function mintThread(store: ClientConversationStore): string {
|
|
34
|
+
if (store.newThread !== undefined) {
|
|
35
|
+
return store.newThread();
|
|
36
|
+
}
|
|
37
|
+
const id = randomUUID();
|
|
38
|
+
store.setActiveThread(id);
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Announce host credentials about to leave the document's origin.
|
|
44
|
+
*
|
|
45
|
+
* `endpoint` and its six sibling URL attributes are plain HTML, and a page that
|
|
46
|
+
* interpolates one from a query parameter or from tenant-authored
|
|
47
|
+
* configuration has handed an attacker the destination. The browser preflights
|
|
48
|
+
* the custom header, any server willing to answer `Access-Control-Allow-Headers`
|
|
49
|
+
* receives it, and the token leaves on the element's very first request —
|
|
50
|
+
* before the user has done anything. Nothing else in this package compares a
|
|
51
|
+
* configured URL against an expected origin, so without this the delivery is
|
|
52
|
+
* silent, which is the only part of that sequence worth changing.
|
|
53
|
+
*
|
|
54
|
+
* A warning rather than a refusal because a cross-origin agent is a documented
|
|
55
|
+
* deployment: refusing would break working installations to defend against a
|
|
56
|
+
* page that is already interpolating untrusted data into its own markup. What
|
|
57
|
+
* it removes is the silence.
|
|
58
|
+
*
|
|
59
|
+
* `warned` is supplied by the caller rather than held here, per this package's
|
|
60
|
+
* rule against shared mutable state: two elements on one page must each get
|
|
61
|
+
* their own notice, and the set lives exactly as long as its owner.
|
|
62
|
+
*
|
|
63
|
+
* Every configured URL goes through this, not the agent endpoint alone. The
|
|
64
|
+
* tool catalog, the skills list, the thread and attachment endpoints and the
|
|
65
|
+
* upload target are all named by the same kind of host attribute and all carry
|
|
66
|
+
* the same headers, so covering one of them and not the rest would report the
|
|
67
|
+
* least interesting of the seven.
|
|
68
|
+
*/
|
|
69
|
+
export function warnOnCrossOriginCredentials(
|
|
70
|
+
url: string | URL,
|
|
71
|
+
credentialNames: readonly string[],
|
|
72
|
+
trustedOrigins: readonly string[],
|
|
73
|
+
warned: Set<string>,
|
|
74
|
+
): void {
|
|
75
|
+
if (credentialNames.length === 0) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// Resolved against the document, so a relative endpoint — the ordinary case —
|
|
79
|
+
// lands on this origin and says nothing.
|
|
80
|
+
const destination = new URL(String(url), location.href).origin;
|
|
81
|
+
if (
|
|
82
|
+
destination === location.origin ||
|
|
83
|
+
trustedOrigins.includes(destination) ||
|
|
84
|
+
warned.has(destination)
|
|
85
|
+
) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
warned.add(destination);
|
|
89
|
+
console.warn(
|
|
90
|
+
`<ag-ui-chat>: sending host credentials (${credentialNames.join(", ")}) to ` +
|
|
91
|
+
`${destination}, which is not this page's origin (${location.origin}). Those headers ` +
|
|
92
|
+
"are the page's own authentication, and whichever server answers the browser's " +
|
|
93
|
+
"preflight receives them — so a URL attribute built from a query parameter or from " +
|
|
94
|
+
"tenant-authored configuration is a channel for the token to leave on. If this " +
|
|
95
|
+
"destination is deliberate, name it in `trustedOrigins` to confirm it and " +
|
|
96
|
+
"silence this notice. Reported once per origin.",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -3,8 +3,14 @@ import { X_DESTRUCTIVE_KEY } from "../constants.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* Whether a tool's JSON-Schema `parameters` marks it destructive.
|
|
5
5
|
*
|
|
6
|
-
* Reads the `x-destructive` extension
|
|
7
|
-
* `
|
|
6
|
+
* Reads the `x-destructive` extension off a schema the **host** declared —
|
|
7
|
+
* a tool passed to `registerTool`, or one of the built-ins. A server-side
|
|
8
|
+
* tool's schema never reaches the browser: tool definitions travel
|
|
9
|
+
* client-to-server on `RunAgentInput.tools`, and the only channel coming the
|
|
10
|
+
* other way is the tool catalog (`data-tools-url`), which carries labels, not
|
|
11
|
+
* schemas. So a server tool marked destructive there is not gated here, and
|
|
12
|
+
* must be gated server-side instead — the confirmation this flag drives is a
|
|
13
|
+
* property of tools the browser itself executes.
|
|
8
14
|
*/
|
|
9
15
|
export function isDestructive(parameters: Record<string, unknown>): boolean {
|
|
10
16
|
return parameters[X_DESTRUCTIVE_KEY] === true;
|
|
@@ -7,17 +7,24 @@ export interface ToolCatalogEntry {
|
|
|
7
7
|
readonly name: string;
|
|
8
8
|
/** A friendly card label for the tool. */
|
|
9
9
|
readonly summary: string;
|
|
10
|
-
/** Optional longer blurb (e.g. for a
|
|
10
|
+
/** Optional longer blurb (e.g. for a tooltip). */
|
|
11
11
|
readonly description?: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Parse a fetched tool catalog into a `name →
|
|
15
|
+
* Parse a fetched tool catalog into a `name → entry` map, skipping any entry
|
|
16
16
|
* that isn't a `{ name: string, summary: string }` object. Tolerant by design:
|
|
17
|
-
* a malformed payload yields an empty map rather than throwing
|
|
17
|
+
* a malformed payload yields an empty map rather than throwing, and an
|
|
18
|
+
* optional field of the wrong type costs that field rather than the entry.
|
|
19
|
+
*
|
|
20
|
+
* Whole entries rather than bare summaries, even though the element itself
|
|
21
|
+
* only labels cards with `summary`: the map is what a caller gets, so
|
|
22
|
+
* narrowing it here would put `description` on the wire with nowhere to
|
|
23
|
+
* arrive, and no consumer could recover it without changing this signature
|
|
24
|
+
* first.
|
|
18
25
|
*/
|
|
19
|
-
export function parseToolCatalog(data: unknown): Record<string,
|
|
20
|
-
const out: Record<string,
|
|
26
|
+
export function parseToolCatalog(data: unknown): Record<string, ToolCatalogEntry> {
|
|
27
|
+
const out: Record<string, ToolCatalogEntry> = {};
|
|
21
28
|
if (!Array.isArray(data)) {
|
|
22
29
|
return out;
|
|
23
30
|
}
|
|
@@ -28,8 +35,13 @@ export function parseToolCatalog(data: unknown): Record<string, string> {
|
|
|
28
35
|
const record = entry as Record<string, unknown>;
|
|
29
36
|
const name = record["name"];
|
|
30
37
|
const summary = record["summary"];
|
|
38
|
+
const description = record["description"];
|
|
31
39
|
if (typeof name === "string" && typeof summary === "string") {
|
|
32
|
-
|
|
40
|
+
// Built conditionally rather than with an `undefined` field:
|
|
41
|
+
// `exactOptionalPropertyTypes` makes "absent" and "present as
|
|
42
|
+
// undefined" different types, and only the former is the wire shape.
|
|
43
|
+
out[name] =
|
|
44
|
+
typeof description === "string" ? { name, summary, description } : { name, summary };
|
|
33
45
|
}
|
|
34
46
|
}
|
|
35
47
|
return out;
|