@artooi/ag-ui-web-component 0.20.1 → 0.21.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +112 -1
  2. package/README.md +277 -16
  3. package/dist/ag-ui-web-component.bundle.js +407 -382
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/core/ag_ui_chat.d.ts +82 -1
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/create_http_agent.d.ts +9 -0
  8. package/dist/core/create_http_agent.d.ts.map +1 -1
  9. package/dist/core/remote_conversation_store.d.ts +8 -1
  10. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  11. package/dist/core/run_index.d.ts +8 -1
  12. package/dist/core/run_index.d.ts.map +1 -1
  13. package/dist/core/transcribe_audio.d.ts +7 -0
  14. package/dist/core/transcribe_audio.d.ts.map +1 -1
  15. package/dist/core/upload_attachment.d.ts +9 -0
  16. package/dist/core/upload_attachment.d.ts.map +1 -1
  17. package/dist/core/utils.d.ts +12 -0
  18. package/dist/core/utils.d.ts.map +1 -0
  19. package/dist/dom/animations.d.ts +51 -11
  20. package/dist/dom/animations.d.ts.map +1 -1
  21. package/dist/dom/dom_driver.d.ts +12 -7
  22. package/dist/dom/dom_driver.d.ts.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +735 -424
  26. package/dist/index.js.map +3 -3
  27. package/dist/ui/styles.d.ts +1 -1
  28. package/dist/ui/styles.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/core/ag_ui_chat.ts +255 -23
  31. package/src/core/create_http_agent.ts +14 -3
  32. package/src/core/remote_conversation_store.ts +25 -6
  33. package/src/core/run_index.ts +27 -5
  34. package/src/core/transcribe_audio.ts +20 -5
  35. package/src/core/upload_attachment.ts +13 -0
  36. package/src/core/utils.ts +18 -0
  37. package/src/dom/animations.ts +175 -31
  38. package/src/dom/dom_driver.ts +18 -12
  39. package/src/index.ts +2 -0
  40. package/src/ui/styles.ts +377 -352
  41. package/src/version.ts +1 -1
@@ -75,6 +75,7 @@ import { RemoteConversationStore } from "./remote_conversation_store.js";
75
75
  import { RunIndex } from "./run_index.js";
76
76
  import { type TranscribeHandler, transcribeAudio } from "./transcribe_audio.js";
77
77
  import { type UploadHandler, uploadAttachment } from "./upload_attachment.js";
78
+ import { withCredentials } from "./utils.js";
78
79
 
79
80
  /** The role a rendered chat message takes. */
80
81
  export type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE];
@@ -133,6 +134,18 @@ const CONNECT_TIME_ATTRIBUTES = [
133
134
  "data-icon-url",
134
135
  ] as const;
135
136
 
137
+ /**
138
+ * The cookie policies `fetch` accepts. Anything else is a configuration
139
+ * mistake, and one that would otherwise surface as an unexplained 401 from a
140
+ * request the browser silently sent anonymously.
141
+ */
142
+ const CREDENTIALS_MODES: readonly string[] = ["omit", "same-origin", "include"];
143
+
144
+ /** Whether `value` is one of the three modes `fetch` understands. */
145
+ function isCredentialsMode(value: string): value is RequestCredentials {
146
+ return CREDENTIALS_MODES.includes(value);
147
+ }
148
+
136
149
  /** Per-tab persistence key for the collapsed state (survives MPA reloads). */
137
150
  const COLLAPSED_KEY = "ag-ui-chat:collapsed";
138
151
 
@@ -158,9 +171,34 @@ export class AgUiChat extends HTMLElement {
158
171
  /** Agent factory; override to inject a custom or fake agent (tests). */
159
172
  agentFactory: AgentFactory = createHttpAgent;
160
173
 
161
- /** Extra HTTP headers for the AG-UI endpoint (e.g. CSRF). */
174
+ /**
175
+ * Static extra HTTP headers, sent with **every** request this element makes —
176
+ * the agent run, the thread index and its messages, the tool and skill
177
+ * catalogs, the run index, uploads and transcription.
178
+ *
179
+ * Right for values fixed for the element's lifetime. A credential that
180
+ * rotates (a short-lived JWT, a re-issued CSRF token) belongs in
181
+ * {@link getHeaders} instead: this is read at request time, but only a
182
+ * re-assignment updates it, so a token captured here is pinned until the host
183
+ * remembers to assign again.
184
+ */
162
185
  headers: Record<string, string> = {};
163
186
 
187
+ /**
188
+ * Live header source, consulted immediately before every request — the way to
189
+ * supply rotating credentials.
190
+ *
191
+ * Set it to a function and each request calls it afresh: a token refreshed by
192
+ * the host between two requests reaches the second one, with nothing to
193
+ * re-assign and nothing to keep in sync.
194
+ *
195
+ * Composes with {@link headers} rather than replacing it: the two are merged
196
+ * per key with `getHeaders()` winning, so a static `X-Client` and a rotating
197
+ * `Authorization` can be configured independently and neither silently drops
198
+ * the other.
199
+ */
200
+ getHeaders: (() => Record<string, string>) | null = null;
201
+
164
202
  /**
165
203
  * Permit `<img>` in rendered assistant markdown. **Off by default**: a
166
204
  * model-controlled image URL is fetched with no user interaction, which
@@ -474,7 +512,11 @@ export class AgUiChat extends HTMLElement {
474
512
  return null;
475
513
  }
476
514
  if (this.#runIndex === null) {
477
- this.#runIndex = new RunIndex(url, () => this.headers);
515
+ this.#runIndex = new RunIndex(
516
+ url,
517
+ () => this.#requestHeaders(),
518
+ () => this.#requestCredentials(),
519
+ );
478
520
  }
479
521
  return this.#runIndex;
480
522
  }
@@ -507,8 +549,9 @@ export class AgUiChat extends HTMLElement {
507
549
  const endpoint = verb === "resume" ? index.resumeUrl(runId) : index.forkUrl(runId);
508
550
  const agent = this.agentFactory({
509
551
  endpoint,
510
- headers: this.headers,
511
- getHeaders: () => this.headers,
552
+ headers: this.#requestHeaders(),
553
+ getHeaders: () => this.#requestHeaders(),
554
+ ...this.#credentialsOption(),
512
555
  threadId: this.#threadId,
513
556
  // The seed the endpoints assume: nothing. The snapshot is the history.
514
557
  initialMessages: [],
@@ -533,10 +576,24 @@ export class AgUiChat extends HTMLElement {
533
576
 
534
577
  /** Attributes the element reacts to after it has been connected. */
535
578
  static get observedAttributes(): string[] {
536
- return ["title-text", "placement", ...CONNECT_TIME_ATTRIBUTES];
579
+ return ["title-text", "placement", "credentials", ...CONNECT_TIME_ATTRIBUTES];
537
580
  }
538
581
 
539
582
  attributeChangedCallback(name: string, previous: string | null, value: string | null): void {
583
+ if (name === "credentials") {
584
+ // Reported the moment the attribute is written — before connect, and
585
+ // whether it came from markup or the property setter. An unrecognised
586
+ // mode is otherwise inert, and the request it was meant to authorise
587
+ // goes out anonymously with nothing to show for it.
588
+ if (value !== null && !isCredentialsMode(value)) {
589
+ console.error(
590
+ `<ag-ui-chat>: credentials="${value}" is not a fetch credentials mode ` +
591
+ `(${CREDENTIALS_MODES.join(" / ")}) — it is being ignored, so requests use ` +
592
+ "the browser default and cross-origin cookies will not be sent.",
593
+ );
594
+ }
595
+ return;
596
+ }
540
597
  if (name === "placement") {
541
598
  // A placement owns the axes it fixes, so hand those back before anything
542
599
  // else: a size dragged under the previous placement would otherwise sit
@@ -777,6 +834,82 @@ export class AgUiChat extends HTMLElement {
777
834
  this.setAttribute("endpoint", value);
778
835
  }
779
836
 
837
+ /**
838
+ * Cookie policy for **every** request this element makes, as `fetch`'s own
839
+ * `credentials` mode (`"omit"` / `"same-origin"` / `"include"`). Mirrored to
840
+ * the `credentials` attribute, so markup embeds can set it without script.
841
+ *
842
+ * `null` (the default) leaves the browser's default of `same-origin` in
843
+ * place. That default sends **no cookies at all** when the endpoints live on
844
+ * a different origin from the page — app.example.com calling
845
+ * api.example.com is cross-origin — and the request goes out anonymously
846
+ * rather than failing, so the symptom is a 401 from a server that looks
847
+ * correctly configured. A cookie-authenticated cross-origin deployment wants
848
+ * `"include"`, plus `Access-Control-Allow-Credentials: true` and a concrete
849
+ * (non-wildcard) `Access-Control-Allow-Origin` on the server.
850
+ *
851
+ * Read per request, so a late assignment applies to everything after it.
852
+ * `"omit"` cannot be honoured by the built-in **upload** transport, which is
853
+ * an `XMLHttpRequest` and only has a two-state cookie switch; every other
854
+ * endpoint honours all three modes.
855
+ */
856
+ get credentials(): RequestCredentials | null {
857
+ const attr = this.getAttribute("credentials");
858
+ return attr !== null && isCredentialsMode(attr) ? attr : null;
859
+ }
860
+
861
+ set credentials(value: RequestCredentials | null) {
862
+ if (value === null) {
863
+ this.removeAttribute("credentials");
864
+ return;
865
+ }
866
+ // Thrown, not warned: an unrecognised mode is inert at request time, and
867
+ // the whole failure this option exists to fix is a request that goes out
868
+ // wrong without saying so. Fail where the mistake was made instead.
869
+ if (!isCredentialsMode(value)) {
870
+ throw new TypeError(
871
+ `<ag-ui-chat>: credentials must be one of ${CREDENTIALS_MODES.map((mode) => `"${mode}"`).join(", ")} ` +
872
+ `(got ${JSON.stringify(value)}).`,
873
+ );
874
+ }
875
+ this.setAttribute("credentials", value);
876
+ }
877
+
878
+ /**
879
+ * The headers for the request about to go out: the static {@link headers}
880
+ * with {@link getHeaders}'s live values overlaid, per key.
881
+ *
882
+ * Every request site goes through here, so "how this element authenticates"
883
+ * is one answer rather than one per endpoint.
884
+ */
885
+ #requestHeaders(): Record<string, string> {
886
+ return { ...this.headers, ...this.getHeaders?.() };
887
+ }
888
+
889
+ /** The configured cookie policy as `fetch` spells it; `undefined` when unset. */
890
+ #requestCredentials(): RequestCredentials | undefined {
891
+ return this.credentials ?? undefined;
892
+ }
893
+
894
+ /**
895
+ * The `credentials` entry for an {@link AgentFactory} call, or nothing at all.
896
+ *
897
+ * Spread rather than assigned: `exactOptionalPropertyTypes` rejects an
898
+ * explicit `credentials: undefined`, and a factory should see the field
899
+ * absent — not present-and-empty — when no policy is configured. The agent
900
+ * reads it when it is built (first send, thread switch, continuation), by
901
+ * which time any host configuration has landed.
902
+ */
903
+ #credentialsOption(): { credentials?: RequestCredentials } {
904
+ const credentials = this.#requestCredentials();
905
+ return credentials === undefined ? {} : { credentials };
906
+ }
907
+
908
+ /** The `fetch` init for the element's own plain GETs (catalogs). */
909
+ #fetchInit(): RequestInit | undefined {
910
+ return withCredentials({ headers: this.#requestHeaders() }, this.#requestCredentials());
911
+ }
912
+
780
913
  /**
781
914
  * How much detail tool-call cards show, from the `data-tool-display`
782
915
  * attribute (`minimal` / `inline` / `compact` / `full`). Defaults to `full`.
@@ -833,7 +966,6 @@ export class AgUiChat extends HTMLElement {
833
966
  }
834
967
  this.#syncRail();
835
968
  this.#initSkills();
836
- void this.#fetchToolCatalog();
837
969
  // Namespace the built-in default store too (a host-injected store is used
838
970
  // verbatim). Must precede #wireThreadStore, which wraps the current store.
839
971
  if (this.#storageNs !== "" && this.conversationStore instanceof SessionStorageStore) {
@@ -843,12 +975,72 @@ export class AgUiChat extends HTMLElement {
843
975
  this.#wireAttachments();
844
976
  this.#wireVoice();
845
977
  this.#threadId = this.conversationStore.threadId();
978
+ // The catalog requests go out a microtask later, so a host configuring
979
+ // through a framework ref still has a chance to be heard — see #startup.
980
+ queueMicrotask(() => this.#startup());
846
981
  void this.#rehydrate();
847
982
  // Last: everything above reads (and some of it sets) attributes, and none
848
983
  // of that should trip the connect-time warning.
849
984
  this.#connected = true;
850
985
  }
851
986
 
987
+ /**
988
+ * The catalog requests the element issues on startup: the tool labels
989
+ * (`data-tools-url`) and the backend skills (`data-skills-url`).
990
+ *
991
+ * Deliberately one microtask behind `connectedCallback`. A host that
992
+ * configures the element through a framework ref necessarily does so *after*
993
+ * inserting the node — React attaches refs and runs layout effects in the
994
+ * same commit as the insertion, but strictly afterwards — so a request issued
995
+ * from `connectedCallback` itself goes out before `headers`,
996
+ * {@link getHeaders} or {@link credentials} exist, and comes back 401 in a
997
+ * way that reads as a server fault rather than a mis-timed assignment. A
998
+ * microtask lands after that commit and still before paint.
999
+ *
1000
+ * Two things it is **not**. It is not a fix for configuration that arrives
1001
+ * later than the commit (a passive `useEffect`, an awaited token fetch):
1002
+ * configure before insertion (`createElement` → configure → `append`) or call
1003
+ * {@link reload} once configured, because a longer timer would hide that race
1004
+ * rather than close it. And it deliberately excludes the *history* replay,
1005
+ * which stays in `connectedCallback`: the replay renders into the transcript,
1006
+ * so deferring it lets a `sendMessage()` issued in the same task land first
1007
+ * and the replay then duplicate it. The thread history is therefore the one
1008
+ * request that can still go out before a ref is attached — {@link reload}
1009
+ * covers it.
1010
+ */
1011
+ #startup(): void {
1012
+ // An element can be inserted and removed inside one task (a discarded
1013
+ // render, a double-mount); nothing should go out for a node that has
1014
+ // already left the document.
1015
+ if (!this.#connected) {
1016
+ return;
1017
+ }
1018
+ void this.#fetchToolCatalog();
1019
+ void this.#fetchSkills();
1020
+ }
1021
+
1022
+ /**
1023
+ * Re-run everything the element loads on startup — the tool-label catalog,
1024
+ * the backend skill catalog and the thread's history — with the transport
1025
+ * configuration as it stands now.
1026
+ *
1027
+ * This is the answer for a host that can only configure the element after the
1028
+ * fact (a token fetched in a passive effect, an async auth handshake): the
1029
+ * startup requests already went out with whatever was set then, and this says
1030
+ * "try again, properly authenticated" without removing and re-inserting the
1031
+ * node.
1032
+ *
1033
+ * A reload, not a merge — the in-flight run is cancelled and the transcript
1034
+ * is rebuilt from the persisted history, so anything streamed since is
1035
+ * dropped. Call it once, when configuration lands; not between turns.
1036
+ */
1037
+ async reload(): Promise<void> {
1038
+ this.#cancelRun();
1039
+ this.#resetState();
1040
+ this.#setRunning(false);
1041
+ await Promise.all([this.#fetchToolCatalog(), this.#fetchSkills(), this.#rehydrate()]);
1042
+ }
1043
+
852
1044
  /**
853
1045
  * Tear down live resources when the element leaves the DOM (a removed node, a
854
1046
  * client-side route swap): cancel the in-flight run so its SSE stream closes,
@@ -940,7 +1132,13 @@ export class AgUiChat extends HTMLElement {
940
1132
  // Forward the tray's abort signal so removing a chip (or tearing the
941
1133
  // element down) cancels the XHR.
942
1134
  return (file, onProgress, signal) =>
943
- uploadAttachment(file, { url, headers: this.headers, onProgress, signal });
1135
+ uploadAttachment(file, {
1136
+ url,
1137
+ headers: this.#requestHeaders(),
1138
+ ...this.#credentialsOption(),
1139
+ onProgress,
1140
+ signal,
1141
+ });
944
1142
  }
945
1143
 
946
1144
  /**
@@ -969,7 +1167,12 @@ export class AgUiChat extends HTMLElement {
969
1167
  if (url === null) {
970
1168
  return null;
971
1169
  }
972
- return (audio) => transcribeAudio(audio, { url, headers: this.headers });
1170
+ return (audio) =>
1171
+ transcribeAudio(audio, {
1172
+ url,
1173
+ headers: this.#requestHeaders(),
1174
+ ...this.#credentialsOption(),
1175
+ });
973
1176
  }
974
1177
 
975
1178
  /** Drop a voice transcript into the composer (appended to any typed text). */
@@ -1050,8 +1253,9 @@ export class AgUiChat extends HTMLElement {
1050
1253
  if (url !== null) {
1051
1254
  this.conversationStore = new RemoteConversationStore(
1052
1255
  url,
1053
- () => this.headers,
1256
+ () => this.#requestHeaders(),
1054
1257
  this.conversationStore,
1258
+ () => this.#requestCredentials(),
1055
1259
  );
1056
1260
  }
1057
1261
  }
@@ -1063,7 +1267,7 @@ export class AgUiChat extends HTMLElement {
1063
1267
  return;
1064
1268
  }
1065
1269
  try {
1066
- const response = await fetch(url, { headers: this.headers });
1270
+ const response = await fetch(url, this.#fetchInit());
1067
1271
  this.#toolCatalog = parseToolCatalog(await response.json());
1068
1272
  } catch {
1069
1273
  // Network/parse failure: cards fall back to toolSummaries / raw names.
@@ -1079,13 +1283,16 @@ export class AgUiChat extends HTMLElement {
1079
1283
  this.#recomputeSkills();
1080
1284
  }
1081
1285
 
1082
- /** Wire the skill surfaces: opt-in flags, embedded catalog, optional fetch. */
1286
+ /**
1287
+ * Wire the skill surfaces: opt-in flags and the embedded catalog. The backend
1288
+ * catalog is fetched from `#startup`, a microtask later, so it carries the
1289
+ * host's transport configuration.
1290
+ */
1083
1291
  #initSkills(): void {
1084
1292
  this.#skillsMenu.enableChips(this.#flag("data-prompt-chips"));
1085
1293
  this.#skillsMenu.enableSlash(this.#flag("data-slash-commands"));
1086
1294
  this.#embedSkills = this.#readEmbeddedSkills();
1087
1295
  this.#recomputeSkills();
1088
- void this.#fetchSkills();
1089
1296
  }
1090
1297
 
1091
1298
  /** Parse the inline `data-skills` JSON catalog (empty when absent/malformed). */
@@ -1108,7 +1315,7 @@ export class AgUiChat extends HTMLElement {
1108
1315
  return;
1109
1316
  }
1110
1317
  try {
1111
- const response = await fetch(url, { headers: this.headers });
1318
+ const response = await fetch(url, this.#fetchInit());
1112
1319
  this.#backendSkills = parseSkills(await response.json());
1113
1320
  this.#recomputeSkills();
1114
1321
  } catch {
@@ -1397,6 +1604,34 @@ export class AgUiChat extends HTMLElement {
1397
1604
  this.#themeToggle.textContent = dark ? "☀️" : "🌙";
1398
1605
  }
1399
1606
 
1607
+ /**
1608
+ * Open the thread-history drawer: the imperative route to the control that
1609
+ * renders as `::part(history-button)`.
1610
+ *
1611
+ * A host that hides `::part(header)` to render its own title bar hides the
1612
+ * history, new-chat and collapse buttons with it — and thread switching then
1613
+ * has no route at all, because those controls live inside the header. Each of
1614
+ * them has a method, so a host chrome can rebuild the set: this one,
1615
+ * {@link openCheckpoints}, {@link newChat}, {@link toggleCollapsed} and
1616
+ * {@link toggleTheme}.
1617
+ */
1618
+ openThreads(): void {
1619
+ void this.#refreshDrawer();
1620
+ this.#drawer.open();
1621
+ }
1622
+
1623
+ /**
1624
+ * Open the checkpoints panel (the `::part(checkpoints-button)` route).
1625
+ *
1626
+ * It lists the runs the `data-runs-url` server reports as continuable;
1627
+ * without that attribute the built-in button is never rendered and this opens
1628
+ * an empty panel.
1629
+ */
1630
+ openCheckpoints(): void {
1631
+ void this.#refreshCheckpoints();
1632
+ this.#checkpoints.open();
1633
+ }
1634
+
1400
1635
  /**
1401
1636
  * Start a fresh conversation: forget the persisted history, drop the
1402
1637
  * in-memory run state, clear the transcript, and mint a new thread id.
@@ -1713,17 +1948,13 @@ export class AgUiChat extends HTMLElement {
1713
1948
  controls.className = "header-controls";
1714
1949
  controls.setAttribute("part", "header-controls");
1715
1950
 
1951
+ // Both controls delegate to the public methods, so a host chrome driving
1952
+ // them imperatively takes exactly the path the built-in button takes.
1716
1953
  const history = this.#headerButton("history", this.#strings.chatHistory, "☰");
1717
- history.addEventListener("click", () => {
1718
- void this.#refreshDrawer();
1719
- this.#drawer.open();
1720
- });
1954
+ history.addEventListener("click", () => this.openThreads());
1721
1955
 
1722
1956
  const checkpoints = this.#headerButton("checkpoints", this.#strings.checkpoints, "⭯");
1723
- checkpoints.addEventListener("click", () => {
1724
- void this.#refreshCheckpoints();
1725
- this.#checkpoints.open();
1726
- });
1957
+ checkpoints.addEventListener("click", () => this.openCheckpoints());
1727
1958
 
1728
1959
  const newChat = this.#headerButton("new", this.#strings.newChat, "✚");
1729
1960
  newChat.addEventListener("click", () => this.newChat());
@@ -2098,11 +2329,12 @@ export class AgUiChat extends HTMLElement {
2098
2329
  if (this.#client === null) {
2099
2330
  const agent = this.agentFactory({
2100
2331
  endpoint: this.endpoint,
2101
- headers: this.headers,
2332
+ headers: this.#requestHeaders(),
2102
2333
  // Live getter: the client is built once and cached, but a rotated
2103
2334
  // token must still reach every request — the factory's fetch wrapper
2104
2335
  // re-reads this on each call.
2105
- getHeaders: () => this.headers,
2336
+ getHeaders: () => this.#requestHeaders(),
2337
+ ...this.#credentialsOption(),
2106
2338
  threadId: this.#threadId,
2107
2339
  initialMessages: this.#initialMessages,
2108
2340
  initialState: this.#sharedState,
@@ -1,10 +1,20 @@
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
4
 
4
5
  /** Config for {@link createHttpAgent}. */
5
6
  export interface HttpAgentOptions {
6
7
  endpoint: string;
7
8
  headers?: Record<string, string>;
9
+ /**
10
+ * Cookie policy for the run request, as `fetch`'s own `credentials` mode.
11
+ * Unset leaves the browser default (`same-origin`) alone — which sends **no**
12
+ * cookies when the agent endpoint is on a different origin (or a different
13
+ * subdomain) from the page. A cookie-authenticated cross-origin deployment
14
+ * needs `"include"` here, and a server that answers it with
15
+ * `Access-Control-Allow-Credentials: true` and a concrete origin.
16
+ */
17
+ credentials?: RequestCredentials;
8
18
  /**
9
19
  * Live header source, re-read on **every** request. `HttpAgent` bakes the
10
20
  * static `headers` into its constructor and the element caches the agent
@@ -43,17 +53,18 @@ export function createHttpAgent(options: HttpAgentOptions): AbstractAgent {
43
53
  // "Illegal invocation" in browsers. Wrap it so `fetch` is always called as
44
54
  // a free function with the correct receiver. The wrapper also overlays
45
55
  // `getHeaders()` per request, so header rotation (CSRF, short-lived JWT)
46
- // reaches the stream even though the agent instance is cached.
56
+ // reaches the stream even though the agent instance is cached, and applies
57
+ // the configured cookie policy — the agent's own config has no seam for it.
47
58
  fetch: (url, init) => {
48
59
  const fresh = options.getHeaders?.();
49
60
  if (fresh === undefined) {
50
- return fetch(url, init);
61
+ return fetch(url, withCredentials(init, options.credentials));
51
62
  }
52
63
  const headers = new Headers(init?.headers);
53
64
  for (const [name, value] of Object.entries(fresh)) {
54
65
  headers.set(name, value);
55
66
  }
56
- return fetch(url, { ...init, headers });
67
+ return fetch(url, withCredentials({ ...init, headers }, options.credentials));
57
68
  },
58
69
  // Spread conditionally: under `exactOptionalPropertyTypes` an explicit
59
70
  // `undefined` is not assignable to these optional config fields.
@@ -5,6 +5,7 @@ import {
5
5
  SessionStorageStore,
6
6
  type ThreadMeta,
7
7
  } from "./conversation_store.js";
8
+ import { withCredentials } from "./utils.js";
8
9
 
9
10
  /** One row of the server thread index (django-ag-ui's `ThreadsView` wire shape). */
10
11
  interface ServerThreadRow {
@@ -17,6 +18,14 @@ interface ServerThreadRow {
17
18
  /** Live header source, read per request so rotated tokens / CSRF reach the server. */
18
19
  type HeadersProvider = () => Record<string, string>;
19
20
 
21
+ /**
22
+ * Live cookie policy, read per request. A provider rather than a value because
23
+ * the store is built once (on connect) and kept, while a host may configure the
24
+ * element after inserting it — a captured value would pin whatever was set
25
+ * during that first frame.
26
+ */
27
+ type CredentialsProvider = () => RequestCredentials | undefined;
28
+
20
29
  /**
21
30
  * A {@link ClientConversationStore} backed by a server thread-index endpoint —
22
31
  * django-ag-ui's owner-scoped `ThreadsView`, the URL passed to `<ag-ui-chat>`
@@ -37,6 +46,7 @@ export class RemoteConversationStore implements ClientConversationStore {
37
46
  readonly #url: string;
38
47
  readonly #headers: HeadersProvider;
39
48
  readonly #local: ClientConversationStore;
49
+ readonly #credentials: CredentialsProvider;
40
50
  readonly #dropped = new Set<string>();
41
51
  readonly #renamed = new Map<string, string>();
42
52
 
@@ -44,10 +54,12 @@ export class RemoteConversationStore implements ClientConversationStore {
44
54
  url: string,
45
55
  headers: HeadersProvider = () => ({}),
46
56
  local: ClientConversationStore = new SessionStorageStore(),
57
+ credentials: CredentialsProvider = () => undefined,
47
58
  ) {
48
59
  this.#url = url.endsWith("/") ? url : `${url}/`;
49
60
  this.#headers = headers;
50
61
  this.#local = local;
62
+ this.#credentials = credentials;
51
63
  }
52
64
 
53
65
  threadId(): string {
@@ -142,7 +154,7 @@ export class RemoteConversationStore implements ClientConversationStore {
142
154
  /** GET that resolves to the `Response`, or `null` on a network error. */
143
155
  async #get(url: string): Promise<Response | null> {
144
156
  try {
145
- return await fetch(url, { headers: this.#headers() });
157
+ return await fetch(url, withCredentials({ headers: this.#headers() }, this.#credentials()));
146
158
  } catch {
147
159
  return null;
148
160
  }
@@ -156,11 +168,18 @@ export class RemoteConversationStore implements ClientConversationStore {
156
168
  ): Promise<void> {
157
169
  const headers = this.#headers();
158
170
  try {
159
- await fetch(`${this.#url}${encodeURIComponent(threadId)}/`, {
160
- method,
161
- headers: body === undefined ? headers : { ...headers, "content-type": "application/json" },
162
- body: body === undefined ? null : JSON.stringify(body),
163
- });
171
+ await fetch(
172
+ `${this.#url}${encodeURIComponent(threadId)}/`,
173
+ withCredentials(
174
+ {
175
+ method,
176
+ headers:
177
+ body === undefined ? headers : { ...headers, "content-type": "application/json" },
178
+ body: body === undefined ? null : JSON.stringify(body),
179
+ },
180
+ this.#credentials(),
181
+ ),
182
+ );
164
183
  } catch {
165
184
  // Best-effort; the optimistic overlay keeps the drawer consistent.
166
185
  }
@@ -1,3 +1,5 @@
1
+ import { withCredentials } from "./utils.js";
2
+
1
3
  /** One row of the server run index (django-ag-ui's `RunsView` wire shape). */
2
4
  export interface RunRow {
3
5
  readonly run_id: string;
@@ -11,6 +13,14 @@ export interface RunRow {
11
13
  /** Live header source, read per request so rotated tokens / CSRF reach the server. */
12
14
  type HeadersProvider = () => Record<string, string>;
13
15
 
16
+ /**
17
+ * Live cookie policy, read per request. A provider rather than a value because
18
+ * the index is built once (on connect) and kept, while a host may configure the
19
+ * element after inserting it — a captured value would pin whatever was set
20
+ * during that first frame.
21
+ */
22
+ type CredentialsProvider = () => RequestCredentials | undefined;
23
+
14
24
  /**
15
25
  * Reads the server's run index and derives the resume / fork URLs beside it.
16
26
  *
@@ -34,10 +44,16 @@ type HeadersProvider = () => Record<string, string>;
34
44
  export class RunIndex {
35
45
  readonly #url: string;
36
46
  readonly #headers: HeadersProvider;
47
+ readonly #credentials: CredentialsProvider;
37
48
 
38
- constructor(url: string, headers: HeadersProvider = () => ({})) {
49
+ constructor(
50
+ url: string,
51
+ headers: HeadersProvider = () => ({}),
52
+ credentials: CredentialsProvider = () => undefined,
53
+ ) {
39
54
  this.#url = url.endsWith("/") ? url : `${url}/`;
40
55
  this.#headers = headers;
56
+ this.#credentials = credentials;
41
57
  }
42
58
 
43
59
  /**
@@ -48,10 +64,16 @@ export class RunIndex {
48
64
  */
49
65
  async list(): Promise<readonly RunRow[]> {
50
66
  try {
51
- const response = await fetch(this.#url, {
52
- method: "GET",
53
- headers: { Accept: "application/json", ...this.#headers() },
54
- });
67
+ const response = await fetch(
68
+ this.#url,
69
+ withCredentials(
70
+ {
71
+ method: "GET",
72
+ headers: { Accept: "application/json", ...this.#headers() },
73
+ },
74
+ this.#credentials(),
75
+ ),
76
+ );
55
77
  if (!response.ok) {
56
78
  return [];
57
79
  }
@@ -1,3 +1,5 @@
1
+ import { withCredentials } from "./utils.js";
2
+
1
3
  /**
2
4
  * The composer's voice-transcription contract: take a recorded audio `Blob` and
3
5
  * resolve to the transcript text. The built-in handler is {@link transcribeAudio}
@@ -13,6 +15,13 @@ export interface TranscribeOptions {
13
15
  readonly url: string;
14
16
  /** Extra HTTP headers (CSRF / auth), read fresh per request. */
15
17
  readonly headers?: Record<string, string>;
18
+ /**
19
+ * Cookie policy, as `fetch`'s own `credentials` mode. Unset leaves the
20
+ * browser default (`same-origin`), which sends no cookies to an endpoint on
21
+ * another origin; a cookie-authenticated cross-origin deployment needs
22
+ * `"include"`.
23
+ */
24
+ readonly credentials?: RequestCredentials;
16
25
  }
17
26
 
18
27
  /**
@@ -29,11 +38,17 @@ export async function transcribeAudio(audio: Blob, options: TranscribeOptions):
29
38
  // reads the blob's content type).
30
39
  form.append("audio", audio, "recording.webm");
31
40
 
32
- const response = await fetch(options.url, {
33
- method: "POST",
34
- headers: { ...(options.headers ?? {}) },
35
- body: form,
36
- });
41
+ const response = await fetch(
42
+ options.url,
43
+ withCredentials(
44
+ {
45
+ method: "POST",
46
+ headers: { ...(options.headers ?? {}) },
47
+ body: form,
48
+ },
49
+ options.credentials,
50
+ ),
51
+ );
37
52
  if (!response.ok) {
38
53
  throw new Error(await errorMessage(response));
39
54
  }
@@ -26,6 +26,15 @@ export interface UploadOptions {
26
26
  readonly url: string;
27
27
  /** Extra HTTP headers (CSRF / auth), read fresh per upload. */
28
28
  readonly headers?: Record<string, string>;
29
+ /**
30
+ * Cookie policy, spelled as `fetch`'s `credentials` mode for consistency with
31
+ * the component's other endpoints — but carried by `XMLHttpRequest`, which
32
+ * only has the two-state `withCredentials`. `"include"` sets it; every other
33
+ * value leaves it off, which matches `"same-origin"`. `"omit"` therefore
34
+ * **cannot** be honoured for a same-origin upload (XHR always sends cookies
35
+ * there); use a custom {@link UploadHandler} if that matters.
36
+ */
37
+ readonly credentials?: RequestCredentials;
29
38
  /** Progress callback, `0..1`, fired as the body uploads. */
30
39
  readonly onProgress?: (fraction: number) => void;
31
40
  /** Abort signal to cancel the in-flight upload. */
@@ -48,6 +57,10 @@ export function uploadAttachment(file: File, options: UploadOptions): Promise<At
48
57
 
49
58
  const xhr = new XMLHttpRequest();
50
59
  xhr.open("POST", options.url);
60
+ // Cross-origin cookies ride only when asked for, exactly as with the fetch
61
+ // sites; without this an upload to another subdomain is anonymous and 401s
62
+ // while the run itself succeeds.
63
+ xhr.withCredentials = options.credentials === "include";
51
64
  for (const [key, value] of Object.entries(options.headers ?? {})) {
52
65
  xhr.setRequestHeader(key, value);
53
66
  }