@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/README.md +69 -2
  3. package/dist/ag-ui-web-component.bundle.js +355 -59
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +16 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +17 -1
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +7 -1
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/core/attachment.d.ts +35 -0
  12. package/dist/core/attachment.d.ts.map +1 -0
  13. package/dist/core/conversation_store.d.ts +37 -8
  14. package/dist/core/conversation_store.d.ts.map +1 -1
  15. package/dist/core/remote_conversation_store.d.ts +35 -0
  16. package/dist/core/remote_conversation_store.d.ts.map +1 -0
  17. package/dist/core/upload_attachment.d.ts +32 -0
  18. package/dist/core/upload_attachment.d.ts.map +1 -0
  19. package/dist/index.d.ts +4 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1198 -24
  22. package/dist/index.js.map +4 -4
  23. package/dist/ui/attachment_chips.d.ts +13 -0
  24. package/dist/ui/attachment_chips.d.ts.map +1 -0
  25. package/dist/ui/attachment_tray.d.ts +42 -0
  26. package/dist/ui/attachment_tray.d.ts.map +1 -0
  27. package/dist/ui/relative_time.d.ts +11 -0
  28. package/dist/ui/relative_time.d.ts.map +1 -0
  29. package/dist/ui/styles.d.ts +1 -1
  30. package/dist/ui/styles.d.ts.map +1 -1
  31. package/dist/ui/thread_drawer.d.ts +33 -0
  32. package/dist/ui/thread_drawer.d.ts.map +1 -0
  33. package/package.json +1 -1
  34. package/src/constants.ts +18 -0
  35. package/src/core/ag_ui_chat.ts +267 -14
  36. package/src/core/agui_client.ts +15 -2
  37. package/src/core/attachment.ts +39 -0
  38. package/src/core/conversation_store.ts +148 -9
  39. package/src/core/remote_conversation_store.ts +147 -0
  40. package/src/core/upload_attachment.ts +113 -0
  41. package/src/index.ts +8 -0
  42. package/src/ui/attachment_chips.ts +68 -0
  43. package/src/ui/attachment_tray.ts +237 -0
  44. package/src/ui/relative_time.ts +28 -0
  45. package/src/ui/styles.ts +294 -0
  46. package/src/ui/thread_drawer.ts +200 -0
  47. package/src/version.ts +1 -1
@@ -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[] = () => createPageMapContext(this.getPageMap, this.autoInjectPageMap);
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
- this.#threadId = this.conversationStore.threadId();
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
- if (text !== "") {
516
- this.appendMessage(MESSAGE_ROLE.USER, text);
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
- inputRow.append(this.#input, this.#send);
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
- // and the missing-placeholder hint.
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
- if (content === "") {
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
  }
@@ -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
- this.#agent.addMessage({ id: randomUUID(), role: "user", content });
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
+ }