@artooi/ag-ui-web-component 0.27.0 → 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 +247 -1
- package/README.md +186 -6
- 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 +55 -1
- 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 +43 -1
- 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 +23 -1
- package/dist/core/remote_conversation_store.d.ts.map +1 -1
- package/dist/core/utils.d.ts +28 -0
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/index.js +564 -94
- 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 +431 -41
- package/src/core/agui_client.ts +18 -1
- package/src/core/conversation_store.ts +128 -42
- package/src/core/create_http_agent.ts +24 -2
- package/src/core/remote_conversation_store.ts +35 -2
- package/src/core/utils.ts +58 -0
- 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;
|
|
@@ -497,6 +504,16 @@ export class AgUiClient {
|
|
|
497
504
|
onReasoningMessageContentEvent({ reasoningMessageBuffer }) {
|
|
498
505
|
h.onReasoningDelta(reasoningMessageBuffer);
|
|
499
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
|
+
},
|
|
500
517
|
onReasoningEndEvent() {
|
|
501
518
|
h.onReasoningEnd();
|
|
502
519
|
},
|
|
@@ -102,6 +102,49 @@ const TITLE_LIMIT = 60;
|
|
|
102
102
|
const PREVIEW_LIMIT = 100;
|
|
103
103
|
const DEFAULT_TITLE = "New conversation";
|
|
104
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
|
+
|
|
105
148
|
/** The drawer-index entry; `titleCustom` (private) freezes a renamed title. */
|
|
106
149
|
interface StoredThread {
|
|
107
150
|
threadId: string;
|
|
@@ -122,15 +165,59 @@ interface StoredThread {
|
|
|
122
165
|
* An optional `namespace` scopes every key to one element, so two
|
|
123
166
|
* `<ag-ui-chat>` instances on the same origin keep separate active-thread
|
|
124
167
|
* pointers and drawer indexes instead of clobbering each other. The default
|
|
125
|
-
* 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}.
|
|
126
170
|
*/
|
|
127
171
|
export class SessionStorageStore implements ClientConversationStore {
|
|
128
172
|
readonly #root: string;
|
|
129
173
|
|
|
130
174
|
constructor(namespace = "") {
|
|
131
|
-
this.#root = namespace
|
|
175
|
+
this.#root = rootFor(namespace);
|
|
132
176
|
if (namespace !== "") {
|
|
133
|
-
|
|
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);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
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);
|
|
134
221
|
}
|
|
135
222
|
}
|
|
136
223
|
|
|
@@ -140,8 +227,8 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
140
227
|
|
|
141
228
|
newThread(): string {
|
|
142
229
|
const id = randomUUID();
|
|
143
|
-
|
|
144
|
-
|
|
230
|
+
writeStoredItem(this.#key(THREAD_SUFFIX), id);
|
|
231
|
+
writeStoredItem(this.#key(MINTED_SUFFIX + id), "1");
|
|
145
232
|
return id;
|
|
146
233
|
}
|
|
147
234
|
|
|
@@ -157,7 +244,7 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
157
244
|
}
|
|
158
245
|
|
|
159
246
|
saveMessages(threadId: string, messages: readonly Message[]): void {
|
|
160
|
-
|
|
247
|
+
writeStoredItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
|
|
161
248
|
sessionStorage.removeItem(this.#key(MINTED_SUFFIX + threadId));
|
|
162
249
|
this.#touchThread(threadId, messages);
|
|
163
250
|
}
|
|
@@ -172,7 +259,7 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
172
259
|
sessionStorage.removeItem(key);
|
|
173
260
|
return;
|
|
174
261
|
}
|
|
175
|
-
|
|
262
|
+
writeStoredItem(key, JSON.stringify(checkpoint));
|
|
176
263
|
}
|
|
177
264
|
|
|
178
265
|
clear(threadId: string): void {
|
|
@@ -196,7 +283,7 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
196
283
|
}
|
|
197
284
|
|
|
198
285
|
setActiveThread(threadId: string): void {
|
|
199
|
-
|
|
286
|
+
writeStoredItem(this.#key(THREAD_SUFFIX), threadId);
|
|
200
287
|
}
|
|
201
288
|
|
|
202
289
|
renameThread(threadId: string, title: string): void {
|
|
@@ -244,7 +331,7 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
244
331
|
sessionStorage.removeItem(key);
|
|
245
332
|
return;
|
|
246
333
|
}
|
|
247
|
-
|
|
334
|
+
writeStoredItem(key, JSON.stringify(threads));
|
|
248
335
|
}
|
|
249
336
|
|
|
250
337
|
/** This store's fully-qualified key for a suffix (namespaced when set). */
|
|
@@ -252,37 +339,6 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
252
339
|
return `${this.#root}:${suffix}`;
|
|
253
340
|
}
|
|
254
341
|
|
|
255
|
-
/**
|
|
256
|
-
* One-time move of un-namespaced `ag-ui-chat:*` keys into this instance's
|
|
257
|
-
* namespace, so an existing conversation isn't orphaned. Only this store's own
|
|
258
|
-
* keys move — the element's `collapsed` / `theme` keys are left alone. The
|
|
259
|
-
* first namespaced instance to mount adopts the data; a second namespace
|
|
260
|
-
* finds it gone and starts fresh.
|
|
261
|
-
*/
|
|
262
|
-
#migrateLegacyKeys(): void {
|
|
263
|
-
const legacyRoot = `${KEY_ROOT}:`;
|
|
264
|
-
const moves: Array<readonly [string, string]> = [];
|
|
265
|
-
for (let i = 0; i < sessionStorage.length; i += 1) {
|
|
266
|
-
const key = sessionStorage.key(i);
|
|
267
|
-
if (key === null || !key.startsWith(legacyRoot)) {
|
|
268
|
-
continue;
|
|
269
|
-
}
|
|
270
|
-
const suffix = key.slice(legacyRoot.length);
|
|
271
|
-
if (isOwnedSuffix(suffix)) {
|
|
272
|
-
moves.push([key, this.#key(suffix)]);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
// Collected first, mutated second — writing while iterating by index skips
|
|
276
|
-
// entries as the key list shifts.
|
|
277
|
-
for (const [from, to] of moves) {
|
|
278
|
-
const value = sessionStorage.getItem(from);
|
|
279
|
-
if (value !== null && sessionStorage.getItem(to) === null) {
|
|
280
|
-
sessionStorage.setItem(to, value);
|
|
281
|
-
}
|
|
282
|
-
sessionStorage.removeItem(from);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
342
|
/** Parse a stored JSON value, returning `null` when absent or corrupt. */
|
|
287
343
|
#readJson<T>(key: string): T | null {
|
|
288
344
|
const raw = sessionStorage.getItem(key);
|
|
@@ -297,13 +353,43 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
297
353
|
}
|
|
298
354
|
}
|
|
299
355
|
|
|
300
|
-
/**
|
|
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). */
|
|
301
386
|
function isOwnedSuffix(suffix: string): boolean {
|
|
302
387
|
return (
|
|
303
388
|
suffix === THREAD_SUFFIX ||
|
|
304
389
|
suffix === THREADS_SUFFIX ||
|
|
305
390
|
suffix.startsWith(MESSAGES_SUFFIX) ||
|
|
306
|
-
suffix.startsWith(CHECKPOINT_SUFFIX)
|
|
391
|
+
suffix.startsWith(CHECKPOINT_SUFFIX) ||
|
|
392
|
+
suffix.startsWith(MINTED_SUFFIX)
|
|
307
393
|
);
|
|
308
394
|
}
|
|
309
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
|
}
|
|
@@ -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 {
|
|
@@ -84,8 +109,16 @@ export class RemoteConversationStore implements ClientConversationStore {
|
|
|
84
109
|
}
|
|
85
110
|
|
|
86
111
|
saveMessages(threadId: string, messages: readonly Message[]): void {
|
|
87
|
-
// The agent run persists server-side; keep a local cache for offline replay
|
|
88
|
-
|
|
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 : []);
|
|
89
122
|
}
|
|
90
123
|
|
|
91
124
|
loadCheckpoint(threadId: string): NavigationCheckpoint | null {
|
package/src/core/utils.ts
CHANGED
|
@@ -38,3 +38,61 @@ export function mintThread(store: ClientConversationStore): string {
|
|
|
38
38
|
store.setActiveThread(id);
|
|
39
39
|
return id;
|
|
40
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;
|