@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.
- package/CHANGELOG.md +112 -1
- package/README.md +277 -16
- package/dist/ag-ui-web-component.bundle.js +407 -382
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/core/ag_ui_chat.d.ts +82 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/create_http_agent.d.ts +9 -0
- package/dist/core/create_http_agent.d.ts.map +1 -1
- package/dist/core/remote_conversation_store.d.ts +8 -1
- package/dist/core/remote_conversation_store.d.ts.map +1 -1
- package/dist/core/run_index.d.ts +8 -1
- package/dist/core/run_index.d.ts.map +1 -1
- package/dist/core/transcribe_audio.d.ts +7 -0
- package/dist/core/transcribe_audio.d.ts.map +1 -1
- package/dist/core/upload_attachment.d.ts +9 -0
- package/dist/core/upload_attachment.d.ts.map +1 -1
- package/dist/core/utils.d.ts +12 -0
- package/dist/core/utils.d.ts.map +1 -0
- package/dist/dom/animations.d.ts +51 -11
- package/dist/dom/animations.d.ts.map +1 -1
- package/dist/dom/dom_driver.d.ts +12 -7
- package/dist/dom/dom_driver.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +735 -424
- package/dist/index.js.map +3 -3
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/core/ag_ui_chat.ts +255 -23
- package/src/core/create_http_agent.ts +14 -3
- package/src/core/remote_conversation_store.ts +25 -6
- package/src/core/run_index.ts +27 -5
- package/src/core/transcribe_audio.ts +20 -5
- package/src/core/upload_attachment.ts +13 -0
- package/src/core/utils.ts +18 -0
- package/src/dom/animations.ts +175 -31
- package/src/dom/dom_driver.ts +18 -12
- package/src/index.ts +2 -0
- package/src/ui/styles.ts +377 -352
- package/src/version.ts +1 -1
package/src/core/ag_ui_chat.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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(
|
|
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
|
|
511
|
-
getHeaders: () => this
|
|
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, {
|
|
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) =>
|
|
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
|
|
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,
|
|
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
|
-
/**
|
|
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,
|
|
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
|
|
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
|
|
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(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
}
|
package/src/core/run_index.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
52
|
-
|
|
53
|
-
|
|
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(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
}
|