@artooi/ag-ui-web-component 0.4.0 → 0.6.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 +50 -1
- package/README.md +69 -2
- package/dist/ag-ui-web-component.bundle.js +355 -59
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +16 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +17 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +7 -1
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/attachment.d.ts +35 -0
- package/dist/core/attachment.d.ts.map +1 -0
- package/dist/core/conversation_store.d.ts +37 -8
- package/dist/core/conversation_store.d.ts.map +1 -1
- package/dist/core/remote_conversation_store.d.ts +35 -0
- package/dist/core/remote_conversation_store.d.ts.map +1 -0
- package/dist/core/upload_attachment.d.ts +32 -0
- package/dist/core/upload_attachment.d.ts.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1198 -24
- package/dist/index.js.map +4 -4
- package/dist/ui/attachment_chips.d.ts +13 -0
- package/dist/ui/attachment_chips.d.ts.map +1 -0
- package/dist/ui/attachment_tray.d.ts +42 -0
- package/dist/ui/attachment_tray.d.ts.map +1 -0
- package/dist/ui/relative_time.d.ts +11 -0
- package/dist/ui/relative_time.d.ts.map +1 -0
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/thread_drawer.d.ts +33 -0
- package/dist/ui/thread_drawer.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/constants.ts +18 -0
- package/src/core/ag_ui_chat.ts +267 -14
- package/src/core/agui_client.ts +15 -2
- package/src/core/attachment.ts +39 -0
- package/src/core/conversation_store.ts +148 -9
- package/src/core/remote_conversation_store.ts +147 -0
- package/src/core/upload_attachment.ts +113 -0
- package/src/index.ts +8 -0
- package/src/ui/attachment_chips.ts +68 -0
- package/src/ui/attachment_tray.ts +237 -0
- package/src/ui/relative_time.ts +28 -0
- package/src/ui/styles.ts +294 -0
- package/src/ui/thread_drawer.ts +200 -0
- package/src/version.ts +1 -1
package/src/core/ag_ui_chat.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Context, Message, Tool } from "@ag-ui/core";
|
|
2
2
|
import {
|
|
3
|
+
DEFAULT_ATTACHMENT_MAX_BYTES,
|
|
3
4
|
MESSAGE_ROLE,
|
|
4
5
|
SUBMIT_EVENT,
|
|
5
6
|
TOGGLE_EVENT,
|
|
@@ -18,12 +19,15 @@ import { createPageMapContext, type PageMap } from "../tools/page_map.js";
|
|
|
18
19
|
import { parseToolCatalog } from "../tools/parse_tool_catalog.js";
|
|
19
20
|
import { createRouteTools, type RouteMap } from "../tools/route_map.js";
|
|
20
21
|
import { createStateHookTools, type StateHook } from "../tools/state_hook.js";
|
|
22
|
+
import { renderAttachmentChips } from "../ui/attachment_chips.js";
|
|
23
|
+
import { AttachmentTray } from "../ui/attachment_tray.js";
|
|
21
24
|
import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
|
|
22
25
|
import { prettifyToolName } from "../ui/prettify_tool_name.js";
|
|
23
26
|
import { renderMarkdown } from "../ui/render_markdown.js";
|
|
24
27
|
import { wrapWords } from "../ui/reveal_words.js";
|
|
25
28
|
import { SkillsMenu } from "../ui/skills_menu.js";
|
|
26
29
|
import { STYLES } from "../ui/styles.js";
|
|
30
|
+
import { ThreadDrawer } from "../ui/thread_drawer.js";
|
|
27
31
|
import { ToolCallCard, type ToolDisplayMode } from "../ui/tool_call_card.js";
|
|
28
32
|
import {
|
|
29
33
|
AgUiClient,
|
|
@@ -31,12 +35,15 @@ import {
|
|
|
31
35
|
type AgUiToolCall,
|
|
32
36
|
type ToolExecution,
|
|
33
37
|
} from "./agui_client.js";
|
|
38
|
+
import { type AttachmentRef, messageAttachments } from "./attachment.js";
|
|
34
39
|
import {
|
|
35
40
|
type ClientConversationStore,
|
|
36
41
|
type NavigationCheckpoint,
|
|
37
42
|
SessionStorageStore,
|
|
38
43
|
} from "./conversation_store.js";
|
|
39
44
|
import { type AgentFactory, createHttpAgent } from "./create_http_agent.js";
|
|
45
|
+
import { RemoteConversationStore } from "./remote_conversation_store.js";
|
|
46
|
+
import { type UploadHandler, uploadAttachment } from "./upload_attachment.js";
|
|
40
47
|
|
|
41
48
|
/** The role a rendered chat message takes. */
|
|
42
49
|
export type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE];
|
|
@@ -44,6 +51,8 @@ export type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE];
|
|
|
44
51
|
/** `detail` shape of the {@link SUBMIT_EVENT} CustomEvent. */
|
|
45
52
|
export interface SubmitDetail {
|
|
46
53
|
readonly content: string;
|
|
54
|
+
/** Durable refs for the files attached to this message (empty when none). */
|
|
55
|
+
readonly attachments: readonly AttachmentRef[];
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
/** `detail` shape of the {@link TOGGLE_EVENT} CustomEvent. */
|
|
@@ -112,9 +121,14 @@ export class AgUiChat extends HTMLElement {
|
|
|
112
121
|
|
|
113
122
|
/**
|
|
114
123
|
* Per-run context provider. Defaults to the compact page map (when a
|
|
115
|
-
* {@link getPageMap} provider is set and {@link autoInjectPageMap} is on)
|
|
124
|
+
* {@link getPageMap} provider is set and {@link autoInjectPageMap} is on)
|
|
125
|
+
* plus a one-line manifest of the files attached to the message being sent,
|
|
126
|
+
* so the agent knows which `read_attachment` ids are available.
|
|
116
127
|
*/
|
|
117
|
-
getContext: () => Context[] = () =>
|
|
128
|
+
getContext: () => Context[] = () => [
|
|
129
|
+
...createPageMapContext(this.getPageMap, this.autoInjectPageMap),
|
|
130
|
+
...this.#attachmentContext(),
|
|
131
|
+
];
|
|
118
132
|
|
|
119
133
|
/**
|
|
120
134
|
* Navigable routes the agent can jump to via the built-in `route.*` tools.
|
|
@@ -142,6 +156,17 @@ export class AgUiChat extends HTMLElement {
|
|
|
142
156
|
*/
|
|
143
157
|
conversationStore: ClientConversationStore = new SessionStorageStore();
|
|
144
158
|
|
|
159
|
+
/**
|
|
160
|
+
* How attached files are uploaded. `null` (default) uses the built-in
|
|
161
|
+
* multipart `POST` to `data-attachments-url`. Set a custom
|
|
162
|
+
* {@link UploadHandler} — `(file, onProgress) => Promise<AttachmentRef>` — to
|
|
163
|
+
* swap the transport (e.g. a `tus-js-client` resumable adapter or
|
|
164
|
+
* direct-to-S3 multipart) without changing the tray, the chips, or the AG-UI
|
|
165
|
+
* wire (refs are transport-agnostic). When set, the 📎 affordance appears even
|
|
166
|
+
* with no `data-attachments-url`; the handler owns its own endpoint + headers.
|
|
167
|
+
*/
|
|
168
|
+
uploadHandler: UploadHandler | null = null;
|
|
169
|
+
|
|
145
170
|
/**
|
|
146
171
|
* Builds the tool result a navigating tool resumes with after the page
|
|
147
172
|
* reloads. Defaults to the landed URL; a host (e.g. the admin package) can
|
|
@@ -192,7 +217,16 @@ export class AgUiChat extends HTMLElement {
|
|
|
192
217
|
readonly #send: HTMLButtonElement;
|
|
193
218
|
readonly #title: HTMLSpanElement;
|
|
194
219
|
readonly #skillsMenu: SkillsMenu;
|
|
220
|
+
readonly #drawer: ThreadDrawer;
|
|
195
221
|
readonly #skillHint: HTMLDivElement;
|
|
222
|
+
/** File-picker button + hidden input + tray slot; the tray mounts on connect. */
|
|
223
|
+
readonly #attachButton: HTMLButtonElement;
|
|
224
|
+
readonly #fileInput: HTMLInputElement;
|
|
225
|
+
readonly #attachSlot: HTMLDivElement;
|
|
226
|
+
/** Upload tray; created on connect only when `data-attachments-url` is set. */
|
|
227
|
+
#attachTray: AttachmentTray | null = null;
|
|
228
|
+
/** Refs attached to the message currently being sent (the context manifest). */
|
|
229
|
+
#runAttachments: readonly AttachmentRef[] = [];
|
|
196
230
|
|
|
197
231
|
#client: AgUiClient | null = null;
|
|
198
232
|
// Whether an interaction is in flight (first onRunStart → onSettled). Drives
|
|
@@ -224,7 +258,26 @@ export class AgUiChat extends HTMLElement {
|
|
|
224
258
|
this.#send = document.createElement("button");
|
|
225
259
|
this.#title = document.createElement("span");
|
|
226
260
|
this.#skillHint = document.createElement("div");
|
|
261
|
+
this.#attachButton = document.createElement("button");
|
|
262
|
+
this.#fileInput = document.createElement("input");
|
|
263
|
+
this.#attachSlot = document.createElement("div");
|
|
227
264
|
this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
|
|
265
|
+
this.#drawer = new ThreadDrawer({
|
|
266
|
+
onSelect: (threadId) => {
|
|
267
|
+
void this.#switchThread(threadId);
|
|
268
|
+
},
|
|
269
|
+
onNew: () => {
|
|
270
|
+
this.newChat();
|
|
271
|
+
void this.#refreshDrawer();
|
|
272
|
+
},
|
|
273
|
+
onRename: (threadId, title) => {
|
|
274
|
+
this.conversationStore.renameThread(threadId, title);
|
|
275
|
+
void this.#refreshDrawer();
|
|
276
|
+
},
|
|
277
|
+
onDelete: (threadId) => {
|
|
278
|
+
this.#deleteThread(threadId);
|
|
279
|
+
},
|
|
280
|
+
});
|
|
228
281
|
}
|
|
229
282
|
|
|
230
283
|
/** Attributes whose late changes must reflect in already-rendered chrome. */
|
|
@@ -337,10 +390,121 @@ export class AgUiChat extends HTMLElement {
|
|
|
337
390
|
}
|
|
338
391
|
this.#initSkills();
|
|
339
392
|
void this.#fetchToolCatalog();
|
|
393
|
+
this.#wireThreadStore();
|
|
394
|
+
this.#wireAttachments();
|
|
340
395
|
this.#threadId = this.conversationStore.threadId();
|
|
341
396
|
void this.#rehydrate();
|
|
342
397
|
}
|
|
343
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Enable the composer's file-upload tray when uploads are possible — either a
|
|
401
|
+
* custom {@link uploadHandler} is set or `data-attachments-url` provides the
|
|
402
|
+
* built-in multipart endpoint: reveal the 📎 button, wire the hidden file
|
|
403
|
+
* input + drag-and-drop, and mount the tray. With neither, the affordance
|
|
404
|
+
* stays hidden and the chat degrades to text-only.
|
|
405
|
+
*/
|
|
406
|
+
#wireAttachments(): void {
|
|
407
|
+
const url = this.getAttribute("data-attachments-url");
|
|
408
|
+
const upload = this.uploadHandler ?? this.#defaultUploadHandler(url);
|
|
409
|
+
if (upload === null) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const accept = this.getAttribute("data-attachment-accept") ?? "";
|
|
413
|
+
this.#attachTray = new AttachmentTray({
|
|
414
|
+
upload,
|
|
415
|
+
maxBytes: this.#attachmentMaxBytes(),
|
|
416
|
+
accept,
|
|
417
|
+
});
|
|
418
|
+
this.#attachSlot.appendChild(this.#attachTray.element);
|
|
419
|
+
this.#fileInput.accept = accept;
|
|
420
|
+
this.#attachButton.hidden = false;
|
|
421
|
+
this.#enableDragAndDrop();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The built-in multipart upload handler for `data-attachments-url`, or `null`. */
|
|
425
|
+
#defaultUploadHandler(url: string | null): UploadHandler | null {
|
|
426
|
+
if (url === null) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
return (file, onProgress) => uploadAttachment(file, { url, headers: this.headers, onProgress });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** The client-side upload size cap from `data-attachment-max-bytes`. */
|
|
433
|
+
#attachmentMaxBytes(): number {
|
|
434
|
+
const attr = this.getAttribute("data-attachment-max-bytes");
|
|
435
|
+
if (attr === null) {
|
|
436
|
+
return DEFAULT_ATTACHMENT_MAX_BYTES;
|
|
437
|
+
}
|
|
438
|
+
const parsed = Number.parseInt(attr, 10);
|
|
439
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_ATTACHMENT_MAX_BYTES;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Queue every file from the picker into the tray, then reset the input. */
|
|
443
|
+
#onFilesPicked(): void {
|
|
444
|
+
const files = this.#fileInput.files;
|
|
445
|
+
if (files !== null) {
|
|
446
|
+
for (const file of Array.from(files)) {
|
|
447
|
+
this.#attachTray?.add(file);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// Reset so re-picking the same file fires `change` again.
|
|
451
|
+
this.#fileInput.value = "";
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Accept files dropped anywhere on the chat shell into the tray. */
|
|
455
|
+
#enableDragAndDrop(): void {
|
|
456
|
+
this.#chat.addEventListener("dragover", (event) => {
|
|
457
|
+
event.preventDefault();
|
|
458
|
+
this.#chat.classList.add("chat--dragover");
|
|
459
|
+
});
|
|
460
|
+
this.#chat.addEventListener("dragleave", () => {
|
|
461
|
+
this.#chat.classList.remove("chat--dragover");
|
|
462
|
+
});
|
|
463
|
+
this.#chat.addEventListener("drop", (event) => {
|
|
464
|
+
event.preventDefault();
|
|
465
|
+
this.#chat.classList.remove("chat--dragover");
|
|
466
|
+
const files = event.dataTransfer?.files;
|
|
467
|
+
if (files !== undefined) {
|
|
468
|
+
for (const file of Array.from(files)) {
|
|
469
|
+
this.#attachTray?.add(file);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** The one-line manifest of the message's attachments, for the run context. */
|
|
476
|
+
#attachmentContext(): Context[] {
|
|
477
|
+
if (this.#runAttachments.length === 0) {
|
|
478
|
+
return [];
|
|
479
|
+
}
|
|
480
|
+
const lines = this.#runAttachments.map(
|
|
481
|
+
(ref) => `- ${ref.name} (id: ${ref.id}, ${ref.mime || "unknown type"}, ${ref.size} bytes)`,
|
|
482
|
+
);
|
|
483
|
+
return [
|
|
484
|
+
{
|
|
485
|
+
description: "Files the user attached to this message",
|
|
486
|
+
value: `${lines.join("\n")}\nUse the read_attachment tool with an id to read a file's contents.`,
|
|
487
|
+
},
|
|
488
|
+
];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* When `data-threads-url` is set, route thread enumeration / load / rename /
|
|
493
|
+
* delete through that server endpoint (wrapping the current store as the
|
|
494
|
+
* client-only fallback), so the history drawer shows durable, cross-device
|
|
495
|
+
* threads. Without it, the client store's per-tab threads are used.
|
|
496
|
+
*/
|
|
497
|
+
#wireThreadStore(): void {
|
|
498
|
+
const url = this.getAttribute("data-threads-url");
|
|
499
|
+
if (url !== null) {
|
|
500
|
+
this.conversationStore = new RemoteConversationStore(
|
|
501
|
+
url,
|
|
502
|
+
() => this.headers,
|
|
503
|
+
this.conversationStore,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
344
508
|
/** Fetch the server tool-label catalog from `data-tools-url`, if set. */
|
|
345
509
|
async #fetchToolCatalog(): Promise<void> {
|
|
346
510
|
const url = this.getAttribute("data-tools-url");
|
|
@@ -473,15 +637,55 @@ export class AgUiChat extends HTMLElement {
|
|
|
473
637
|
// leave the old agent streaming into a cleared transcript.
|
|
474
638
|
this.#cancelRun();
|
|
475
639
|
this.conversationStore.clear(this.#threadId);
|
|
640
|
+
this.#resetState();
|
|
641
|
+
this.#threadId = this.conversationStore.threadId();
|
|
642
|
+
this.#setRunning(false);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Drop the in-memory run + transcript, leaving the thread id untouched. */
|
|
646
|
+
#resetState(): void {
|
|
476
647
|
this.#client = null;
|
|
477
648
|
this.#streamingBubble = null;
|
|
478
649
|
this.#hidePending();
|
|
479
650
|
this.#toolCards.clear();
|
|
480
651
|
this.#serverSettled.clear();
|
|
481
652
|
this.#initialMessages = [];
|
|
653
|
+
this.#runAttachments = [];
|
|
654
|
+
this.#attachTray?.clear();
|
|
482
655
|
this.#messages.replaceChildren();
|
|
483
|
-
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Switch the active conversation to an existing thread and replay it. */
|
|
659
|
+
async #switchThread(threadId: string): Promise<void> {
|
|
660
|
+
if (threadId === this.#threadId) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
this.#cancelRun();
|
|
664
|
+
this.#resetState();
|
|
665
|
+
this.conversationStore.setActiveThread(threadId);
|
|
666
|
+
this.#threadId = threadId;
|
|
484
667
|
this.#setRunning(false);
|
|
668
|
+
await this.#rehydrate();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Delete a thread; if it was the active one, fall back to a fresh chat. */
|
|
672
|
+
#deleteThread(threadId: string): void {
|
|
673
|
+
const wasActive = threadId === this.#threadId;
|
|
674
|
+
if (wasActive) {
|
|
675
|
+
this.#cancelRun();
|
|
676
|
+
}
|
|
677
|
+
this.conversationStore.clear(threadId);
|
|
678
|
+
if (wasActive) {
|
|
679
|
+
this.#resetState();
|
|
680
|
+
this.#threadId = this.conversationStore.threadId();
|
|
681
|
+
this.#setRunning(false);
|
|
682
|
+
}
|
|
683
|
+
void this.#refreshDrawer();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Reload the drawer's thread list, marking the active thread. */
|
|
687
|
+
async #refreshDrawer(): Promise<void> {
|
|
688
|
+
this.#drawer.setThreads(await this.conversationStore.listThreads(), this.#threadId);
|
|
485
689
|
}
|
|
486
690
|
|
|
487
691
|
/**
|
|
@@ -512,8 +716,12 @@ export class AgUiChat extends HTMLElement {
|
|
|
512
716
|
#renderHistoricMessage(message: Message): void {
|
|
513
717
|
const text = typeof message.content === "string" ? message.content : "";
|
|
514
718
|
if (message.role === MESSAGE_ROLE.USER) {
|
|
515
|
-
|
|
516
|
-
|
|
719
|
+
const attachments = messageAttachments(message);
|
|
720
|
+
if (text !== "" || attachments.length > 0) {
|
|
721
|
+
const bubble = this.appendMessage(MESSAGE_ROLE.USER, text);
|
|
722
|
+
if (attachments.length > 0) {
|
|
723
|
+
bubble.appendChild(renderAttachmentChips(attachments));
|
|
724
|
+
}
|
|
517
725
|
}
|
|
518
726
|
return;
|
|
519
727
|
}
|
|
@@ -613,6 +821,17 @@ export class AgUiChat extends HTMLElement {
|
|
|
613
821
|
const controls = document.createElement("div");
|
|
614
822
|
controls.className = "header-controls";
|
|
615
823
|
|
|
824
|
+
const history = document.createElement("button");
|
|
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 = "☰";
|
|
830
|
+
history.addEventListener("click", () => {
|
|
831
|
+
void this.#refreshDrawer();
|
|
832
|
+
this.#drawer.open();
|
|
833
|
+
});
|
|
834
|
+
|
|
616
835
|
const newChat = document.createElement("button");
|
|
617
836
|
newChat.type = "button";
|
|
618
837
|
newChat.className = "header-btn header-btn--new";
|
|
@@ -629,7 +848,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
629
848
|
collapse.textContent = "—";
|
|
630
849
|
collapse.addEventListener("click", () => this.toggleCollapsed());
|
|
631
850
|
|
|
632
|
-
controls.append(newChat, collapse);
|
|
851
|
+
controls.append(history, newChat, collapse);
|
|
633
852
|
header.append(title, controls);
|
|
634
853
|
|
|
635
854
|
this.#messages.className = "messages";
|
|
@@ -666,16 +885,37 @@ export class AgUiChat extends HTMLElement {
|
|
|
666
885
|
this.#skillHint.className = "skill-hint";
|
|
667
886
|
this.#skillHint.hidden = true;
|
|
668
887
|
|
|
669
|
-
|
|
888
|
+
// File-upload affordance: a 📎 button (hidden until `data-attachments-url`
|
|
889
|
+
// is wired) opening a hidden multi-file input. Drag-and-drop covers the
|
|
890
|
+
// whole shell (wired in #enableDragAndDrop).
|
|
891
|
+
this.#attachButton.className = "attach-btn";
|
|
892
|
+
this.#attachButton.type = "button";
|
|
893
|
+
this.#attachButton.textContent = "📎";
|
|
894
|
+
this.#attachButton.title = "Attach files";
|
|
895
|
+
this.#attachButton.setAttribute("aria-label", "Attach files");
|
|
896
|
+
this.#attachButton.hidden = true;
|
|
897
|
+
this.#attachButton.addEventListener("click", () => this.#fileInput.click());
|
|
898
|
+
|
|
899
|
+
this.#fileInput.className = "attach-input";
|
|
900
|
+
this.#fileInput.type = "file";
|
|
901
|
+
this.#fileInput.multiple = true;
|
|
902
|
+
this.#fileInput.hidden = true;
|
|
903
|
+
this.#fileInput.addEventListener("change", () => this.#onFilesPicked());
|
|
904
|
+
|
|
905
|
+
this.#attachSlot.className = "attachment-slot";
|
|
906
|
+
|
|
907
|
+
inputRow.append(this.#attachButton, this.#input, this.#send, this.#fileInput);
|
|
670
908
|
// Skill surfaces sit just above the input: palette (opens on `/`), chips,
|
|
671
|
-
//
|
|
909
|
+
// the missing-placeholder hint, and the pending-attachments tray.
|
|
672
910
|
this.#chat.append(
|
|
673
911
|
header,
|
|
674
912
|
this.#messages,
|
|
675
913
|
this.#skillsMenu.palette,
|
|
676
914
|
this.#skillsMenu.chips,
|
|
677
915
|
this.#skillHint,
|
|
916
|
+
this.#attachSlot,
|
|
678
917
|
inputRow,
|
|
918
|
+
this.#drawer.element,
|
|
679
919
|
);
|
|
680
920
|
this.#root.append(style, this.#chat);
|
|
681
921
|
}
|
|
@@ -727,26 +967,36 @@ export class AgUiChat extends HTMLElement {
|
|
|
727
967
|
|
|
728
968
|
async #submit(): Promise<void> {
|
|
729
969
|
const content = this.#input.value.trim();
|
|
730
|
-
|
|
970
|
+
const attachments = this.#attachTray?.readyRefs() ?? [];
|
|
971
|
+
// Allow an attachments-only message (no typed text), but nothing empty.
|
|
972
|
+
if (content === "" && attachments.length === 0) {
|
|
731
973
|
return;
|
|
732
974
|
}
|
|
733
|
-
this.appendMessage(MESSAGE_ROLE.USER, content);
|
|
975
|
+
const bubble = this.appendMessage(MESSAGE_ROLE.USER, content);
|
|
976
|
+
if (attachments.length > 0) {
|
|
977
|
+
bubble.appendChild(renderAttachmentChips(attachments));
|
|
978
|
+
}
|
|
734
979
|
this.#input.value = "";
|
|
980
|
+
// The refs are now on the bubble; drop the settled chips, keep any still
|
|
981
|
+
// uploading for a follow-up message.
|
|
982
|
+
this.#attachTray?.clearReady();
|
|
983
|
+
// Surfaced to the run via the context manifest until the run settles.
|
|
984
|
+
this.#runAttachments = attachments;
|
|
735
985
|
this.dispatchEvent(
|
|
736
986
|
new CustomEvent<SubmitDetail>(SUBMIT_EVENT, {
|
|
737
|
-
detail: { content },
|
|
987
|
+
detail: { content, attachments },
|
|
738
988
|
bubbles: true,
|
|
739
989
|
composed: true,
|
|
740
990
|
}),
|
|
741
991
|
);
|
|
742
|
-
await this.#client_send(content);
|
|
992
|
+
await this.#client_send(content, attachments);
|
|
743
993
|
}
|
|
744
994
|
|
|
745
|
-
async #client_send(content: string): Promise<void> {
|
|
995
|
+
async #client_send(content: string, attachments: readonly AttachmentRef[]): Promise<void> {
|
|
746
996
|
if (this.endpoint === "") {
|
|
747
997
|
return;
|
|
748
998
|
}
|
|
749
|
-
await this.#ensureClient().send(content);
|
|
999
|
+
await this.#ensureClient().send(content, attachments);
|
|
750
1000
|
}
|
|
751
1001
|
|
|
752
1002
|
#ensureClient(): AgUiClient {
|
|
@@ -911,6 +1161,9 @@ export class AgUiChat extends HTMLElement {
|
|
|
911
1161
|
this.#hidePending();
|
|
912
1162
|
this.#setRunning(false);
|
|
913
1163
|
this.#streamingBubble = null;
|
|
1164
|
+
// The attachment manifest was for this run only; the model has read what
|
|
1165
|
+
// it needed (results now live in history).
|
|
1166
|
+
this.#runAttachments = [];
|
|
914
1167
|
},
|
|
915
1168
|
};
|
|
916
1169
|
}
|
package/src/core/agui_client.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type AbstractAgent, type AgentSubscriber, randomUUID } from "@ag-ui/client";
|
|
2
2
|
import type { Context, Message, Tool } from "@ag-ui/core";
|
|
3
3
|
import { MAX_TOOL_ROUNDS } from "../constants.js";
|
|
4
|
+
import type { AttachmentRef } from "./attachment.js";
|
|
4
5
|
|
|
5
6
|
/** A tool call surfaced to the host by {@link AgUiClient}. */
|
|
6
7
|
export interface AgUiToolCall {
|
|
@@ -137,9 +138,21 @@ export class AgUiClient {
|
|
|
137
138
|
* When the agent calls frontend tools, this executes them and re-runs the
|
|
138
139
|
* agent with the results, looping until the agent stops calling frontend
|
|
139
140
|
* tools (bounded by {@link MAX_TOOL_ROUNDS}).
|
|
141
|
+
*
|
|
142
|
+
* `attachments` ride on the user message as a non-standard field so the
|
|
143
|
+
* default client store round-trips them for history replay; the agent learns
|
|
144
|
+
* the ids from the run context (the server's strict validation ignores the
|
|
145
|
+
* unknown message field), then reads bytes via the `read_attachment` tool.
|
|
140
146
|
*/
|
|
141
|
-
async send(content: string): Promise<void> {
|
|
142
|
-
|
|
147
|
+
async send(content: string, attachments: readonly AttachmentRef[] = []): Promise<void> {
|
|
148
|
+
// Cast at the AG-UI boundary: `attachments` is a web-component augmentation
|
|
149
|
+
// the strict `Message` union doesn't declare, but `addMessage` /
|
|
150
|
+
// `structuredClone` preserve it verbatim.
|
|
151
|
+
const message = { id: randomUUID(), role: "user", content } as Message;
|
|
152
|
+
if (attachments.length > 0) {
|
|
153
|
+
(message as { attachments?: readonly AttachmentRef[] }).attachments = attachments;
|
|
154
|
+
}
|
|
155
|
+
this.#agent.addMessage(message);
|
|
143
156
|
this.#onPersist(this.#agent.messages);
|
|
144
157
|
await this.#run();
|
|
145
158
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Message } from "@ag-ui/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A durable, lightweight reference to one uploaded file — what an upload
|
|
5
|
+
* returns and what rides on a sent message, never the bytes.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors django-ag-ui's `AttachmentRef`: a file uploads out-of-band to the
|
|
8
|
+
* attachments endpoint, the server hands back this ref, and the agent reads the
|
|
9
|
+
* actual content server-side via the `read_attachment` tool. Keeping the AG-UI
|
|
10
|
+
* message stream free of base64 mirrors how the tool catalog keeps schemas off
|
|
11
|
+
* the wire.
|
|
12
|
+
*/
|
|
13
|
+
export interface AttachmentRef {
|
|
14
|
+
/** Opaque, owner-scoped handle the server resolves back to bytes. */
|
|
15
|
+
readonly id: string;
|
|
16
|
+
/** Original filename, for display on the chip. */
|
|
17
|
+
readonly name: string;
|
|
18
|
+
/** Declared content type (a hint — the server is authoritative). */
|
|
19
|
+
readonly mime: string;
|
|
20
|
+
/** Size in bytes. */
|
|
21
|
+
readonly size: number;
|
|
22
|
+
/** Optional direct fetch URL (the owner-checked download endpoint). */
|
|
23
|
+
readonly url?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The attachment refs a user message carries.
|
|
28
|
+
*
|
|
29
|
+
* Refs are stored on the user message as a non-standard `attachments` field: a
|
|
30
|
+
* web-component augmentation that the default client store round-trips and
|
|
31
|
+
* `@ag-ui/client` preserves through `addMessage` / `structuredClone`, so a
|
|
32
|
+
* restored conversation re-renders its attachment chips. The server's strict
|
|
33
|
+
* `RunAgentInput` validation ignores the unknown field — the model learns the
|
|
34
|
+
* ids from the run context manifest instead.
|
|
35
|
+
*/
|
|
36
|
+
export function messageAttachments(message: Message): readonly AttachmentRef[] {
|
|
37
|
+
const refs = (message as { attachments?: unknown }).attachments;
|
|
38
|
+
return Array.isArray(refs) ? (refs as readonly AttachmentRef[]) : [];
|
|
39
|
+
}
|