@artooi/ag-ui-web-component 0.5.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.
@@ -0,0 +1,113 @@
1
+ import type { AttachmentRef } from "./attachment.js";
2
+
3
+ /**
4
+ * The composer's upload contract: take a `File`, report `0..1` progress, and
5
+ * resolve to a durable {@link AttachmentRef}. The built-in handler is
6
+ * {@link uploadAttachment} (multipart POST); a host swaps in its own — e.g. a
7
+ * `tus-js-client` or direct-to-S3 adapter — via `AgUiChat.uploadHandler`,
8
+ * **without** touching the tray, the chips, or the AG-UI wire (refs are
9
+ * transport-agnostic).
10
+ */
11
+ export type UploadHandler = (
12
+ file: File,
13
+ onProgress: (fraction: number) => void,
14
+ ) => Promise<AttachmentRef>;
15
+
16
+ /** Options for {@link uploadAttachment}. */
17
+ export interface UploadOptions {
18
+ /** The attachments endpoint (`data-attachments-url`). */
19
+ readonly url: string;
20
+ /** Extra HTTP headers (CSRF / auth), read fresh per upload. */
21
+ readonly headers?: Record<string, string>;
22
+ /** Progress callback, `0..1`, fired as the body uploads. */
23
+ readonly onProgress?: (fraction: number) => void;
24
+ /** Abort signal to cancel the in-flight upload. */
25
+ readonly signal?: AbortSignal;
26
+ }
27
+
28
+ /**
29
+ * Upload one file to the attachments endpoint and resolve to its durable
30
+ * {@link AttachmentRef}.
31
+ *
32
+ * Uses `XMLHttpRequest` (not `fetch`) for real upload-progress events: the file
33
+ * is sent as multipart under the `file` field, with the element's `headers` so
34
+ * CSRF / auth ride along exactly like the skills/tools fetches. A non-2xx
35
+ * response or a network/abort error rejects, so the tray can show an error chip.
36
+ */
37
+ export function uploadAttachment(file: File, options: UploadOptions): Promise<AttachmentRef> {
38
+ return new Promise<AttachmentRef>((resolve, reject) => {
39
+ const form = new FormData();
40
+ form.append("file", file);
41
+
42
+ const xhr = new XMLHttpRequest();
43
+ xhr.open("POST", options.url);
44
+ for (const [key, value] of Object.entries(options.headers ?? {})) {
45
+ xhr.setRequestHeader(key, value);
46
+ }
47
+
48
+ const onProgress = options.onProgress;
49
+ if (onProgress !== undefined) {
50
+ xhr.upload.addEventListener("progress", (event) => {
51
+ if (event.lengthComputable) {
52
+ onProgress(event.total === 0 ? 0 : event.loaded / event.total);
53
+ }
54
+ });
55
+ }
56
+
57
+ xhr.addEventListener("load", () => {
58
+ if (xhr.status >= 200 && xhr.status < 300) {
59
+ try {
60
+ resolve(parseRef(JSON.parse(xhr.responseText)));
61
+ } catch {
62
+ reject(new Error("upload returned an unreadable response"));
63
+ }
64
+ } else {
65
+ reject(new Error(errorMessage(xhr)));
66
+ }
67
+ });
68
+ xhr.addEventListener("error", () => reject(new Error("upload failed")));
69
+ xhr.addEventListener("abort", () => reject(new Error("upload cancelled")));
70
+
71
+ const signal = options.signal;
72
+ if (signal !== undefined) {
73
+ signal.addEventListener("abort", () => xhr.abort());
74
+ }
75
+
76
+ xhr.send(form);
77
+ });
78
+ }
79
+
80
+ /** Validate + narrow the server's `201` body into an {@link AttachmentRef}. */
81
+ function parseRef(body: unknown): AttachmentRef {
82
+ if (typeof body !== "object" || body === null) {
83
+ throw new Error("not an object");
84
+ }
85
+ const o = body as Record<string, unknown>;
86
+ const id = o["id"];
87
+ const name = o["name"];
88
+ const mime = o["mime"];
89
+ const size = o["size"];
90
+ const url = o["url"];
91
+ if (
92
+ typeof id !== "string" ||
93
+ typeof name !== "string" ||
94
+ typeof mime !== "string" ||
95
+ typeof size !== "number"
96
+ ) {
97
+ throw new Error("missing fields");
98
+ }
99
+ return typeof url === "string" ? { id, name, mime, size, url } : { id, name, mime, size };
100
+ }
101
+
102
+ /** A human-readable message from a non-2xx upload response. */
103
+ function errorMessage(xhr: XMLHttpRequest): string {
104
+ try {
105
+ const body = JSON.parse(xhr.responseText) as { error?: unknown };
106
+ if (typeof body.error === "string") {
107
+ return body.error;
108
+ }
109
+ } catch {
110
+ // Non-JSON error body — fall through to the status text.
111
+ }
112
+ return `upload failed (${xhr.status})`;
113
+ }
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ export {
28
28
  type ExecuteTool,
29
29
  type ToolExecution,
30
30
  } from "./core/agui_client.js";
31
+ export { type AttachmentRef, messageAttachments } from "./core/attachment.js";
31
32
  export {
32
33
  type ClientConversationStore,
33
34
  type NavigationCheckpoint,
@@ -41,6 +42,11 @@ export {
41
42
  } from "./core/create_http_agent.js";
42
43
  export { defineAgUiChat } from "./core/define_ag_ui_chat.js";
43
44
  export { RemoteConversationStore } from "./core/remote_conversation_store.js";
45
+ export {
46
+ type UploadHandler,
47
+ type UploadOptions,
48
+ uploadAttachment,
49
+ } from "./core/upload_attachment.js";
44
50
  export {
45
51
  type FlashOptions,
46
52
  focusWithFlash,
@@ -0,0 +1,68 @@
1
+ import type { AttachmentRef } from "../core/attachment.js";
2
+
3
+ /**
4
+ * Render the read-only attachment chips shown on a sent user message bubble
5
+ * (and on restored history) — one chip per ref with a type icon, the filename,
6
+ * and a human size. Static by design: no progress, no remove (that lives in the
7
+ * composer tray); a restored bubble re-renders these with no animation.
8
+ */
9
+ export function renderAttachmentChips(refs: readonly AttachmentRef[]): HTMLDivElement {
10
+ const list = document.createElement("div");
11
+ list.className = "attachment-chips";
12
+ for (const ref of refs) {
13
+ list.appendChild(renderChip(ref));
14
+ }
15
+ return list;
16
+ }
17
+
18
+ function renderChip(ref: AttachmentRef): HTMLDivElement {
19
+ const chip = document.createElement("div");
20
+ chip.className = "attachment-chip attachment-chip--ready";
21
+
22
+ const icon = document.createElement("span");
23
+ icon.className = "attachment-chip-icon";
24
+ icon.textContent = iconFor(ref.mime);
25
+ icon.setAttribute("aria-hidden", "true");
26
+
27
+ const name = document.createElement("span");
28
+ name.className = "attachment-chip-name";
29
+ name.textContent = ref.name;
30
+ name.title = ref.name;
31
+
32
+ const size = document.createElement("span");
33
+ size.className = "attachment-chip-size";
34
+ size.textContent = formatBytes(ref.size);
35
+
36
+ chip.append(icon, name, size);
37
+ return chip;
38
+ }
39
+
40
+ /** A coarse type icon for a chip — image, document, or generic file. */
41
+ export function iconFor(mime: string): string {
42
+ if (mime.startsWith("image/")) {
43
+ return "🖼";
44
+ }
45
+ if (mime === "application/pdf") {
46
+ return "📕";
47
+ }
48
+ if (mime.startsWith("text/")) {
49
+ return "📄";
50
+ }
51
+ return "📎";
52
+ }
53
+
54
+ /** A compact human-readable byte size (e.g. `1.2 MB`). */
55
+ export function formatBytes(bytes: number): string {
56
+ if (bytes < 1024) {
57
+ return `${bytes} B`;
58
+ }
59
+ const units = ["KB", "MB", "GB"];
60
+ let value = bytes / 1024;
61
+ let unit = 0;
62
+ while (value >= 1024 && unit < units.length - 1) {
63
+ value /= 1024;
64
+ unit += 1;
65
+ }
66
+ const rounded = value < 10 ? Math.round(value * 10) / 10 : Math.round(value);
67
+ return `${rounded} ${units[unit]}`;
68
+ }
@@ -0,0 +1,237 @@
1
+ import { randomUUID } from "@ag-ui/client";
2
+ import { ATTACHMENT_STATUS } from "../constants.js";
3
+ import type { AttachmentRef } from "../core/attachment.js";
4
+ import type { UploadHandler } from "../core/upload_attachment.js";
5
+ import { formatBytes, iconFor } from "./attachment_chips.js";
6
+
7
+ /** Status of a pending tray chip. */
8
+ type AttachmentStatus = (typeof ATTACHMENT_STATUS)[keyof typeof ATTACHMENT_STATUS];
9
+
10
+ /** Config the host ({@link AgUiChat}) hands the tray. */
11
+ export interface AttachmentTrayConfig {
12
+ /** Upload one file, reporting `0..1` progress; resolves to a durable ref. */
13
+ readonly upload: UploadHandler;
14
+ /** Client-side size cap in bytes (`0` = no cap). The server stays authoritative. */
15
+ readonly maxBytes: number;
16
+ /** Client-side accept list (`<input accept>` syntax; `""` = any). */
17
+ readonly accept: string;
18
+ /** Fired when the set of attachments changes (add / settle / remove). */
19
+ readonly onChange?: () => void;
20
+ }
21
+
22
+ /** One pending file in the tray, from pick to ready/error. */
23
+ interface TrayItem {
24
+ readonly localId: string;
25
+ readonly file: File;
26
+ status: AttachmentStatus;
27
+ progress: number;
28
+ ref: AttachmentRef | null;
29
+ error: string;
30
+ }
31
+
32
+ /**
33
+ * The composer's pending-attachments tray: a chip per picked file with a
34
+ * progress bar while it uploads, settling to a ready chip (holding the durable
35
+ * ref) or an error chip (with retry). A *stateful view* in the spirit of
36
+ * {@link ThreadDrawer} — the host appends {@link element}, calls {@link add} on
37
+ * pick/drop, reads {@link readyRefs} when the user sends, and clears it.
38
+ *
39
+ * Client-side size/type guards reject a bad file into an error chip without
40
+ * uploading — instant feedback, but the server is the authority.
41
+ */
42
+ export class AttachmentTray {
43
+ /** The tray root; append above the input row. Hidden while empty. */
44
+ readonly element: HTMLDivElement;
45
+
46
+ readonly #config: AttachmentTrayConfig;
47
+ #items: TrayItem[] = [];
48
+
49
+ constructor(config: AttachmentTrayConfig) {
50
+ this.#config = config;
51
+ this.element = document.createElement("div");
52
+ this.element.className = "attachment-tray";
53
+ this.element.hidden = true;
54
+ }
55
+
56
+ /** Queue a file: reject oversize/disallowed into an error chip, else upload. */
57
+ add(file: File): void {
58
+ const item: TrayItem = {
59
+ localId: randomUUID(),
60
+ file,
61
+ status: ATTACHMENT_STATUS.UPLOADING,
62
+ progress: 0,
63
+ ref: null,
64
+ error: "",
65
+ };
66
+ this.#items.push(item);
67
+ const rejection = this.#reject(file);
68
+ if (rejection !== null) {
69
+ item.status = ATTACHMENT_STATUS.ERROR;
70
+ item.error = rejection;
71
+ this.#render();
72
+ this.#config.onChange?.();
73
+ return;
74
+ }
75
+ this.#render();
76
+ this.#config.onChange?.();
77
+ this.#upload(item);
78
+ }
79
+
80
+ /** The durable refs of every chip that finished uploading. */
81
+ readyRefs(): readonly AttachmentRef[] {
82
+ const refs: AttachmentRef[] = [];
83
+ for (const item of this.#items) {
84
+ if (item.ref !== null) {
85
+ refs.push(item.ref);
86
+ }
87
+ }
88
+ return refs;
89
+ }
90
+
91
+ /** Whether any chip is still uploading (a send would drop nothing if false). */
92
+ hasPending(): boolean {
93
+ return this.#items.some((item) => item.status === ATTACHMENT_STATUS.UPLOADING);
94
+ }
95
+
96
+ /** Whether the tray holds no chips. */
97
+ isEmpty(): boolean {
98
+ return this.#items.length === 0;
99
+ }
100
+
101
+ /** Drop the settled (ready / error) chips, leaving any still uploading. */
102
+ clearReady(): void {
103
+ this.#items = this.#items.filter((item) => item.status === ATTACHMENT_STATUS.UPLOADING);
104
+ this.#render();
105
+ }
106
+
107
+ /** Drop every chip (a reset / new-chat). */
108
+ clear(): void {
109
+ this.#items = [];
110
+ this.#render();
111
+ }
112
+
113
+ /** The size/type rejection reason for a file, or `null` when accepted. */
114
+ #reject(file: File): string | null {
115
+ if (this.#config.maxBytes > 0 && file.size > this.#config.maxBytes) {
116
+ return `Too large (max ${formatBytes(this.#config.maxBytes)})`;
117
+ }
118
+ if (!accepts(this.#config.accept, file)) {
119
+ return "File type not allowed";
120
+ }
121
+ return null;
122
+ }
123
+
124
+ #upload(item: TrayItem): void {
125
+ item.status = ATTACHMENT_STATUS.UPLOADING;
126
+ item.progress = 0;
127
+ item.error = "";
128
+ this.#render();
129
+ this.#config
130
+ .upload(item.file, (fraction) => {
131
+ item.progress = fraction;
132
+ this.#render();
133
+ })
134
+ .then((ref) => {
135
+ item.status = ATTACHMENT_STATUS.READY;
136
+ item.ref = ref;
137
+ })
138
+ .catch((error: unknown) => {
139
+ item.status = ATTACHMENT_STATUS.ERROR;
140
+ item.error = error instanceof Error ? error.message : "upload failed";
141
+ })
142
+ .finally(() => {
143
+ this.#render();
144
+ this.#config.onChange?.();
145
+ });
146
+ }
147
+
148
+ #remove(item: TrayItem): void {
149
+ this.#items = this.#items.filter((other) => other !== item);
150
+ this.#render();
151
+ this.#config.onChange?.();
152
+ }
153
+
154
+ #render(): void {
155
+ this.element.replaceChildren();
156
+ this.element.hidden = this.#items.length === 0;
157
+ for (const item of this.#items) {
158
+ this.element.appendChild(this.#renderChip(item));
159
+ }
160
+ }
161
+
162
+ #renderChip(item: TrayItem): HTMLDivElement {
163
+ const chip = document.createElement("div");
164
+ chip.className = `attachment-chip attachment-chip--${item.status}`;
165
+
166
+ const icon = document.createElement("span");
167
+ icon.className = "attachment-chip-icon";
168
+ icon.textContent = iconFor(item.file.type);
169
+ icon.setAttribute("aria-hidden", "true");
170
+
171
+ const name = document.createElement("span");
172
+ name.className = "attachment-chip-name";
173
+ name.textContent = item.file.name;
174
+ name.title = item.file.name;
175
+
176
+ const meta = document.createElement("span");
177
+ meta.className = "attachment-chip-size";
178
+ meta.textContent =
179
+ item.status === ATTACHMENT_STATUS.ERROR ? item.error : formatBytes(item.file.size);
180
+
181
+ chip.append(icon, name, meta);
182
+
183
+ if (item.status === ATTACHMENT_STATUS.UPLOADING) {
184
+ const bar = document.createElement("div");
185
+ bar.className = "attachment-chip-bar";
186
+ const fill = document.createElement("div");
187
+ fill.className = "attachment-chip-bar-fill";
188
+ fill.style.width = `${Math.round(item.progress * 100)}%`;
189
+ bar.appendChild(fill);
190
+ chip.appendChild(bar);
191
+ }
192
+
193
+ if (item.status === ATTACHMENT_STATUS.ERROR) {
194
+ const retry = document.createElement("button");
195
+ retry.type = "button";
196
+ retry.className = "attachment-chip-retry";
197
+ retry.title = "Retry";
198
+ retry.setAttribute("aria-label", "Retry upload");
199
+ retry.textContent = "↻";
200
+ retry.addEventListener("click", () => this.#upload(item));
201
+ chip.appendChild(retry);
202
+ }
203
+
204
+ const remove = document.createElement("button");
205
+ remove.type = "button";
206
+ remove.className = "attachment-chip-remove";
207
+ remove.title = "Remove";
208
+ remove.setAttribute("aria-label", "Remove attachment");
209
+ remove.textContent = "✕";
210
+ remove.addEventListener("click", () => this.#remove(item));
211
+ chip.appendChild(remove);
212
+
213
+ return chip;
214
+ }
215
+ }
216
+
217
+ /** Whether `file` matches an `<input accept>` list (`""` accepts anything). */
218
+ function accepts(accept: string, file: File): boolean {
219
+ const tokens = accept
220
+ .split(",")
221
+ .map((token) => token.trim().toLowerCase())
222
+ .filter((token) => token !== "");
223
+ if (tokens.length === 0) {
224
+ return true;
225
+ }
226
+ const mime = file.type.toLowerCase();
227
+ const name = file.name.toLowerCase();
228
+ return tokens.some((token) => {
229
+ if (token.startsWith(".")) {
230
+ return name.endsWith(token);
231
+ }
232
+ if (token.endsWith("/*")) {
233
+ return mime.startsWith(token.slice(0, -1));
234
+ }
235
+ return mime === token;
236
+ });
237
+ }
package/src/ui/styles.ts CHANGED
@@ -506,6 +506,116 @@ export const STYLES = `
506
506
  background: var(--ag-ui-muted);
507
507
  }
508
508
 
509
+ /* ── File attachments ───────────────────────────────────────────────────── */
510
+ /* The 📎 picker button sits left of the input; hidden until data-attachments-url. */
511
+ .attach-btn {
512
+ border: 1px solid var(--ag-ui-border);
513
+ border-radius: 8px;
514
+ padding: 0 10px;
515
+ background: var(--ag-ui-input-bg);
516
+ color: inherit;
517
+ font: inherit;
518
+ cursor: pointer;
519
+ }
520
+
521
+ .attach-btn:hover {
522
+ border-color: var(--ag-ui-accent);
523
+ }
524
+
525
+ .attach-input {
526
+ display: none;
527
+ }
528
+
529
+ /* Pending-attachments tray, above the input row; collapses (hidden) when empty. */
530
+ .attachment-slot {
531
+ display: contents;
532
+ }
533
+
534
+ .attachment-tray {
535
+ display: flex;
536
+ flex-wrap: wrap;
537
+ gap: 6px;
538
+ padding: 8px 12px 0;
539
+ }
540
+
541
+ .attachment-chips {
542
+ display: flex;
543
+ flex-wrap: wrap;
544
+ gap: 6px;
545
+ margin-top: 6px;
546
+ }
547
+
548
+ .attachment-chip {
549
+ display: inline-flex;
550
+ align-items: center;
551
+ gap: 6px;
552
+ max-width: 100%;
553
+ padding: 4px 8px;
554
+ border: 1px solid var(--ag-ui-border);
555
+ border-radius: 999px;
556
+ background: var(--ag-ui-assistant-bg);
557
+ font-size: 0.85em;
558
+ position: relative;
559
+ }
560
+
561
+ .attachment-chip--error {
562
+ border-color: var(--ag-ui-danger);
563
+ color: var(--ag-ui-danger);
564
+ }
565
+
566
+ .attachment-chip-name {
567
+ overflow: hidden;
568
+ text-overflow: ellipsis;
569
+ white-space: nowrap;
570
+ max-width: 14ch;
571
+ }
572
+
573
+ .attachment-chip-size {
574
+ color: var(--ag-ui-muted);
575
+ white-space: nowrap;
576
+ }
577
+
578
+ .attachment-chip--error .attachment-chip-size {
579
+ color: var(--ag-ui-danger);
580
+ }
581
+
582
+ /* The progress bar fills as the file uploads. */
583
+ .attachment-chip-bar {
584
+ flex-basis: 100%;
585
+ height: 3px;
586
+ border-radius: 2px;
587
+ background: var(--ag-ui-border);
588
+ overflow: hidden;
589
+ }
590
+
591
+ .attachment-chip-bar-fill {
592
+ height: 100%;
593
+ background: var(--ag-ui-accent);
594
+ transition: width 0.15s ease;
595
+ }
596
+
597
+ .attachment-chip-remove,
598
+ .attachment-chip-retry {
599
+ border: none;
600
+ background: none;
601
+ color: inherit;
602
+ cursor: pointer;
603
+ padding: 0;
604
+ line-height: 1;
605
+ opacity: 0.7;
606
+ }
607
+
608
+ .attachment-chip-remove:hover,
609
+ .attachment-chip-retry:hover {
610
+ opacity: 1;
611
+ }
612
+
613
+ /* A subtle outline while a file is dragged over the shell. */
614
+ .chat--dragover {
615
+ outline: 2px dashed var(--ag-ui-accent);
616
+ outline-offset: -4px;
617
+ }
618
+
509
619
  /* Muted "⏹ Stopped" line after a cancelled run — a note, not an error bubble. */
510
620
  .stopped-note {
511
621
  align-self: flex-start;
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION: string = "0.5.0";
1
+ export const VERSION: string = "0.6.0";