@artooi/ag-ui-web-component 0.9.0 → 0.10.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 +61 -7
- package/dist/ag-ui-web-component.bundle.js +56 -56
- package/dist/ag-ui-web-component.bundle.js.map +3 -3
- package/dist/core/ag_ui_chat.d.ts +10 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/attachment.d.ts +5 -0
- package/dist/core/attachment.d.ts.map +1 -1
- package/dist/core/conversation_store.d.ts +8 -0
- package/dist/core/conversation_store.d.ts.map +1 -1
- package/dist/core/remote_conversation_store.d.ts.map +1 -1
- package/dist/core/upload_attachment.d.ts +8 -2
- package/dist/core/upload_attachment.d.ts.map +1 -1
- package/dist/index.js +300 -58
- package/dist/index.js.map +2 -2
- package/dist/ui/attachment_tray.d.ts +7 -1
- package/dist/ui/attachment_tray.d.ts.map +1 -1
- package/dist/ui/relative_time.d.ts +5 -3
- package/dist/ui/relative_time.d.ts.map +1 -1
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/thoughts_block.d.ts +2 -2
- package/dist/ui/thread_drawer.d.ts.map +1 -1
- package/dist/ui/tool_call_card.d.ts.map +1 -1
- package/dist/ui/voice_input.d.ts +9 -1
- package/dist/ui/voice_input.d.ts.map +1 -1
- package/dist/version.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/core/ag_ui_chat.ts +79 -11
- package/src/core/agui_client.ts +14 -3
- package/src/core/attachment.ts +21 -1
- package/src/core/conversation_store.ts +84 -18
- package/src/core/remote_conversation_store.ts +24 -3
- package/src/core/upload_attachment.ts +8 -1
- package/src/ui/attachment_tray.ts +42 -5
- package/src/ui/relative_time.ts +8 -3
- package/src/ui/styles.ts +9 -9
- package/src/ui/thoughts_block.ts +2 -2
- package/src/ui/thread_drawer.ts +83 -9
- package/src/ui/tool_call_card.ts +6 -0
- package/src/ui/voice_input.ts +21 -1
- package/src/version.ts +1 -1
package/dist/index.js
CHANGED
|
@@ -591,7 +591,8 @@ var AttachmentTray = class {
|
|
|
591
591
|
status: ATTACHMENT_STATUS.UPLOADING,
|
|
592
592
|
progress: 0,
|
|
593
593
|
ref: null,
|
|
594
|
-
error: ""
|
|
594
|
+
error: "",
|
|
595
|
+
controller: null
|
|
595
596
|
};
|
|
596
597
|
this.#items.push(item);
|
|
597
598
|
const rejection = this.#reject(file);
|
|
@@ -629,11 +630,24 @@ var AttachmentTray = class {
|
|
|
629
630
|
this.#items = this.#items.filter((item) => item.status === ATTACHMENT_STATUS.UPLOADING);
|
|
630
631
|
this.#render();
|
|
631
632
|
}
|
|
632
|
-
/** Drop every chip (a reset / new-chat). */
|
|
633
|
+
/** Drop every chip (a reset / new-chat), aborting any in-flight upload. */
|
|
633
634
|
clear() {
|
|
635
|
+
for (const item of this.#items) {
|
|
636
|
+
item.controller?.abort();
|
|
637
|
+
}
|
|
634
638
|
this.#items = [];
|
|
635
639
|
this.#render();
|
|
636
640
|
}
|
|
641
|
+
/**
|
|
642
|
+
* Abort every in-flight upload without touching the rendered chips — the
|
|
643
|
+
* teardown path when the host element is removed mid-upload, so a cancelled
|
|
644
|
+
* transfer doesn't orphan a server-side file.
|
|
645
|
+
*/
|
|
646
|
+
dispose() {
|
|
647
|
+
for (const item of this.#items) {
|
|
648
|
+
item.controller?.abort();
|
|
649
|
+
}
|
|
650
|
+
}
|
|
637
651
|
/** The size/type rejection reason for a file, or `null` when accepted. */
|
|
638
652
|
#reject(file) {
|
|
639
653
|
if (this.#config.maxBytes > 0 && file.size > this.#config.maxBytes) {
|
|
@@ -645,25 +659,41 @@ var AttachmentTray = class {
|
|
|
645
659
|
return null;
|
|
646
660
|
}
|
|
647
661
|
#upload(item) {
|
|
662
|
+
const rejection = this.#reject(item.file);
|
|
663
|
+
if (rejection !== null) {
|
|
664
|
+
item.status = ATTACHMENT_STATUS.ERROR;
|
|
665
|
+
item.error = rejection;
|
|
666
|
+
this.#render();
|
|
667
|
+
this.#config.onChange?.();
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
648
670
|
item.status = ATTACHMENT_STATUS.UPLOADING;
|
|
649
671
|
item.progress = 0;
|
|
650
672
|
item.error = "";
|
|
673
|
+
const controller = new AbortController();
|
|
674
|
+
item.controller = controller;
|
|
651
675
|
this.#render();
|
|
652
|
-
this.#config.upload(
|
|
653
|
-
item.
|
|
654
|
-
|
|
655
|
-
|
|
676
|
+
this.#config.upload(
|
|
677
|
+
item.file,
|
|
678
|
+
(fraction) => {
|
|
679
|
+
item.progress = fraction;
|
|
680
|
+
this.#render();
|
|
681
|
+
},
|
|
682
|
+
controller.signal
|
|
683
|
+
).then((ref) => {
|
|
656
684
|
item.status = ATTACHMENT_STATUS.READY;
|
|
657
685
|
item.ref = ref;
|
|
658
686
|
}).catch((error) => {
|
|
659
687
|
item.status = ATTACHMENT_STATUS.ERROR;
|
|
660
688
|
item.error = error instanceof Error ? error.message : this.#strings.uploadFailed;
|
|
661
689
|
}).finally(() => {
|
|
690
|
+
item.controller = null;
|
|
662
691
|
this.#render();
|
|
663
692
|
this.#config.onChange?.();
|
|
664
693
|
});
|
|
665
694
|
}
|
|
666
695
|
#remove(item) {
|
|
696
|
+
item.controller?.abort();
|
|
667
697
|
this.#items = this.#items.filter((other) => other !== item);
|
|
668
698
|
this.#render();
|
|
669
699
|
this.#config.onChange?.();
|
|
@@ -3617,7 +3647,7 @@ var STYLES = `
|
|
|
3617
3647
|
--ag-ui-radius: 0;
|
|
3618
3648
|
}
|
|
3619
3649
|
|
|
3620
|
-
/* Page
|
|
3650
|
+
/* Page: full-bleed background with a centred reading column. Unlike
|
|
3621
3651
|
"full" (edge-to-edge, left-aligned messages) the content sits in a column
|
|
3622
3652
|
capped at --ag-ui-content-max-width. The column is produced by symmetric auto
|
|
3623
3653
|
padding on the scroll area + composer (no per-row wrapper), so user pills
|
|
@@ -3659,7 +3689,7 @@ var STYLES = `
|
|
|
3659
3689
|
max-width: 100%;
|
|
3660
3690
|
}
|
|
3661
3691
|
|
|
3662
|
-
/* Sidebar
|
|
3692
|
+
/* Sidebar: a full-height docked panel that slides open/closed and
|
|
3663
3693
|
collapses to a slim icon rail (not the floating launcher). Docked right by
|
|
3664
3694
|
default; data-side="left" docks it left. Overlay by default \u2014 set
|
|
3665
3695
|
--ag-ui-position: static (and place this element in your own layout) for a
|
|
@@ -3769,7 +3799,7 @@ var STYLES = `
|
|
|
3769
3799
|
white-space: nowrap;
|
|
3770
3800
|
}
|
|
3771
3801
|
|
|
3772
|
-
/* Header / launcher icon holder
|
|
3802
|
+
/* Header / launcher icon holder: a slot, with a data-icon-url <img>
|
|
3773
3803
|
fallback, sized via --ag-ui-icon-size. */
|
|
3774
3804
|
.icon-holder {
|
|
3775
3805
|
display: inline-flex;
|
|
@@ -3843,7 +3873,7 @@ var STYLES = `
|
|
|
3843
3873
|
gap: var(--ag-ui-space);
|
|
3844
3874
|
}
|
|
3845
3875
|
|
|
3846
|
-
/* Empty-state region (
|
|
3876
|
+
/* Empty-state region (slot): centred while it's the only thing in the
|
|
3847
3877
|
list, hidden as soon as a message, card, or pending indicator renders. */
|
|
3848
3878
|
.empty {
|
|
3849
3879
|
margin: auto;
|
|
@@ -3855,7 +3885,7 @@ var STYLES = `
|
|
|
3855
3885
|
display: none;
|
|
3856
3886
|
}
|
|
3857
3887
|
|
|
3858
|
-
/* \u2500\u2500 Answer group / well
|
|
3888
|
+
/* \u2500\u2500 Answer group / well \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3859
3889
|
One .answer per assistant turn wraps the streamed text, its tool cards,
|
|
3860
3890
|
and the pending indicator so a whole answer reads (and can be boxed) as one
|
|
3861
3891
|
unit. A flex column on the message-list gap, stretched to the list width so
|
|
@@ -4024,7 +4054,7 @@ var STYLES = `
|
|
|
4024
4054
|
}
|
|
4025
4055
|
}
|
|
4026
4056
|
|
|
4027
|
-
/* \u2500\u2500 Thoughts region
|
|
4057
|
+
/* \u2500\u2500 Thoughts region \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4028
4058
|
A muted, collapsible chain-of-thought at the top of the answer group: open
|
|
4029
4059
|
while the model reasons, folded once the answer text starts. */
|
|
4030
4060
|
.thoughts {
|
|
@@ -4117,7 +4147,7 @@ var STYLES = `
|
|
|
4117
4147
|
word-break: break-word;
|
|
4118
4148
|
}
|
|
4119
4149
|
|
|
4120
|
-
/* Leading status icon
|
|
4150
|
+
/* Leading status icon. Empty in the DOM \u2014 the glyph/spinner is drawn
|
|
4121
4151
|
here from the card's data-status, so it stays themeable. */
|
|
4122
4152
|
.tool-call-icon {
|
|
4123
4153
|
flex: none;
|
|
@@ -4165,7 +4195,7 @@ var STYLES = `
|
|
|
4165
4195
|
}
|
|
4166
4196
|
}
|
|
4167
4197
|
|
|
4168
|
-
/* Inline display mode
|
|
4198
|
+
/* Inline display mode: the lightest card \u2014 drop the box chrome so the
|
|
4169
4199
|
status row reads as one line of the answer; the result toggle still expands
|
|
4170
4200
|
below it. */
|
|
4171
4201
|
.tool-call[data-display="inline"] {
|
|
@@ -4295,7 +4325,7 @@ var STYLES = `
|
|
|
4295
4325
|
display: none;
|
|
4296
4326
|
}
|
|
4297
4327
|
|
|
4298
|
-
/* The \u{1F3A4} mic button
|
|
4328
|
+
/* The \u{1F3A4} mic button; shown only once #wireVoice mounts it. */
|
|
4299
4329
|
.voice-slot {
|
|
4300
4330
|
display: contents;
|
|
4301
4331
|
}
|
|
@@ -4810,6 +4840,9 @@ var ThoughtsBlock = class {
|
|
|
4810
4840
|
|
|
4811
4841
|
// src/ui/relative_time.ts
|
|
4812
4842
|
function relativeTime(timestamp, now = Date.now(), strings = DEFAULT_UI_STRINGS) {
|
|
4843
|
+
if (!Number.isFinite(timestamp)) {
|
|
4844
|
+
return strings.justNow;
|
|
4845
|
+
}
|
|
4813
4846
|
const seconds = Math.round((now - timestamp) / 1e3);
|
|
4814
4847
|
if (seconds < 60) {
|
|
4815
4848
|
return strings.justNow;
|
|
@@ -4841,6 +4874,8 @@ var ThreadDrawer = class {
|
|
|
4841
4874
|
#strings;
|
|
4842
4875
|
#threads = [];
|
|
4843
4876
|
#activeId = "";
|
|
4877
|
+
/** The element focused before the drawer opened, restored on close. */
|
|
4878
|
+
#lastFocused = null;
|
|
4844
4879
|
constructor(callbacks, strings = DEFAULT_UI_STRINGS) {
|
|
4845
4880
|
this.#callbacks = callbacks;
|
|
4846
4881
|
this.#strings = strings;
|
|
@@ -4856,7 +4891,9 @@ var ThreadDrawer = class {
|
|
|
4856
4891
|
this.#panel.className = "drawer-panel";
|
|
4857
4892
|
this.#panel.setAttribute("part", "drawer-panel");
|
|
4858
4893
|
this.#panel.setAttribute("role", "dialog");
|
|
4894
|
+
this.#panel.setAttribute("aria-modal", "true");
|
|
4859
4895
|
this.#panel.setAttribute("aria-label", strings.chatHistory);
|
|
4896
|
+
this.#panel.addEventListener("keydown", (event) => this.#onPanelKeydown(event));
|
|
4860
4897
|
const header = document.createElement("div");
|
|
4861
4898
|
header.className = "drawer-header";
|
|
4862
4899
|
header.setAttribute("part", "drawer-header");
|
|
@@ -4892,13 +4929,55 @@ var ThreadDrawer = class {
|
|
|
4892
4929
|
return !this.element.hidden;
|
|
4893
4930
|
}
|
|
4894
4931
|
open() {
|
|
4932
|
+
if (this.isOpen()) {
|
|
4933
|
+
return;
|
|
4934
|
+
}
|
|
4935
|
+
this.#lastFocused = this.#activeElement();
|
|
4895
4936
|
this.element.hidden = false;
|
|
4937
|
+
this.#newButton.focus();
|
|
4896
4938
|
}
|
|
4897
4939
|
close() {
|
|
4940
|
+
if (!this.isOpen()) {
|
|
4941
|
+
return;
|
|
4942
|
+
}
|
|
4898
4943
|
this.element.hidden = true;
|
|
4944
|
+
this.#lastFocused?.focus();
|
|
4945
|
+
this.#lastFocused = null;
|
|
4899
4946
|
}
|
|
4900
4947
|
toggle() {
|
|
4901
|
-
this.
|
|
4948
|
+
if (this.isOpen()) {
|
|
4949
|
+
this.close();
|
|
4950
|
+
} else {
|
|
4951
|
+
this.open();
|
|
4952
|
+
}
|
|
4953
|
+
}
|
|
4954
|
+
/** The currently-focused element within the drawer's root (shadow-aware). */
|
|
4955
|
+
#activeElement() {
|
|
4956
|
+
return this.element.getRootNode().activeElement;
|
|
4957
|
+
}
|
|
4958
|
+
/** Escape-to-close and a Tab focus trap while the dialog is open. */
|
|
4959
|
+
#onPanelKeydown(event) {
|
|
4960
|
+
if (event.key === "Escape") {
|
|
4961
|
+
event.preventDefault();
|
|
4962
|
+
this.close();
|
|
4963
|
+
return;
|
|
4964
|
+
}
|
|
4965
|
+
if (event.key !== "Tab") {
|
|
4966
|
+
return;
|
|
4967
|
+
}
|
|
4968
|
+
const focusables = Array.from(
|
|
4969
|
+
this.#panel.querySelectorAll("button, input, [tabindex]")
|
|
4970
|
+
).filter((el) => !el.hidden);
|
|
4971
|
+
const first = focusables[0];
|
|
4972
|
+
const last = focusables[focusables.length - 1];
|
|
4973
|
+
const active = this.#activeElement();
|
|
4974
|
+
if (event.shiftKey && active === first) {
|
|
4975
|
+
event.preventDefault();
|
|
4976
|
+
last?.focus();
|
|
4977
|
+
} else if (!event.shiftKey && active === last) {
|
|
4978
|
+
event.preventDefault();
|
|
4979
|
+
first?.focus();
|
|
4980
|
+
}
|
|
4902
4981
|
}
|
|
4903
4982
|
/** Render the rows (or the empty state), highlighting the active thread. */
|
|
4904
4983
|
setThreads(threads, activeId) {
|
|
@@ -4965,24 +5044,43 @@ var ThreadDrawer = class {
|
|
|
4965
5044
|
row.append(select, actions);
|
|
4966
5045
|
return row;
|
|
4967
5046
|
}
|
|
4968
|
-
/** Swap a row for an inline rename input; Enter commits, Escape cancels. */
|
|
5047
|
+
/** Swap a row for an inline rename input; Enter/blur commits, Escape cancels. */
|
|
4969
5048
|
#startRename(row, meta) {
|
|
4970
5049
|
const input = document.createElement("input");
|
|
4971
5050
|
input.type = "text";
|
|
4972
5051
|
input.className = "drawer-rename-input";
|
|
4973
5052
|
input.value = meta.title;
|
|
5053
|
+
let done = false;
|
|
5054
|
+
const commit = () => {
|
|
5055
|
+
if (done) {
|
|
5056
|
+
return;
|
|
5057
|
+
}
|
|
5058
|
+
done = true;
|
|
5059
|
+
const value = input.value.trim();
|
|
5060
|
+
if (value === "" || value === meta.title) {
|
|
5061
|
+
this.#renderList();
|
|
5062
|
+
} else {
|
|
5063
|
+
this.#callbacks.onRename(meta.threadId, value);
|
|
5064
|
+
}
|
|
5065
|
+
};
|
|
5066
|
+
const cancel = () => {
|
|
5067
|
+
if (done) {
|
|
5068
|
+
return;
|
|
5069
|
+
}
|
|
5070
|
+
done = true;
|
|
5071
|
+
this.#renderList();
|
|
5072
|
+
};
|
|
4974
5073
|
input.addEventListener("keydown", (event) => {
|
|
4975
5074
|
if (event.key === "Enter") {
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
this.#renderList();
|
|
4979
|
-
} else {
|
|
4980
|
-
this.#callbacks.onRename(meta.threadId, value);
|
|
4981
|
-
}
|
|
5075
|
+
event.preventDefault();
|
|
5076
|
+
commit();
|
|
4982
5077
|
} else if (event.key === "Escape") {
|
|
4983
|
-
|
|
5078
|
+
event.preventDefault();
|
|
5079
|
+
event.stopPropagation();
|
|
5080
|
+
cancel();
|
|
4984
5081
|
}
|
|
4985
5082
|
});
|
|
5083
|
+
input.addEventListener("blur", () => commit());
|
|
4986
5084
|
row.replaceChildren(input);
|
|
4987
5085
|
input.focus();
|
|
4988
5086
|
input.select();
|
|
@@ -5078,6 +5176,9 @@ var ToolCallCard = class {
|
|
|
5078
5176
|
* `inline`), or the args + result together (`compact`).
|
|
5079
5177
|
*/
|
|
5080
5178
|
settle(status, text2) {
|
|
5179
|
+
if (this.#settled) {
|
|
5180
|
+
return;
|
|
5181
|
+
}
|
|
5081
5182
|
this.#settled = true;
|
|
5082
5183
|
this.element.setAttribute("data-status", status);
|
|
5083
5184
|
this.#status.textContent = statusLabels(this.#strings)[status];
|
|
@@ -5122,6 +5223,7 @@ var VoiceInput = class {
|
|
|
5122
5223
|
#recorder = null;
|
|
5123
5224
|
#stream = null;
|
|
5124
5225
|
#chunks = [];
|
|
5226
|
+
#disposed = false;
|
|
5125
5227
|
constructor(options) {
|
|
5126
5228
|
this.#transcribe = options.transcribe;
|
|
5127
5229
|
this.#onText = options.onText;
|
|
@@ -5171,7 +5273,25 @@ var VoiceInput = class {
|
|
|
5171
5273
|
#stop() {
|
|
5172
5274
|
this.#recorder?.stop();
|
|
5173
5275
|
}
|
|
5276
|
+
/**
|
|
5277
|
+
* Tear the control down — the teardown path when the host element is removed
|
|
5278
|
+
* mid-recording. Stops any live `MediaRecorder`, releases the mic tracks (so
|
|
5279
|
+
* the browser's recording indicator clears), and suppresses the pending
|
|
5280
|
+
* transcription: a disconnected control must not fire `onText` back into a
|
|
5281
|
+
* detached element.
|
|
5282
|
+
*/
|
|
5283
|
+
dispose() {
|
|
5284
|
+
this.#disposed = true;
|
|
5285
|
+
if (this.#recorder !== null && this.#recorder.state !== "inactive") {
|
|
5286
|
+
this.#recorder.stop();
|
|
5287
|
+
}
|
|
5288
|
+
this.#recorder = null;
|
|
5289
|
+
this.#releaseStream();
|
|
5290
|
+
}
|
|
5174
5291
|
async #finish(mimeType) {
|
|
5292
|
+
if (this.#disposed) {
|
|
5293
|
+
return;
|
|
5294
|
+
}
|
|
5175
5295
|
this.#releaseStream();
|
|
5176
5296
|
this.#setState("transcribing");
|
|
5177
5297
|
const audio = new Blob(this.#chunks, { type: mimeType || "audio/webm" });
|
|
@@ -5329,7 +5449,7 @@ var AgUiClient = class {
|
|
|
5329
5449
|
return;
|
|
5330
5450
|
}
|
|
5331
5451
|
const pending = [];
|
|
5332
|
-
const runState = { terminal: false };
|
|
5452
|
+
const runState = { terminal: false, errored: false };
|
|
5333
5453
|
await this.#agent.runAgent(
|
|
5334
5454
|
{ tools: this.#getTools(), context: this.#getContext() },
|
|
5335
5455
|
this.#buildSubscriber(pending, runState)
|
|
@@ -5341,6 +5461,9 @@ var AgUiClient = class {
|
|
|
5341
5461
|
if (!runState.terminal) {
|
|
5342
5462
|
throw new ConnectionLostError(this.#connectionLostMessage);
|
|
5343
5463
|
}
|
|
5464
|
+
if (runState.errored) {
|
|
5465
|
+
return;
|
|
5466
|
+
}
|
|
5344
5467
|
if (this.#executeTool === null || pending.length === 0) {
|
|
5345
5468
|
return;
|
|
5346
5469
|
}
|
|
@@ -5391,7 +5514,7 @@ var AgUiClient = class {
|
|
|
5391
5514
|
onToolCallResultEvent({ event }) {
|
|
5392
5515
|
h.onToolResult(event.toolCallId, event.content);
|
|
5393
5516
|
},
|
|
5394
|
-
// Reasoning
|
|
5517
|
+
// Reasoning. `@ag-ui/client` already maps the deprecated
|
|
5395
5518
|
// THINKING_* events onto these REASONING_* callbacks, so handling the
|
|
5396
5519
|
// reasoning family alone covers both protocol versions.
|
|
5397
5520
|
onReasoningStartEvent() {
|
|
@@ -5405,6 +5528,7 @@ var AgUiClient = class {
|
|
|
5405
5528
|
},
|
|
5406
5529
|
onRunErrorEvent({ event }) {
|
|
5407
5530
|
runState.terminal = true;
|
|
5531
|
+
runState.errored = true;
|
|
5408
5532
|
h.onError(event.message);
|
|
5409
5533
|
},
|
|
5410
5534
|
onRunFinalized() {
|
|
@@ -5421,40 +5545,56 @@ function isAbortError(error) {
|
|
|
5421
5545
|
// src/core/attachment.ts
|
|
5422
5546
|
function messageAttachments(message) {
|
|
5423
5547
|
const refs = message.attachments;
|
|
5424
|
-
return Array.isArray(refs) ? refs : [];
|
|
5548
|
+
return Array.isArray(refs) ? refs.filter(isAttachmentRef) : [];
|
|
5549
|
+
}
|
|
5550
|
+
function isAttachmentRef(value) {
|
|
5551
|
+
if (typeof value !== "object" || value === null) {
|
|
5552
|
+
return false;
|
|
5553
|
+
}
|
|
5554
|
+
const ref = value;
|
|
5555
|
+
return typeof ref["id"] === "string" && typeof ref["name"] === "string" && typeof ref["mime"] === "string" && typeof ref["size"] === "number" && (ref["url"] === void 0 || typeof ref["url"] === "string");
|
|
5425
5556
|
}
|
|
5426
5557
|
|
|
5427
5558
|
// src/core/conversation_store.ts
|
|
5428
5559
|
import { randomUUID as randomUUID3 } from "@ag-ui/client";
|
|
5429
|
-
var
|
|
5430
|
-
var
|
|
5431
|
-
var
|
|
5432
|
-
var
|
|
5560
|
+
var KEY_ROOT = "ag-ui-chat";
|
|
5561
|
+
var THREAD_SUFFIX = "thread";
|
|
5562
|
+
var THREADS_SUFFIX = "threads";
|
|
5563
|
+
var MESSAGES_SUFFIX = "messages:";
|
|
5564
|
+
var CHECKPOINT_SUFFIX = "checkpoint:";
|
|
5433
5565
|
var TITLE_LIMIT = 60;
|
|
5434
5566
|
var PREVIEW_LIMIT = 100;
|
|
5435
5567
|
var DEFAULT_TITLE = "New conversation";
|
|
5436
5568
|
var SessionStorageStore = class {
|
|
5569
|
+
#root;
|
|
5570
|
+
constructor(namespace = "") {
|
|
5571
|
+
this.#root = namespace === "" ? KEY_ROOT : `${KEY_ROOT}@${namespace}`;
|
|
5572
|
+
if (namespace !== "") {
|
|
5573
|
+
this.#migrateLegacyKeys();
|
|
5574
|
+
}
|
|
5575
|
+
}
|
|
5437
5576
|
threadId() {
|
|
5438
|
-
const
|
|
5577
|
+
const key = this.#key(THREAD_SUFFIX);
|
|
5578
|
+
const existing = sessionStorage.getItem(key);
|
|
5439
5579
|
if (existing !== null) {
|
|
5440
5580
|
return existing;
|
|
5441
5581
|
}
|
|
5442
5582
|
const id = randomUUID3();
|
|
5443
|
-
sessionStorage.setItem(
|
|
5583
|
+
sessionStorage.setItem(key, id);
|
|
5444
5584
|
return id;
|
|
5445
5585
|
}
|
|
5446
5586
|
loadMessages(threadId) {
|
|
5447
|
-
return Promise.resolve(this.#readJson(
|
|
5587
|
+
return Promise.resolve(this.#readJson(this.#key(MESSAGES_SUFFIX + threadId)));
|
|
5448
5588
|
}
|
|
5449
5589
|
saveMessages(threadId, messages) {
|
|
5450
|
-
sessionStorage.setItem(
|
|
5590
|
+
sessionStorage.setItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
|
|
5451
5591
|
this.#touchThread(threadId, messages);
|
|
5452
5592
|
}
|
|
5453
5593
|
loadCheckpoint(threadId) {
|
|
5454
|
-
return this.#readJson(
|
|
5594
|
+
return this.#readJson(this.#key(CHECKPOINT_SUFFIX + threadId));
|
|
5455
5595
|
}
|
|
5456
5596
|
saveCheckpoint(threadId, checkpoint) {
|
|
5457
|
-
const key =
|
|
5597
|
+
const key = this.#key(CHECKPOINT_SUFFIX + threadId);
|
|
5458
5598
|
if (checkpoint === null) {
|
|
5459
5599
|
sessionStorage.removeItem(key);
|
|
5460
5600
|
return;
|
|
@@ -5462,11 +5602,11 @@ var SessionStorageStore = class {
|
|
|
5462
5602
|
sessionStorage.setItem(key, JSON.stringify(checkpoint));
|
|
5463
5603
|
}
|
|
5464
5604
|
clear(threadId) {
|
|
5465
|
-
sessionStorage.removeItem(
|
|
5466
|
-
sessionStorage.removeItem(
|
|
5605
|
+
sessionStorage.removeItem(this.#key(MESSAGES_SUFFIX + threadId));
|
|
5606
|
+
sessionStorage.removeItem(this.#key(CHECKPOINT_SUFFIX + threadId));
|
|
5467
5607
|
this.#writeThreads(this.#readThreads().filter((thread) => thread.threadId !== threadId));
|
|
5468
|
-
if (sessionStorage.getItem(
|
|
5469
|
-
sessionStorage.removeItem(
|
|
5608
|
+
if (sessionStorage.getItem(this.#key(THREAD_SUFFIX)) === threadId) {
|
|
5609
|
+
sessionStorage.removeItem(this.#key(THREAD_SUFFIX));
|
|
5470
5610
|
}
|
|
5471
5611
|
}
|
|
5472
5612
|
listThreads() {
|
|
@@ -5474,7 +5614,7 @@ var SessionStorageStore = class {
|
|
|
5474
5614
|
return Promise.resolve(metas);
|
|
5475
5615
|
}
|
|
5476
5616
|
setActiveThread(threadId) {
|
|
5477
|
-
sessionStorage.setItem(
|
|
5617
|
+
sessionStorage.setItem(this.#key(THREAD_SUFFIX), threadId);
|
|
5478
5618
|
}
|
|
5479
5619
|
renameThread(threadId, title) {
|
|
5480
5620
|
const threads = this.#readThreads();
|
|
@@ -5510,14 +5650,48 @@ var SessionStorageStore = class {
|
|
|
5510
5650
|
this.#writeThreads(threads);
|
|
5511
5651
|
}
|
|
5512
5652
|
#readThreads() {
|
|
5513
|
-
return this.#readJson(
|
|
5653
|
+
return this.#readJson(this.#key(THREADS_SUFFIX)) ?? [];
|
|
5514
5654
|
}
|
|
5515
5655
|
#writeThreads(threads) {
|
|
5656
|
+
const key = this.#key(THREADS_SUFFIX);
|
|
5516
5657
|
if (threads.length === 0) {
|
|
5517
|
-
sessionStorage.removeItem(
|
|
5658
|
+
sessionStorage.removeItem(key);
|
|
5518
5659
|
return;
|
|
5519
5660
|
}
|
|
5520
|
-
sessionStorage.setItem(
|
|
5661
|
+
sessionStorage.setItem(key, JSON.stringify(threads));
|
|
5662
|
+
}
|
|
5663
|
+
/** This store's fully-qualified key for a suffix (namespaced when set). */
|
|
5664
|
+
#key(suffix) {
|
|
5665
|
+
return `${this.#root}:${suffix}`;
|
|
5666
|
+
}
|
|
5667
|
+
/**
|
|
5668
|
+
* One-time move of pre-namespacing (`ag-ui-chat:*`) keys into this instance's
|
|
5669
|
+
* namespace, so an existing conversation isn't orphaned by the upgrade. Only
|
|
5670
|
+
* this store's own keys move (thread pointer, drawer index, per-thread
|
|
5671
|
+
* messages/checkpoints) — the element's `collapsed`/`theme` keys are left
|
|
5672
|
+
* alone. The first namespaced instance to mount adopts the legacy data; a
|
|
5673
|
+
* second namespace finds it gone and starts fresh.
|
|
5674
|
+
*/
|
|
5675
|
+
#migrateLegacyKeys() {
|
|
5676
|
+
const legacyRoot = `${KEY_ROOT}:`;
|
|
5677
|
+
const moves = [];
|
|
5678
|
+
for (let i = 0; i < sessionStorage.length; i += 1) {
|
|
5679
|
+
const key = sessionStorage.key(i);
|
|
5680
|
+
if (key === null || !key.startsWith(legacyRoot)) {
|
|
5681
|
+
continue;
|
|
5682
|
+
}
|
|
5683
|
+
const suffix = key.slice(legacyRoot.length);
|
|
5684
|
+
if (isOwnedSuffix(suffix)) {
|
|
5685
|
+
moves.push([key, this.#key(suffix)]);
|
|
5686
|
+
}
|
|
5687
|
+
}
|
|
5688
|
+
for (const [from, to] of moves) {
|
|
5689
|
+
const value = sessionStorage.getItem(from);
|
|
5690
|
+
if (value !== null && sessionStorage.getItem(to) === null) {
|
|
5691
|
+
sessionStorage.setItem(to, value);
|
|
5692
|
+
}
|
|
5693
|
+
sessionStorage.removeItem(from);
|
|
5694
|
+
}
|
|
5521
5695
|
}
|
|
5522
5696
|
/** Parse a stored JSON value, returning `null` when absent or corrupt. */
|
|
5523
5697
|
#readJson(key) {
|
|
@@ -5532,6 +5706,9 @@ var SessionStorageStore = class {
|
|
|
5532
5706
|
}
|
|
5533
5707
|
}
|
|
5534
5708
|
};
|
|
5709
|
+
function isOwnedSuffix(suffix) {
|
|
5710
|
+
return suffix === THREAD_SUFFIX || suffix === THREADS_SUFFIX || suffix.startsWith(MESSAGES_SUFFIX) || suffix.startsWith(CHECKPOINT_SUFFIX);
|
|
5711
|
+
}
|
|
5535
5712
|
function deriveTitle(messages) {
|
|
5536
5713
|
for (const message of messages) {
|
|
5537
5714
|
if (message.role === "user") {
|
|
@@ -5638,7 +5815,10 @@ var RemoteConversationStore = class {
|
|
|
5638
5815
|
if (response === null || !response.ok) {
|
|
5639
5816
|
return this.#local.loadMessages(threadId);
|
|
5640
5817
|
}
|
|
5641
|
-
const body = await response
|
|
5818
|
+
const body = await this.#readJson(response);
|
|
5819
|
+
if (body === null) {
|
|
5820
|
+
return this.#local.loadMessages(threadId);
|
|
5821
|
+
}
|
|
5642
5822
|
return body.messages ?? null;
|
|
5643
5823
|
}
|
|
5644
5824
|
async #fetchThreads() {
|
|
@@ -5646,14 +5826,28 @@ var RemoteConversationStore = class {
|
|
|
5646
5826
|
if (response === null || !response.ok) {
|
|
5647
5827
|
return null;
|
|
5648
5828
|
}
|
|
5649
|
-
const body = await response
|
|
5829
|
+
const body = await this.#readJson(response);
|
|
5830
|
+
if (body === null) {
|
|
5831
|
+
return null;
|
|
5832
|
+
}
|
|
5650
5833
|
return body.threads ?? [];
|
|
5651
5834
|
}
|
|
5835
|
+
/** Parse a `Response` body as JSON, or `null` when it isn't valid JSON. */
|
|
5836
|
+
async #readJson(response) {
|
|
5837
|
+
try {
|
|
5838
|
+
return await response.json();
|
|
5839
|
+
} catch {
|
|
5840
|
+
return null;
|
|
5841
|
+
}
|
|
5842
|
+
}
|
|
5652
5843
|
#toMeta(row) {
|
|
5653
5844
|
return {
|
|
5654
5845
|
threadId: row.thread_id,
|
|
5655
5846
|
title: this.#renamed.get(row.thread_id) ?? row.title,
|
|
5656
|
-
|
|
5847
|
+
// `null` or an unparseable date both become `NaN` (Date.parse's own
|
|
5848
|
+
// signal), which `relativeTime` renders as a neutral label rather than
|
|
5849
|
+
// "~2950w ago" (epoch 0) or "NaNw ago".
|
|
5850
|
+
updatedAt: row.updated_at === null ? Number.NaN : Date.parse(row.updated_at),
|
|
5657
5851
|
preview: row.preview
|
|
5658
5852
|
};
|
|
5659
5853
|
}
|
|
@@ -5929,7 +6123,7 @@ var AgUiChat = class extends HTMLElement {
|
|
|
5929
6123
|
#attachButton;
|
|
5930
6124
|
#fileInput;
|
|
5931
6125
|
#attachSlot;
|
|
5932
|
-
/** Optional built-in header theme toggle
|
|
6126
|
+
/** Optional built-in header theme toggle; shown only with `data-theme-toggle`. */
|
|
5933
6127
|
#themeToggle;
|
|
5934
6128
|
/** The collapsed-sidebar rail (an expand affordance; shown only for `placement="sidebar"`). */
|
|
5935
6129
|
#rail;
|
|
@@ -5957,18 +6151,25 @@ var AgUiChat = class extends HTMLElement {
|
|
|
5957
6151
|
// it; ≤1 ⇒ it arrived at once and the word reveal is appropriate.
|
|
5958
6152
|
#streamDeltas = 0;
|
|
5959
6153
|
#pending = null;
|
|
5960
|
-
// The current assistant turn's grouping container
|
|
6154
|
+
// The current assistant turn's grouping container. One `.answer`
|
|
5961
6155
|
// wraps everything a single answer produces — streamed text, tool cards, the
|
|
5962
6156
|
// pending indicator — so it can be boxed as one "well" by CSS. Opened on the
|
|
5963
6157
|
// turn's first run start, closed at settle, so it spans the whole multi-round
|
|
5964
6158
|
// frontend-tool loop (which is several AG-UI runs), not one run. `null`
|
|
5965
6159
|
// between turns; user bubbles never enter it.
|
|
5966
6160
|
#currentGroup = null;
|
|
5967
|
-
// The current turn's streamed-reasoning region
|
|
6161
|
+
// The current turn's streamed-reasoning region, shown at the top of
|
|
5968
6162
|
// the answer group while a reasoning model thinks and collapsed once the
|
|
5969
6163
|
// answer's first text token arrives. `null` outside a reasoning turn.
|
|
5970
6164
|
#thoughts = null;
|
|
5971
6165
|
#threadId = "";
|
|
6166
|
+
// Per-instance suffix for the origin-scoped storage keys (collapsed / theme /
|
|
6167
|
+
// active thread), so two instances on one origin don't clobber each other.
|
|
6168
|
+
// Empty ⇒ the pre-namespacing global keys (back-compat). Resolved on connect.
|
|
6169
|
+
#storageNs = "";
|
|
6170
|
+
// Bumped on every #rehydrate; a replay whose generation is stale (a newer
|
|
6171
|
+
// thread switch started while it awaited a slow store) drops its result.
|
|
6172
|
+
#rehydrateGeneration = 0;
|
|
5972
6173
|
#initialMessages = [];
|
|
5973
6174
|
// Skill catalog by source; merged backend → embed → client (later wins).
|
|
5974
6175
|
#backendSkills = [];
|
|
@@ -6112,27 +6313,44 @@ var AgUiChat = class extends HTMLElement {
|
|
|
6112
6313
|
this.setAttribute("data-tool-display", value);
|
|
6113
6314
|
}
|
|
6114
6315
|
connectedCallback() {
|
|
6316
|
+
this.#storageNs = this.id !== "" ? this.id : this.endpoint;
|
|
6115
6317
|
this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
|
|
6116
6318
|
if (this.getAttribute("data-theme-toggle") !== null) {
|
|
6117
|
-
const saved =
|
|
6319
|
+
const saved = this.#readScopedItem(THEME_KEY);
|
|
6118
6320
|
if (saved !== null) {
|
|
6119
6321
|
this.setAttribute("theme", saved);
|
|
6120
6322
|
}
|
|
6121
6323
|
}
|
|
6122
6324
|
this.#render();
|
|
6123
6325
|
this.#drawer.setStrings(this.#strings);
|
|
6124
|
-
if (
|
|
6326
|
+
if (this.#readScopedItem(COLLAPSED_KEY) === "1") {
|
|
6125
6327
|
this.setAttribute("collapsed", "");
|
|
6126
6328
|
}
|
|
6127
6329
|
this.#syncRail();
|
|
6128
6330
|
this.#initSkills();
|
|
6129
6331
|
void this.#fetchToolCatalog();
|
|
6332
|
+
if (this.#storageNs !== "" && this.conversationStore instanceof SessionStorageStore) {
|
|
6333
|
+
this.conversationStore = new SessionStorageStore(this.#storageNs);
|
|
6334
|
+
}
|
|
6130
6335
|
this.#wireThreadStore();
|
|
6131
6336
|
this.#wireAttachments();
|
|
6132
6337
|
this.#wireVoice();
|
|
6133
6338
|
this.#threadId = this.conversationStore.threadId();
|
|
6134
6339
|
void this.#rehydrate();
|
|
6135
6340
|
}
|
|
6341
|
+
/**
|
|
6342
|
+
* Tear down live resources when the element leaves the DOM (a removed node, a
|
|
6343
|
+
* client-side route swap): cancel the in-flight run so its SSE stream closes,
|
|
6344
|
+
* abort any in-flight uploads so they don't orphan server-side files, and
|
|
6345
|
+
* release the mic so the browser's recording indicator clears. Without this a
|
|
6346
|
+
* removed `<ag-ui-chat>` leaks a streaming request, uploads, and a live
|
|
6347
|
+
* `MediaRecorder`.
|
|
6348
|
+
*/
|
|
6349
|
+
disconnectedCallback() {
|
|
6350
|
+
this.#cancelRun();
|
|
6351
|
+
this.#attachTray?.dispose();
|
|
6352
|
+
this.#voice?.dispose();
|
|
6353
|
+
}
|
|
6136
6354
|
/** Parse the inline `data-strings` JSON overrides (empty when absent/malformed). */
|
|
6137
6355
|
#readStringOverrides() {
|
|
6138
6356
|
const raw = this.getAttribute("data-strings");
|
|
@@ -6178,7 +6396,7 @@ var AgUiChat = class extends HTMLElement {
|
|
|
6178
6396
|
if (url === null) {
|
|
6179
6397
|
return null;
|
|
6180
6398
|
}
|
|
6181
|
-
return (file, onProgress) => uploadAttachment(file, { url, headers: this.headers, onProgress });
|
|
6399
|
+
return (file, onProgress, signal) => uploadAttachment(file, { url, headers: this.headers, onProgress, signal });
|
|
6182
6400
|
}
|
|
6183
6401
|
/**
|
|
6184
6402
|
* Reveal the composer's 🎤 mic button when transcription is possible — either
|
|
@@ -6382,7 +6600,7 @@ Use the read_attachment tool with an id to read a file's contents.`
|
|
|
6382
6600
|
} else {
|
|
6383
6601
|
this.removeAttribute("collapsed");
|
|
6384
6602
|
}
|
|
6385
|
-
sessionStorage.setItem(COLLAPSED_KEY, collapsed ? "1" : "0");
|
|
6603
|
+
sessionStorage.setItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
|
|
6386
6604
|
this.#syncRail();
|
|
6387
6605
|
this.dispatchEvent(
|
|
6388
6606
|
new CustomEvent(TOGGLE_EVENT, {
|
|
@@ -6405,9 +6623,25 @@ Use the read_attachment tool with an id to read a file's contents.`
|
|
|
6405
6623
|
toggleTheme() {
|
|
6406
6624
|
const next = this.getAttribute("theme") === "dark" ? "light" : "dark";
|
|
6407
6625
|
this.setAttribute("theme", next);
|
|
6408
|
-
sessionStorage.setItem(THEME_KEY, next);
|
|
6626
|
+
sessionStorage.setItem(this.#storageKey(THEME_KEY), next);
|
|
6409
6627
|
this.#syncThemeGlyph();
|
|
6410
6628
|
}
|
|
6629
|
+
/** This instance's namespaced form of an origin-scoped storage key. */
|
|
6630
|
+
#storageKey(base) {
|
|
6631
|
+
return this.#storageNs === "" ? base : `${base}:${this.#storageNs}`;
|
|
6632
|
+
}
|
|
6633
|
+
/**
|
|
6634
|
+
* Read a namespaced origin-scoped value, falling back once to the legacy
|
|
6635
|
+
* pre-namespacing global key (left in place) so an existing collapsed/theme
|
|
6636
|
+
* preference survives the upgrade.
|
|
6637
|
+
*/
|
|
6638
|
+
#readScopedItem(base) {
|
|
6639
|
+
const scoped = sessionStorage.getItem(this.#storageKey(base));
|
|
6640
|
+
if (scoped !== null || this.#storageNs === "") {
|
|
6641
|
+
return scoped;
|
|
6642
|
+
}
|
|
6643
|
+
return sessionStorage.getItem(base);
|
|
6644
|
+
}
|
|
6411
6645
|
/** Reflect the current theme on the toggle: show the destination's glyph. */
|
|
6412
6646
|
#syncThemeGlyph() {
|
|
6413
6647
|
const dark = this.getAttribute("theme") === "dark";
|
|
@@ -6475,7 +6709,12 @@ Use the read_attachment tool with an id to read a file's contents.`
|
|
|
6475
6709
|
* result from the page we landed on.
|
|
6476
6710
|
*/
|
|
6477
6711
|
async #rehydrate() {
|
|
6712
|
+
this.#rehydrateGeneration += 1;
|
|
6713
|
+
const generation = this.#rehydrateGeneration;
|
|
6478
6714
|
const messages = await this.conversationStore.loadMessages(this.#threadId);
|
|
6715
|
+
if (generation !== this.#rehydrateGeneration) {
|
|
6716
|
+
return;
|
|
6717
|
+
}
|
|
6479
6718
|
if (messages !== null) {
|
|
6480
6719
|
this.#initialMessages = messages;
|
|
6481
6720
|
for (const message of messages) {
|
|
@@ -6562,7 +6801,7 @@ Use the read_attachment tool with an id to read a file's contents.`
|
|
|
6562
6801
|
* stays literal text (no need to parse what the user typed, and it avoids
|
|
6563
6802
|
* rendering user-authored markup).
|
|
6564
6803
|
*
|
|
6565
|
-
* Assistant bubbles land in the current answer group
|
|
6804
|
+
* Assistant bubbles land in the current answer group, opening one if
|
|
6566
6805
|
* needed; a user bubble closes the prior group and sits directly in the list
|
|
6567
6806
|
* (the well wraps the *assistant* turn, the user message precedes it).
|
|
6568
6807
|
*/
|
|
@@ -6796,6 +7035,9 @@ Use the read_attachment tool with an id to read a file's contents.`
|
|
|
6796
7035
|
this.#send.dataset["state"] = running ? "running" : "idle";
|
|
6797
7036
|
}
|
|
6798
7037
|
async #submit() {
|
|
7038
|
+
if (this.#running) {
|
|
7039
|
+
return;
|
|
7040
|
+
}
|
|
6799
7041
|
const content = this.#input.value.trim();
|
|
6800
7042
|
const attachments = this.#attachTray?.readyRefs() ?? [];
|
|
6801
7043
|
if (content === "" && attachments.length === 0) {
|
|
@@ -7113,7 +7355,7 @@ function setControlValue(el, value) {
|
|
|
7113
7355
|
}
|
|
7114
7356
|
|
|
7115
7357
|
// src/version.ts
|
|
7116
|
-
var VERSION = "0.
|
|
7358
|
+
var VERSION = "0.10.0";
|
|
7117
7359
|
export {
|
|
7118
7360
|
AgUiChat,
|
|
7119
7361
|
AgUiClient,
|