@artooi/ag-ui-web-component 0.6.0 → 0.8.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 +73 -1
- package/README.md +180 -9
- package/dist/ag-ui-web-component.bundle.js +268 -47
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +5 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +20 -0
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +16 -0
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +847 -237
- package/dist/index.js.map +3 -3
- package/dist/tools/page_action_tools.d.ts +31 -0
- package/dist/tools/page_action_tools.d.ts.map +1 -0
- package/dist/ui/attachment_tray.d.ts +3 -0
- package/dist/ui/attachment_tray.d.ts.map +1 -1
- package/dist/ui/confirmation_card.d.ts +4 -1
- package/dist/ui/confirmation_card.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/thread_drawer.d.ts +7 -1
- package/dist/ui/thread_drawer.d.ts.map +1 -1
- package/dist/ui/tool_call_card.d.ts +19 -8
- package/dist/ui/tool_call_card.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +126 -0
- package/dist/ui/ui_strings.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/constants.ts +5 -0
- package/src/core/ag_ui_chat.ts +267 -46
- package/src/core/agui_client.ts +33 -2
- package/src/index.ts +7 -0
- package/src/tools/page_action_tools.ts +130 -0
- package/src/ui/attachment_tray.ts +13 -7
- package/src/ui/confirmation_card.ts +15 -5
- package/src/ui/relative_time.ts +15 -8
- package/src/ui/styles.ts +221 -0
- package/src/ui/thread_drawer.ts +53 -25
- package/src/ui/tool_call_card.ts +63 -25
- package/src/ui/ui_strings.ts +208 -0
- package/src/version.ts +1 -1
package/src/core/ag_ui_chat.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { Skill } from "../skills/skill.js";
|
|
|
15
15
|
import { type ClientTool, ClientToolRegistry } from "../tools/client_tool_registry.js";
|
|
16
16
|
import { isDestructive } from "../tools/is_destructive.js";
|
|
17
17
|
import { isNavigates } from "../tools/is_navigates.js";
|
|
18
|
+
import { createPageActionTools, type ResolvePageTarget } from "../tools/page_action_tools.js";
|
|
18
19
|
import { createPageMapContext, type PageMap } from "../tools/page_map.js";
|
|
19
20
|
import { parseToolCatalog } from "../tools/parse_tool_catalog.js";
|
|
20
21
|
import { createRouteTools, type RouteMap } from "../tools/route_map.js";
|
|
@@ -29,6 +30,7 @@ import { SkillsMenu } from "../ui/skills_menu.js";
|
|
|
29
30
|
import { STYLES } from "../ui/styles.js";
|
|
30
31
|
import { ThreadDrawer } from "../ui/thread_drawer.js";
|
|
31
32
|
import { ToolCallCard, type ToolDisplayMode } from "../ui/tool_call_card.js";
|
|
33
|
+
import { DEFAULT_UI_STRINGS, mergeUiStrings, type UiStrings } from "../ui/ui_strings.js";
|
|
32
34
|
import {
|
|
33
35
|
AgUiClient,
|
|
34
36
|
type AgUiClientHandlers,
|
|
@@ -194,12 +196,30 @@ export class AgUiChat extends HTMLElement {
|
|
|
194
196
|
*/
|
|
195
197
|
toolSummaries: Record<string, string> = {};
|
|
196
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Localizable UI strings — a partial override merged over the English
|
|
201
|
+
* {@link DEFAULT_UI_STRINGS}. Resolved once on connect (so set it before the
|
|
202
|
+
* element is appended); the `data-strings` JSON attribute is the markup
|
|
203
|
+
* equivalent, and this property wins key-by-key over it.
|
|
204
|
+
*/
|
|
205
|
+
strings: Partial<UiStrings> = {};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Resolve a `scroll_to` / `drag_and_drop` target string to a host-page
|
|
209
|
+
* element (or `null`). Defaults to a CSS-selector lookup; override to map
|
|
210
|
+
* page-map element ids. The page-action tools are opt-in via the
|
|
211
|
+
* `data-page-actions` attribute (`"scroll"` / `"drag"`).
|
|
212
|
+
*/
|
|
213
|
+
resolvePageTarget: ResolvePageTarget = (target) => document.querySelector<HTMLElement>(target);
|
|
214
|
+
|
|
197
215
|
/**
|
|
198
216
|
* Card labels fetched from a server tool catalog (`data-tools-url`), keyed by
|
|
199
217
|
* tool name. The base layer behind {@link toolSummaries}: an explicit entry in
|
|
200
218
|
* `toolSummaries` wins, this fills the rest. Populated once on connect.
|
|
201
219
|
*/
|
|
202
220
|
#toolCatalog: Record<string, string> = {};
|
|
221
|
+
/** The resolved string table (defaults ← `data-strings` ← `strings`). */
|
|
222
|
+
#strings: UiStrings = DEFAULT_UI_STRINGS;
|
|
203
223
|
|
|
204
224
|
readonly #toolRegistry = new ClientToolRegistry();
|
|
205
225
|
/** Tool-call cards awaiting execution, keyed by call id. */
|
|
@@ -223,6 +243,10 @@ export class AgUiChat extends HTMLElement {
|
|
|
223
243
|
readonly #attachButton: HTMLButtonElement;
|
|
224
244
|
readonly #fileInput: HTMLInputElement;
|
|
225
245
|
readonly #attachSlot: HTMLDivElement;
|
|
246
|
+
/** The collapsed-sidebar rail (an expand affordance; shown only for `placement="sidebar"`). */
|
|
247
|
+
readonly #rail: HTMLButtonElement;
|
|
248
|
+
/** Empty-state region at the top of the message list; hidden once anything renders. */
|
|
249
|
+
readonly #emptyWrap: HTMLDivElement;
|
|
226
250
|
/** Upload tray; created on connect only when `data-attachments-url` is set. */
|
|
227
251
|
#attachTray: AttachmentTray | null = null;
|
|
228
252
|
/** Refs attached to the message currently being sent (the context manifest). */
|
|
@@ -242,6 +266,13 @@ export class AgUiChat extends HTMLElement {
|
|
|
242
266
|
// it; ≤1 ⇒ it arrived at once and the word reveal is appropriate.
|
|
243
267
|
#streamDeltas = 0;
|
|
244
268
|
#pending: HTMLDivElement | null = null;
|
|
269
|
+
// The current assistant turn's grouping container (WELL-1). One `.answer`
|
|
270
|
+
// wraps everything a single answer produces — streamed text, tool cards, the
|
|
271
|
+
// pending indicator — so it can be boxed as one "well" by CSS. Opened on the
|
|
272
|
+
// turn's first run start, closed at settle, so it spans the whole multi-round
|
|
273
|
+
// frontend-tool loop (which is several AG-UI runs), not one run. `null`
|
|
274
|
+
// between turns; user bubbles never enter it.
|
|
275
|
+
#currentGroup: HTMLDivElement | null = null;
|
|
245
276
|
#threadId = "";
|
|
246
277
|
#initialMessages: readonly Message[] = [];
|
|
247
278
|
// Skill catalog by source; merged backend → embed → client (later wins).
|
|
@@ -261,6 +292,8 @@ export class AgUiChat extends HTMLElement {
|
|
|
261
292
|
this.#attachButton = document.createElement("button");
|
|
262
293
|
this.#fileInput = document.createElement("input");
|
|
263
294
|
this.#attachSlot = document.createElement("div");
|
|
295
|
+
this.#rail = document.createElement("button");
|
|
296
|
+
this.#emptyWrap = document.createElement("div");
|
|
264
297
|
this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
|
|
265
298
|
this.#drawer = new ThreadDrawer({
|
|
266
299
|
onSelect: (threadId) => {
|
|
@@ -287,8 +320,9 @@ export class AgUiChat extends HTMLElement {
|
|
|
287
320
|
|
|
288
321
|
attributeChangedCallback(_name: string, _previous: string | null, value: string | null): void {
|
|
289
322
|
// Only `title-text` is observed (other attributes are read at use-time or
|
|
290
|
-
// are CSS-reactive), so update the header title directly.
|
|
291
|
-
|
|
323
|
+
// are CSS-reactive), so update the header title directly. `#strings` is the
|
|
324
|
+
// resolved table once connected, the English defaults before then.
|
|
325
|
+
this.#title.textContent = value ?? this.#strings.title;
|
|
292
326
|
}
|
|
293
327
|
|
|
294
328
|
/** Declare a frontend tool the agent may call. */
|
|
@@ -341,9 +375,29 @@ export class AgUiChat extends HTMLElement {
|
|
|
341
375
|
];
|
|
342
376
|
}
|
|
343
377
|
|
|
344
|
-
/**
|
|
378
|
+
/**
|
|
379
|
+
* Opt-in page-action tools (`scroll_to` / `drag_and_drop`), enabled per token
|
|
380
|
+
* via the `data-page-actions` attribute (e.g. `"scroll,drag"`). Targets resolve
|
|
381
|
+
* through {@link resolvePageTarget} so a host controls the agent's interaction
|
|
382
|
+
* surface; absent attribute ⇒ no tools registered.
|
|
383
|
+
*/
|
|
384
|
+
#pageActionTools(): ClientTool[] {
|
|
385
|
+
const attr = this.getAttribute("data-page-actions");
|
|
386
|
+
if (attr === null) {
|
|
387
|
+
return [];
|
|
388
|
+
}
|
|
389
|
+
const enabled = new Set(
|
|
390
|
+
attr
|
|
391
|
+
.split(",")
|
|
392
|
+
.map((token) => token.trim())
|
|
393
|
+
.filter((token) => token !== ""),
|
|
394
|
+
);
|
|
395
|
+
return createPageActionTools(enabled, (target) => this.resolvePageTarget(target));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** All built-in (route + page + page-action) frontend tools. */
|
|
345
399
|
#builtinTools(): ClientTool[] {
|
|
346
|
-
return [...this.#routeTools(), ...this.#pageTools()];
|
|
400
|
+
return [...this.#routeTools(), ...this.#pageTools(), ...this.#pageActionTools()];
|
|
347
401
|
}
|
|
348
402
|
|
|
349
403
|
/** Resolve a tool by name: built-in tools first, then the registry. */
|
|
@@ -373,7 +427,11 @@ export class AgUiChat extends HTMLElement {
|
|
|
373
427
|
*/
|
|
374
428
|
get toolDisplay(): ToolDisplayMode {
|
|
375
429
|
const attr = this.getAttribute("data-tool-display");
|
|
376
|
-
if (
|
|
430
|
+
if (
|
|
431
|
+
attr === TOOL_DISPLAY.INLINE ||
|
|
432
|
+
attr === TOOL_DISPLAY.MINIMAL ||
|
|
433
|
+
attr === TOOL_DISPLAY.COMPACT
|
|
434
|
+
) {
|
|
377
435
|
return attr;
|
|
378
436
|
}
|
|
379
437
|
return TOOL_DISPLAY.FULL;
|
|
@@ -384,10 +442,15 @@ export class AgUiChat extends HTMLElement {
|
|
|
384
442
|
}
|
|
385
443
|
|
|
386
444
|
connectedCallback(): void {
|
|
445
|
+
// Resolve the string table before rendering any chrome (defaults are the
|
|
446
|
+
// floor; `data-strings` then the `strings` property layer over them).
|
|
447
|
+
this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
|
|
387
448
|
this.#render();
|
|
449
|
+
this.#drawer.setStrings(this.#strings);
|
|
388
450
|
if (sessionStorage.getItem(COLLAPSED_KEY) === "1") {
|
|
389
451
|
this.setAttribute("collapsed", "");
|
|
390
452
|
}
|
|
453
|
+
this.#syncRail();
|
|
391
454
|
this.#initSkills();
|
|
392
455
|
void this.#fetchToolCatalog();
|
|
393
456
|
this.#wireThreadStore();
|
|
@@ -396,6 +459,23 @@ export class AgUiChat extends HTMLElement {
|
|
|
396
459
|
void this.#rehydrate();
|
|
397
460
|
}
|
|
398
461
|
|
|
462
|
+
/** Parse the inline `data-strings` JSON overrides (empty when absent/malformed). */
|
|
463
|
+
#readStringOverrides(): Partial<UiStrings> {
|
|
464
|
+
const raw = this.getAttribute("data-strings");
|
|
465
|
+
if (raw === null) {
|
|
466
|
+
return {};
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
const parsed: unknown = JSON.parse(raw);
|
|
470
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
471
|
+
return parsed as Partial<UiStrings>;
|
|
472
|
+
}
|
|
473
|
+
} catch {
|
|
474
|
+
// Malformed JSON — fall back to the defaults rather than failing to mount.
|
|
475
|
+
}
|
|
476
|
+
return {};
|
|
477
|
+
}
|
|
478
|
+
|
|
399
479
|
/**
|
|
400
480
|
* Enable the composer's file-upload tray when uploads are possible — either a
|
|
401
481
|
* custom {@link uploadHandler} is set or `data-attachments-url` provides the
|
|
@@ -414,6 +494,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
414
494
|
upload,
|
|
415
495
|
maxBytes: this.#attachmentMaxBytes(),
|
|
416
496
|
accept,
|
|
497
|
+
strings: this.#strings,
|
|
417
498
|
});
|
|
418
499
|
this.#attachSlot.appendChild(this.#attachTray.element);
|
|
419
500
|
this.#fileInput.accept = accept;
|
|
@@ -578,7 +659,9 @@ export class AgUiChat extends HTMLElement {
|
|
|
578
659
|
#applySkill(skill: Skill): void {
|
|
579
660
|
const { text, missing } = fillTemplate(skill.prompt, this.skillContext());
|
|
580
661
|
if (missing.length > 0) {
|
|
581
|
-
this.#skillHint.textContent =
|
|
662
|
+
this.#skillHint.textContent = this.#strings.skillNeeds
|
|
663
|
+
.replace("{title}", skill.title)
|
|
664
|
+
.replace("{fields}", missing.join(", "));
|
|
582
665
|
this.#skillHint.hidden = false;
|
|
583
666
|
return;
|
|
584
667
|
}
|
|
@@ -614,6 +697,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
614
697
|
this.removeAttribute("collapsed");
|
|
615
698
|
}
|
|
616
699
|
sessionStorage.setItem(COLLAPSED_KEY, collapsed ? "1" : "0");
|
|
700
|
+
this.#syncRail();
|
|
617
701
|
this.dispatchEvent(
|
|
618
702
|
new CustomEvent<ToggleDetail>(TOGGLE_EVENT, {
|
|
619
703
|
detail: { collapsed },
|
|
@@ -646,13 +730,16 @@ export class AgUiChat extends HTMLElement {
|
|
|
646
730
|
#resetState(): void {
|
|
647
731
|
this.#client = null;
|
|
648
732
|
this.#streamingBubble = null;
|
|
733
|
+
this.#currentGroup = null;
|
|
649
734
|
this.#hidePending();
|
|
650
735
|
this.#toolCards.clear();
|
|
651
736
|
this.#serverSettled.clear();
|
|
652
737
|
this.#initialMessages = [];
|
|
653
738
|
this.#runAttachments = [];
|
|
654
739
|
this.#attachTray?.clear();
|
|
655
|
-
|
|
740
|
+
// Keep the empty-state region; everything else clears.
|
|
741
|
+
this.#messages.replaceChildren(this.#emptyWrap);
|
|
742
|
+
this.#updateEmptyState();
|
|
656
743
|
}
|
|
657
744
|
|
|
658
745
|
/** Switch the active conversation to an existing thread and replay it. */
|
|
@@ -791,86 +878,129 @@ export class AgUiChat extends HTMLElement {
|
|
|
791
878
|
* Assistant content is rendered as sanitised markdown/HTML; user content
|
|
792
879
|
* stays literal text (no need to parse what the user typed, and it avoids
|
|
793
880
|
* rendering user-authored markup).
|
|
881
|
+
*
|
|
882
|
+
* Assistant bubbles land in the current answer group (WELL-1), opening one if
|
|
883
|
+
* needed; a user bubble closes the prior group and sits directly in the list
|
|
884
|
+
* (the well wraps the *assistant* turn, the user message precedes it).
|
|
794
885
|
*/
|
|
795
886
|
appendMessage(role: MessageRole, content: string): HTMLDivElement {
|
|
796
887
|
const bubble = document.createElement("div");
|
|
797
888
|
bubble.className = `message message--${role}`;
|
|
889
|
+
bubble.setAttribute("part", `message message-${role}`);
|
|
798
890
|
if (role === MESSAGE_ROLE.ASSISTANT) {
|
|
799
891
|
bubble.innerHTML = renderMarkdown(content, { allowImages: this.allowImages });
|
|
892
|
+
this.#ensureGroup().appendChild(bubble);
|
|
800
893
|
} else {
|
|
894
|
+
this.#currentGroup = null;
|
|
801
895
|
bubble.textContent = content;
|
|
896
|
+
this.#messages.appendChild(bubble);
|
|
802
897
|
}
|
|
803
|
-
this.#
|
|
898
|
+
this.#updateEmptyState();
|
|
804
899
|
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
805
900
|
return bubble;
|
|
806
901
|
}
|
|
807
902
|
|
|
903
|
+
/**
|
|
904
|
+
* The open answer group, creating and appending it on first use. Everything a
|
|
905
|
+
* single assistant turn renders (text, tool cards, the pending indicator)
|
|
906
|
+
* goes inside it, so the opt-in `data-answer-well` styling can box the whole
|
|
907
|
+
* turn. Idempotent across the turn's runs — it persists until {@link #handlers}'
|
|
908
|
+
* `onSettled` nulls it.
|
|
909
|
+
*/
|
|
910
|
+
#ensureGroup(): HTMLDivElement {
|
|
911
|
+
if (this.#currentGroup === null) {
|
|
912
|
+
const group = document.createElement("div");
|
|
913
|
+
group.className = "answer";
|
|
914
|
+
group.setAttribute("part", "answer");
|
|
915
|
+
this.#currentGroup = group;
|
|
916
|
+
this.#messages.appendChild(group);
|
|
917
|
+
this.#updateEmptyState();
|
|
918
|
+
}
|
|
919
|
+
return this.#currentGroup;
|
|
920
|
+
}
|
|
921
|
+
|
|
808
922
|
#render(): void {
|
|
809
923
|
const style = document.createElement("style");
|
|
810
924
|
style.textContent = STYLES;
|
|
811
925
|
|
|
812
926
|
this.#chat.className = "chat";
|
|
927
|
+
this.#chat.setAttribute("part", "panel");
|
|
813
928
|
|
|
814
929
|
const header = document.createElement("div");
|
|
815
930
|
header.className = "header";
|
|
931
|
+
header.setAttribute("part", "header");
|
|
816
932
|
|
|
817
933
|
const title = this.#title;
|
|
818
934
|
title.className = "header-title";
|
|
819
|
-
title.
|
|
935
|
+
title.setAttribute("part", "title");
|
|
936
|
+
title.textContent = this.getAttribute("title-text") ?? this.#strings.title;
|
|
937
|
+
|
|
938
|
+
// Optional header icon: a slot (any markup) with a `data-icon-url` <img>
|
|
939
|
+
// fallback. Rendered only when one of the two is provided, so the header has
|
|
940
|
+
// no phantom gap otherwise.
|
|
941
|
+
if (
|
|
942
|
+
this.querySelector('[slot="icon"]') !== null ||
|
|
943
|
+
this.getAttribute("data-icon-url") !== null
|
|
944
|
+
) {
|
|
945
|
+
header.append(this.#iconElement("icon", "icon", null));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// A coarse slot for host-provided header actions, between title and controls.
|
|
949
|
+
const headerActions = document.createElement("slot");
|
|
950
|
+
headerActions.name = "header-actions";
|
|
820
951
|
|
|
821
952
|
const controls = document.createElement("div");
|
|
822
953
|
controls.className = "header-controls";
|
|
954
|
+
controls.setAttribute("part", "header-controls");
|
|
823
955
|
|
|
824
|
-
const history =
|
|
825
|
-
history.type = "button";
|
|
826
|
-
history.className = "header-btn header-btn--history";
|
|
827
|
-
history.title = "Chat history";
|
|
828
|
-
history.setAttribute("aria-label", "Chat history");
|
|
829
|
-
history.textContent = "☰";
|
|
956
|
+
const history = this.#headerButton("history", this.#strings.chatHistory, "☰");
|
|
830
957
|
history.addEventListener("click", () => {
|
|
831
958
|
void this.#refreshDrawer();
|
|
832
959
|
this.#drawer.open();
|
|
833
960
|
});
|
|
834
961
|
|
|
835
|
-
const newChat =
|
|
836
|
-
newChat.type = "button";
|
|
837
|
-
newChat.className = "header-btn header-btn--new";
|
|
838
|
-
newChat.title = "New chat";
|
|
839
|
-
newChat.setAttribute("aria-label", "New chat");
|
|
840
|
-
newChat.textContent = "✚";
|
|
962
|
+
const newChat = this.#headerButton("new", this.#strings.newChat, "✚");
|
|
841
963
|
newChat.addEventListener("click", () => this.newChat());
|
|
842
964
|
|
|
843
|
-
const collapse =
|
|
844
|
-
collapse.type = "button";
|
|
845
|
-
collapse.className = "header-btn header-btn--collapse";
|
|
846
|
-
collapse.title = "Collapse";
|
|
847
|
-
collapse.setAttribute("aria-label", "Collapse");
|
|
848
|
-
collapse.textContent = "—";
|
|
965
|
+
const collapse = this.#headerButton("collapse", this.#strings.collapse, "—");
|
|
849
966
|
collapse.addEventListener("click", () => this.toggleCollapsed());
|
|
850
967
|
|
|
851
968
|
controls.append(history, newChat, collapse);
|
|
852
|
-
header.append(title, controls);
|
|
969
|
+
header.append(title, headerActions, controls);
|
|
853
970
|
|
|
854
971
|
this.#messages.className = "messages";
|
|
972
|
+
this.#messages.setAttribute("part", "messages");
|
|
855
973
|
// Screen readers announce streamed messages as they arrive.
|
|
856
974
|
this.#messages.setAttribute("role", "log");
|
|
857
975
|
this.#messages.setAttribute("aria-live", "polite");
|
|
858
|
-
this.#messages.setAttribute("aria-label",
|
|
976
|
+
this.#messages.setAttribute("aria-label", this.#strings.conversation);
|
|
977
|
+
|
|
978
|
+
// Empty-state region: a host slot at the top of the list, hidden as soon as
|
|
979
|
+
// anything renders.
|
|
980
|
+
this.#emptyWrap.className = "empty";
|
|
981
|
+
this.#emptyWrap.setAttribute("part", "empty");
|
|
982
|
+
const emptySlot = document.createElement("slot");
|
|
983
|
+
emptySlot.name = "empty";
|
|
984
|
+
this.#emptyWrap.append(emptySlot);
|
|
985
|
+
this.#messages.append(this.#emptyWrap);
|
|
859
986
|
|
|
860
987
|
const inputRow = document.createElement("div");
|
|
861
988
|
inputRow.className = "input-row";
|
|
989
|
+
inputRow.setAttribute("part", "composer");
|
|
862
990
|
|
|
863
991
|
this.#input.className = "input";
|
|
864
|
-
this.#input.setAttribute("
|
|
992
|
+
this.#input.setAttribute("part", "input");
|
|
993
|
+
this.#input.setAttribute("aria-label", this.#strings.message);
|
|
865
994
|
this.#input.rows = 2;
|
|
866
|
-
this.#input.placeholder =
|
|
995
|
+
this.#input.placeholder = this.#strings.inputPlaceholder;
|
|
867
996
|
this.#input.addEventListener("keydown", (event) => this.#onKeydown(event));
|
|
868
997
|
this.#input.addEventListener("input", () => this.#onInput());
|
|
869
998
|
|
|
870
999
|
this.#send.className = "send";
|
|
871
1000
|
this.#send.type = "button";
|
|
872
|
-
this.#send.
|
|
873
|
-
this.#send.
|
|
1001
|
+
this.#send.setAttribute("part", "send");
|
|
1002
|
+
this.#send.textContent = this.#strings.send;
|
|
1003
|
+
this.#send.setAttribute("aria-label", this.#strings.send);
|
|
874
1004
|
this.#send.dataset["state"] = "idle";
|
|
875
1005
|
this.#send.addEventListener("click", () => {
|
|
876
1006
|
// One button, two states: Send while idle, Stop while a run is in
|
|
@@ -890,9 +1020,10 @@ export class AgUiChat extends HTMLElement {
|
|
|
890
1020
|
// whole shell (wired in #enableDragAndDrop).
|
|
891
1021
|
this.#attachButton.className = "attach-btn";
|
|
892
1022
|
this.#attachButton.type = "button";
|
|
1023
|
+
this.#attachButton.setAttribute("part", "attach-button");
|
|
893
1024
|
this.#attachButton.textContent = "📎";
|
|
894
|
-
this.#attachButton.title =
|
|
895
|
-
this.#attachButton.setAttribute("aria-label",
|
|
1025
|
+
this.#attachButton.title = this.#strings.attachFiles;
|
|
1026
|
+
this.#attachButton.setAttribute("aria-label", this.#strings.attachFiles);
|
|
896
1027
|
this.#attachButton.hidden = true;
|
|
897
1028
|
this.#attachButton.addEventListener("click", () => this.#fileInput.click());
|
|
898
1029
|
|
|
@@ -904,6 +1035,10 @@ export class AgUiChat extends HTMLElement {
|
|
|
904
1035
|
|
|
905
1036
|
this.#attachSlot.className = "attachment-slot";
|
|
906
1037
|
|
|
1038
|
+
// A coarse footer slot below the composer.
|
|
1039
|
+
const footer = document.createElement("slot");
|
|
1040
|
+
footer.name = "footer";
|
|
1041
|
+
|
|
907
1042
|
inputRow.append(this.#attachButton, this.#input, this.#send, this.#fileInput);
|
|
908
1043
|
// Skill surfaces sit just above the input: palette (opens on `/`), chips,
|
|
909
1044
|
// the missing-placeholder hint, and the pending-attachments tray.
|
|
@@ -915,9 +1050,67 @@ export class AgUiChat extends HTMLElement {
|
|
|
915
1050
|
this.#skillHint,
|
|
916
1051
|
this.#attachSlot,
|
|
917
1052
|
inputRow,
|
|
1053
|
+
footer,
|
|
918
1054
|
this.#drawer.element,
|
|
919
1055
|
);
|
|
920
|
-
|
|
1056
|
+
|
|
1057
|
+
// The collapsed-sidebar rail: a slim edge strip (the expand affordance),
|
|
1058
|
+
// sibling of the panel so it survives the panel being hidden. CSS shows it
|
|
1059
|
+
// only for `placement="sidebar"` + `collapsed`.
|
|
1060
|
+
this.#rail.className = "rail";
|
|
1061
|
+
this.#rail.type = "button";
|
|
1062
|
+
this.#rail.setAttribute("part", "launcher");
|
|
1063
|
+
this.#rail.setAttribute("aria-label", this.#strings.expand);
|
|
1064
|
+
this.#rail.append(this.#iconElement("launcher", "launcher-icon", "💬"));
|
|
1065
|
+
this.#rail.addEventListener("click", () => this.setCollapsed(false));
|
|
1066
|
+
|
|
1067
|
+
this.#root.append(style, this.#chat, this.#rail);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** Build a header control button (icon glyph + localized title/aria). */
|
|
1071
|
+
#headerButton(modifier: string, label: string, glyph: string): HTMLButtonElement {
|
|
1072
|
+
const button = document.createElement("button");
|
|
1073
|
+
button.type = "button";
|
|
1074
|
+
button.className = `header-btn header-btn--${modifier}`;
|
|
1075
|
+
button.setAttribute("part", `header-button ${modifier}-button`);
|
|
1076
|
+
button.title = label;
|
|
1077
|
+
button.setAttribute("aria-label", label);
|
|
1078
|
+
button.textContent = glyph;
|
|
1079
|
+
return button;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* An icon holder wrapping a `<slot>` so a host can project custom markup; with
|
|
1084
|
+
* a `data-icon-url` `<img>` as the slot's fallback, or a glyph when given.
|
|
1085
|
+
*/
|
|
1086
|
+
#iconElement(slotName: string, part: string, fallbackGlyph: string | null): HTMLSpanElement {
|
|
1087
|
+
const holder = document.createElement("span");
|
|
1088
|
+
holder.className = "icon-holder";
|
|
1089
|
+
holder.setAttribute("part", part);
|
|
1090
|
+
const slot = document.createElement("slot");
|
|
1091
|
+
slot.name = slotName;
|
|
1092
|
+
const iconUrl = this.getAttribute("data-icon-url");
|
|
1093
|
+
if (iconUrl !== null) {
|
|
1094
|
+
const img = document.createElement("img");
|
|
1095
|
+
img.className = "icon-img";
|
|
1096
|
+
img.src = iconUrl;
|
|
1097
|
+
img.alt = "";
|
|
1098
|
+
slot.append(img);
|
|
1099
|
+
} else if (fallbackGlyph !== null) {
|
|
1100
|
+
slot.append(document.createTextNode(fallbackGlyph));
|
|
1101
|
+
}
|
|
1102
|
+
holder.append(slot);
|
|
1103
|
+
return holder;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** Reflect the collapsed state on the rail's `aria-expanded`. */
|
|
1107
|
+
#syncRail(): void {
|
|
1108
|
+
this.#rail.setAttribute("aria-expanded", String(!this.collapsed));
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** Hide the empty-state region once the message list holds anything else. */
|
|
1112
|
+
#updateEmptyState(): void {
|
|
1113
|
+
this.#emptyWrap.hidden = this.#messages.childElementCount > 1;
|
|
921
1114
|
}
|
|
922
1115
|
|
|
923
1116
|
/** Forward input changes to the skills palette and clear any stale hint. */
|
|
@@ -959,7 +1152,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
959
1152
|
/** Swap the composer button between Send (idle) and Stop (running). */
|
|
960
1153
|
#setRunning(running: boolean): void {
|
|
961
1154
|
this.#running = running;
|
|
962
|
-
const label = running ?
|
|
1155
|
+
const label = running ? this.#strings.stop : this.#strings.send;
|
|
963
1156
|
this.#send.textContent = label;
|
|
964
1157
|
this.#send.setAttribute("aria-label", label);
|
|
965
1158
|
this.#send.dataset["state"] = running ? "running" : "idle";
|
|
@@ -1018,6 +1211,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1018
1211
|
getContext: () => this.getContext(),
|
|
1019
1212
|
executeTool: (call) => this.#executeTool(call),
|
|
1020
1213
|
onPersist: (messages) => this.conversationStore.saveMessages(this.#threadId, messages),
|
|
1214
|
+
connectionLostMessage: this.#strings.connectionLost,
|
|
1021
1215
|
});
|
|
1022
1216
|
}
|
|
1023
1217
|
return this.#client;
|
|
@@ -1047,7 +1241,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1047
1241
|
// pending indicator: nothing here triggers another client round, so it
|
|
1048
1242
|
// would hang after the run ended.
|
|
1049
1243
|
if (!this.#serverSettled.has(call.id)) {
|
|
1050
|
-
card.settle(TOOL_CALL_STATUS.DONE,
|
|
1244
|
+
card.settle(TOOL_CALL_STATUS.DONE, this.#strings.noResult);
|
|
1051
1245
|
}
|
|
1052
1246
|
return null;
|
|
1053
1247
|
}
|
|
@@ -1062,12 +1256,14 @@ export class AgUiChat extends HTMLElement {
|
|
|
1062
1256
|
this.#confirmAbort = new AbortController();
|
|
1063
1257
|
const decision = requestConfirmation(this.#messages, request, {
|
|
1064
1258
|
signal: this.#confirmAbort.signal,
|
|
1259
|
+
strings: this.#strings,
|
|
1065
1260
|
});
|
|
1261
|
+
this.#updateEmptyState();
|
|
1066
1262
|
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
1067
1263
|
const accepted = await decision;
|
|
1068
1264
|
this.#confirmAbort = null;
|
|
1069
1265
|
if (!accepted) {
|
|
1070
|
-
const message =
|
|
1266
|
+
const message = this.#strings.declinedAction;
|
|
1071
1267
|
card.settle(TOOL_CALL_STATUS.DECLINED, message);
|
|
1072
1268
|
this.#showPending();
|
|
1073
1269
|
return { content: message };
|
|
@@ -1085,7 +1281,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1085
1281
|
try {
|
|
1086
1282
|
const result = await tool.handler(call.args);
|
|
1087
1283
|
if (navigates) {
|
|
1088
|
-
card.settle(TOOL_CALL_STATUS.DONE,
|
|
1284
|
+
card.settle(TOOL_CALL_STATUS.DONE, this.#strings.navigating);
|
|
1089
1285
|
return { content: "", halt: true };
|
|
1090
1286
|
}
|
|
1091
1287
|
const content = JSON.stringify(result ?? null);
|
|
@@ -1108,6 +1304,10 @@ export class AgUiChat extends HTMLElement {
|
|
|
1108
1304
|
return {
|
|
1109
1305
|
onRunStart: () => {
|
|
1110
1306
|
this.#setRunning(true);
|
|
1307
|
+
// Open the answer group on the turn's first run so the pending
|
|
1308
|
+
// indicator (and everything after) lands inside the well. Idempotent:
|
|
1309
|
+
// later rounds of the same turn reuse it.
|
|
1310
|
+
this.#ensureGroup();
|
|
1111
1311
|
this.#showPending();
|
|
1112
1312
|
},
|
|
1113
1313
|
onTextDelta: (buffer) => {
|
|
@@ -1164,6 +1364,22 @@ export class AgUiChat extends HTMLElement {
|
|
|
1164
1364
|
// The attachment manifest was for this run only; the model has read what
|
|
1165
1365
|
// it needed (results now live in history).
|
|
1166
1366
|
this.#runAttachments = [];
|
|
1367
|
+
// Belt-and-suspenders: a tool card still pending at settle (e.g. a
|
|
1368
|
+
// server tool whose result never streamed because the connection
|
|
1369
|
+
// dropped) would hang forever — settle it to the no-result fallback.
|
|
1370
|
+
for (const card of this.#toolCards.values()) {
|
|
1371
|
+
if (!card.settled) {
|
|
1372
|
+
card.settle(TOOL_CALL_STATUS.DONE, this.#strings.noResult);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
// Close the turn's answer group. Drop it if the turn rendered nothing
|
|
1376
|
+
// (e.g. a server-only round that streamed no text/card) so an opted-in
|
|
1377
|
+
// well leaves no empty box behind.
|
|
1378
|
+
if (this.#currentGroup !== null && this.#currentGroup.childElementCount === 0) {
|
|
1379
|
+
this.#currentGroup.remove();
|
|
1380
|
+
this.#updateEmptyState();
|
|
1381
|
+
}
|
|
1382
|
+
this.#currentGroup = null;
|
|
1167
1383
|
},
|
|
1168
1384
|
};
|
|
1169
1385
|
}
|
|
@@ -1172,9 +1388,11 @@ export class AgUiChat extends HTMLElement {
|
|
|
1172
1388
|
#appendStoppedNote(): void {
|
|
1173
1389
|
const note = document.createElement("div");
|
|
1174
1390
|
note.className = "stopped-note";
|
|
1391
|
+
note.setAttribute("part", "stopped");
|
|
1175
1392
|
note.setAttribute("role", "status");
|
|
1176
|
-
note.textContent =
|
|
1177
|
-
this.#
|
|
1393
|
+
note.textContent = this.#strings.stopped;
|
|
1394
|
+
this.#ensureGroup().appendChild(note);
|
|
1395
|
+
this.#updateEmptyState();
|
|
1178
1396
|
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
1179
1397
|
}
|
|
1180
1398
|
|
|
@@ -1189,15 +1407,17 @@ export class AgUiChat extends HTMLElement {
|
|
|
1189
1407
|
}
|
|
1190
1408
|
const pending = document.createElement("div");
|
|
1191
1409
|
pending.className = "pending";
|
|
1410
|
+
pending.setAttribute("part", "pending");
|
|
1192
1411
|
pending.setAttribute("role", "status");
|
|
1193
|
-
pending.setAttribute("aria-label",
|
|
1412
|
+
pending.setAttribute("aria-label", this.#strings.thinking);
|
|
1194
1413
|
for (let i = 0; i < 3; i += 1) {
|
|
1195
1414
|
const dot = document.createElement("span");
|
|
1196
1415
|
dot.className = "pending-dot";
|
|
1197
1416
|
pending.appendChild(dot);
|
|
1198
1417
|
}
|
|
1199
1418
|
this.#pending = pending;
|
|
1200
|
-
this.#
|
|
1419
|
+
this.#ensureGroup().appendChild(pending);
|
|
1420
|
+
this.#updateEmptyState();
|
|
1201
1421
|
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
1202
1422
|
}
|
|
1203
1423
|
|
|
@@ -1238,9 +1458,10 @@ export class AgUiChat extends HTMLElement {
|
|
|
1238
1458
|
: (this.toolSummaries[call.name] ??
|
|
1239
1459
|
this.#toolCatalog[call.name] ??
|
|
1240
1460
|
prettifyToolName(call.name));
|
|
1241
|
-
const card = new ToolCallCard(call.name, call.args, this.toolDisplay, summary);
|
|
1461
|
+
const card = new ToolCallCard(call.name, call.args, this.toolDisplay, summary, this.#strings);
|
|
1242
1462
|
this.#toolCards.set(call.id, card);
|
|
1243
|
-
this.#
|
|
1463
|
+
this.#ensureGroup().appendChild(card.element);
|
|
1464
|
+
this.#updateEmptyState();
|
|
1244
1465
|
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
1245
1466
|
return card;
|
|
1246
1467
|
}
|
package/src/core/agui_client.ts
CHANGED
|
@@ -93,6 +93,26 @@ export interface AgUiClientConfig extends AgUiRunInputs {
|
|
|
93
93
|
* conversation in-memory only.
|
|
94
94
|
*/
|
|
95
95
|
onPersist?: (messages: readonly Message[]) => void;
|
|
96
|
+
/**
|
|
97
|
+
* Error text surfaced to {@link AgUiClientHandlers.onError} when a run's
|
|
98
|
+
* stream closes without a terminal AG-UI event (`RUN_FINISHED`/`RUN_ERROR`) —
|
|
99
|
+
* a dropped connection. Defaults to `"Connection lost"`; the host passes its
|
|
100
|
+
* localized string.
|
|
101
|
+
*/
|
|
102
|
+
connectionLostMessage?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Raised when a run's stream closes cleanly at the transport level but never
|
|
107
|
+
* emits a terminal AG-UI event, so the run neither finished nor errored. Routed
|
|
108
|
+
* to {@link AgUiClientHandlers.onError} (it is not an abort), turning a silent
|
|
109
|
+
* "stuck pending" into a visible "connection lost".
|
|
110
|
+
*/
|
|
111
|
+
export class ConnectionLostError extends Error {
|
|
112
|
+
constructor(message: string) {
|
|
113
|
+
super(message);
|
|
114
|
+
this.name = "ConnectionLostError";
|
|
115
|
+
}
|
|
96
116
|
}
|
|
97
117
|
|
|
98
118
|
/**
|
|
@@ -109,6 +129,7 @@ export class AgUiClient {
|
|
|
109
129
|
readonly #getContext: () => Context[];
|
|
110
130
|
readonly #executeTool: ExecuteTool | null;
|
|
111
131
|
readonly #onPersist: (messages: readonly Message[]) => void;
|
|
132
|
+
readonly #connectionLostMessage: string;
|
|
112
133
|
// Set by cancel(); reset at the top of each #run(). Checked by the loop so
|
|
113
134
|
// a cancel between frontend-tool rounds doesn't start another round.
|
|
114
135
|
#cancelled = false;
|
|
@@ -120,6 +141,7 @@ export class AgUiClient {
|
|
|
120
141
|
this.#getContext = config.getContext ?? (() => []);
|
|
121
142
|
this.#executeTool = config.executeTool ?? null;
|
|
122
143
|
this.#onPersist = config.onPersist ?? (() => {});
|
|
144
|
+
this.#connectionLostMessage = config.connectionLostMessage ?? "Connection lost";
|
|
123
145
|
}
|
|
124
146
|
|
|
125
147
|
/** Whether a run is currently in flight. */
|
|
@@ -220,9 +242,10 @@ export class AgUiClient {
|
|
|
220
242
|
return;
|
|
221
243
|
}
|
|
222
244
|
const pending: AgUiToolCall[] = [];
|
|
245
|
+
const runState = { terminal: false };
|
|
223
246
|
await this.#agent.runAgent(
|
|
224
247
|
{ tools: this.#getTools(), context: this.#getContext() },
|
|
225
|
-
this.#buildSubscriber(pending),
|
|
248
|
+
this.#buildSubscriber(pending, runState),
|
|
226
249
|
);
|
|
227
250
|
this.#onPersist(this.#agent.messages);
|
|
228
251
|
// Cancelled mid-stream: the user said stop — don't execute the tool
|
|
@@ -230,6 +253,12 @@ export class AgUiClient {
|
|
|
230
253
|
if (this.#cancelled) {
|
|
231
254
|
return;
|
|
232
255
|
}
|
|
256
|
+
// The stream resolved without RUN_FINISHED / RUN_ERROR: the transport
|
|
257
|
+
// dropped mid-run. Surface it as an error so the UI doesn't rest silently
|
|
258
|
+
// with a stuck pending indicator (caught by #run → onError).
|
|
259
|
+
if (!runState.terminal) {
|
|
260
|
+
throw new ConnectionLostError(this.#connectionLostMessage);
|
|
261
|
+
}
|
|
233
262
|
if (this.#executeTool === null || pending.length === 0) {
|
|
234
263
|
return;
|
|
235
264
|
}
|
|
@@ -259,7 +288,7 @@ export class AgUiClient {
|
|
|
259
288
|
}
|
|
260
289
|
}
|
|
261
290
|
|
|
262
|
-
#buildSubscriber(pending: AgUiToolCall[]): AgentSubscriber {
|
|
291
|
+
#buildSubscriber(pending: AgUiToolCall[], runState: { terminal: boolean }): AgentSubscriber {
|
|
263
292
|
const h = this.#handlers;
|
|
264
293
|
return {
|
|
265
294
|
onRunInitialized() {
|
|
@@ -284,9 +313,11 @@ export class AgUiClient {
|
|
|
284
313
|
h.onToolResult(event.toolCallId, event.content);
|
|
285
314
|
},
|
|
286
315
|
onRunErrorEvent({ event }) {
|
|
316
|
+
runState.terminal = true;
|
|
287
317
|
h.onError(event.message);
|
|
288
318
|
},
|
|
289
319
|
onRunFinalized() {
|
|
320
|
+
runState.terminal = true;
|
|
290
321
|
h.onRunEnd();
|
|
291
322
|
},
|
|
292
323
|
};
|