@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
|
@@ -12,6 +12,22 @@ export interface NavigationCheckpoint {
|
|
|
12
12
|
readonly toolCallId: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Lightweight metadata for one conversation — the thread-drawer row shape.
|
|
17
|
+
*
|
|
18
|
+
* Returned by {@link ClientConversationStore.listThreads} so the drawer can
|
|
19
|
+
* render a list without loading message bodies. `title` defaults to a
|
|
20
|
+
* truncation of the first user message (until an explicit rename); `preview`
|
|
21
|
+
* is a one-line excerpt of the latest message; `updatedAt` is epoch ms of the
|
|
22
|
+
* last change, used to order the list.
|
|
23
|
+
*/
|
|
24
|
+
export interface ThreadMeta {
|
|
25
|
+
readonly threadId: string;
|
|
26
|
+
readonly title: string;
|
|
27
|
+
readonly updatedAt: number;
|
|
28
|
+
readonly preview: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
/**
|
|
16
32
|
* Client-side persistence seam for the conversation and a pending-navigation
|
|
17
33
|
* checkpoint, keyed by `thread_id`.
|
|
@@ -19,36 +35,61 @@ export interface NavigationCheckpoint {
|
|
|
19
35
|
* The default {@link SessionStorageStore} keeps everything per-tab in
|
|
20
36
|
* `sessionStorage`, so the chat survives the full page reloads of a
|
|
21
37
|
* multi-page app. A host may inject a server-backed store instead (e.g. one
|
|
22
|
-
* that rehydrates from a history endpoint); `loadMessages`
|
|
23
|
-
* async-friendly. The checkpoint methods stay synchronous — the
|
|
24
|
-
* tiny local hint a server store can derive from history and no-op.
|
|
38
|
+
* that rehydrates from a history endpoint); `loadMessages` and `listThreads`
|
|
39
|
+
* are therefore async-friendly. The checkpoint methods stay synchronous — the
|
|
40
|
+
* marker is a tiny local hint a server store can derive from history and no-op.
|
|
41
|
+
*
|
|
42
|
+
* Thread enumeration (`listThreads` / `setActiveThread` / `renameThread`) backs
|
|
43
|
+
* the chat-history drawer; "delete a thread" reuses {@link clear} and "new
|
|
44
|
+
* chat" reuses {@link threadId} after clearing the active thread.
|
|
25
45
|
*/
|
|
26
46
|
export interface ClientConversationStore {
|
|
27
|
-
/**
|
|
47
|
+
/** The active conversation id, generated and persisted on first read. */
|
|
28
48
|
threadId(): string;
|
|
29
49
|
/** Load the persisted message history, or `null` when none exists. */
|
|
30
50
|
loadMessages(threadId: string): Promise<readonly Message[] | null>;
|
|
31
|
-
/** Persist the message history. */
|
|
51
|
+
/** Persist the message history (and refresh the thread's drawer metadata). */
|
|
32
52
|
saveMessages(threadId: string, messages: readonly Message[]): void;
|
|
33
53
|
/** Load the pending-navigation checkpoint, or `null` when none is set. */
|
|
34
54
|
loadCheckpoint(threadId: string): NavigationCheckpoint | null;
|
|
35
55
|
/** Set the pending-navigation checkpoint, or clear it when given `null`. */
|
|
36
56
|
saveCheckpoint(threadId: string, checkpoint: NavigationCheckpoint | null): void;
|
|
37
|
-
/** Forget the conversation and checkpoint (
|
|
57
|
+
/** Forget the conversation and checkpoint (a "delete thread" / "new chat"). */
|
|
38
58
|
clear(threadId: string): void;
|
|
59
|
+
/** The user's threads as drawer metadata (no message bodies), newest first. */
|
|
60
|
+
listThreads(): Promise<readonly ThreadMeta[]>;
|
|
61
|
+
/** Make `threadId` the active conversation (the drawer selecting a row). */
|
|
62
|
+
setActiveThread(threadId: string): void;
|
|
63
|
+
/** Set a thread's display title (the drawer renaming a row). */
|
|
64
|
+
renameThread(threadId: string, title: string): void;
|
|
39
65
|
}
|
|
40
66
|
|
|
41
67
|
const THREAD_KEY = "ag-ui-chat:thread";
|
|
68
|
+
const THREADS_KEY = "ag-ui-chat:threads";
|
|
42
69
|
const MESSAGES_PREFIX = "ag-ui-chat:messages:";
|
|
43
70
|
const CHECKPOINT_PREFIX = "ag-ui-chat:checkpoint:";
|
|
44
71
|
|
|
72
|
+
const TITLE_LIMIT = 60;
|
|
73
|
+
const PREVIEW_LIMIT = 100;
|
|
74
|
+
const DEFAULT_TITLE = "New conversation";
|
|
75
|
+
|
|
76
|
+
/** The drawer-index entry; `titleCustom` (private) freezes a renamed title. */
|
|
77
|
+
interface StoredThread {
|
|
78
|
+
threadId: string;
|
|
79
|
+
title: string;
|
|
80
|
+
titleCustom: boolean;
|
|
81
|
+
preview: string;
|
|
82
|
+
updatedAt: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
45
85
|
/**
|
|
46
86
|
* Default {@link ClientConversationStore}: per-tab `sessionStorage`.
|
|
47
87
|
*
|
|
48
88
|
* Survives full page reloads and same-tab navigation, clears on tab close —
|
|
49
89
|
* the right scope for an embedded agent's conversation in a multi-page app.
|
|
50
|
-
*
|
|
51
|
-
*
|
|
90
|
+
* Tracks multiple threads per tab: the active id lives under one key, the
|
|
91
|
+
* message history / checkpoint are namespaced by id, and a small index feeds
|
|
92
|
+
* the drawer so it works with no server.
|
|
52
93
|
*/
|
|
53
94
|
export class SessionStorageStore implements ClientConversationStore {
|
|
54
95
|
threadId(): string {
|
|
@@ -67,6 +108,7 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
67
108
|
|
|
68
109
|
saveMessages(threadId: string, messages: readonly Message[]): void {
|
|
69
110
|
sessionStorage.setItem(MESSAGES_PREFIX + threadId, JSON.stringify(messages));
|
|
111
|
+
this.#touchThread(threadId, messages);
|
|
70
112
|
}
|
|
71
113
|
|
|
72
114
|
loadCheckpoint(threadId: string): NavigationCheckpoint | null {
|
|
@@ -85,7 +127,71 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
85
127
|
clear(threadId: string): void {
|
|
86
128
|
sessionStorage.removeItem(MESSAGES_PREFIX + threadId);
|
|
87
129
|
sessionStorage.removeItem(CHECKPOINT_PREFIX + threadId);
|
|
88
|
-
|
|
130
|
+
this.#writeThreads(this.#readThreads().filter((thread) => thread.threadId !== threadId));
|
|
131
|
+
// Only drop the active pointer when the active thread itself is cleared, so
|
|
132
|
+
// the next `threadId()` mints a fresh one. Deleting another thread from the
|
|
133
|
+
// drawer must not disturb the conversation on screen.
|
|
134
|
+
if (sessionStorage.getItem(THREAD_KEY) === threadId) {
|
|
135
|
+
sessionStorage.removeItem(THREAD_KEY);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
listThreads(): Promise<readonly ThreadMeta[]> {
|
|
140
|
+
const metas = this.#readThreads()
|
|
141
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
142
|
+
.map(({ threadId, title, updatedAt, preview }) => ({ threadId, title, updatedAt, preview }));
|
|
143
|
+
return Promise.resolve(metas);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
setActiveThread(threadId: string): void {
|
|
147
|
+
sessionStorage.setItem(THREAD_KEY, threadId);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
renameThread(threadId: string, title: string): void {
|
|
151
|
+
const threads = this.#readThreads();
|
|
152
|
+
const entry = threads.find((thread) => thread.threadId === threadId);
|
|
153
|
+
if (entry === undefined) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
entry.title = title;
|
|
157
|
+
entry.titleCustom = true;
|
|
158
|
+
this.#writeThreads(threads);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Add or refresh a thread's drawer metadata from its latest messages. */
|
|
162
|
+
#touchThread(threadId: string, messages: readonly Message[]): void {
|
|
163
|
+
const threads = this.#readThreads();
|
|
164
|
+
const entry = threads.find((thread) => thread.threadId === threadId);
|
|
165
|
+
const preview = derivePreview(messages);
|
|
166
|
+
const updatedAt = Date.now();
|
|
167
|
+
if (entry === undefined) {
|
|
168
|
+
threads.push({
|
|
169
|
+
threadId,
|
|
170
|
+
title: deriveTitle(messages),
|
|
171
|
+
titleCustom: false,
|
|
172
|
+
preview,
|
|
173
|
+
updatedAt,
|
|
174
|
+
});
|
|
175
|
+
} else {
|
|
176
|
+
entry.preview = preview;
|
|
177
|
+
entry.updatedAt = updatedAt;
|
|
178
|
+
if (!entry.titleCustom) {
|
|
179
|
+
entry.title = deriveTitle(messages);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
this.#writeThreads(threads);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#readThreads(): StoredThread[] {
|
|
186
|
+
return this.#readJson<StoredThread[]>(THREADS_KEY) ?? [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#writeThreads(threads: readonly StoredThread[]): void {
|
|
190
|
+
if (threads.length === 0) {
|
|
191
|
+
sessionStorage.removeItem(THREADS_KEY);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
sessionStorage.setItem(THREADS_KEY, JSON.stringify(threads));
|
|
89
195
|
}
|
|
90
196
|
|
|
91
197
|
/** Parse a stored JSON value, returning `null` when absent or corrupt. */
|
|
@@ -101,3 +207,36 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
101
207
|
}
|
|
102
208
|
}
|
|
103
209
|
}
|
|
210
|
+
|
|
211
|
+
/** The thread title: the first user message, collapsed + truncated. */
|
|
212
|
+
function deriveTitle(messages: readonly Message[]): string {
|
|
213
|
+
for (const message of messages) {
|
|
214
|
+
if (message.role === "user") {
|
|
215
|
+
const text = cleanText(message.content);
|
|
216
|
+
if (text !== "") {
|
|
217
|
+
return truncate(text, TITLE_LIMIT);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return DEFAULT_TITLE;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A one-line preview: the latest message with text, collapsed + truncated. */
|
|
225
|
+
function derivePreview(messages: readonly Message[]): string {
|
|
226
|
+
for (const message of [...messages].reverse()) {
|
|
227
|
+
const text = cleanText(message.content);
|
|
228
|
+
if (text !== "") {
|
|
229
|
+
return truncate(text, PREVIEW_LIMIT);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return "";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Whitespace-collapsed message text, or `""` for non-string content. */
|
|
236
|
+
function cleanText(content: unknown): string {
|
|
237
|
+
return typeof content === "string" ? content.replace(/\s+/g, " ").trim() : "";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function truncate(text: string, limit: number): string {
|
|
241
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 1).trimEnd()}…`;
|
|
242
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Message } from "@ag-ui/core";
|
|
2
|
+
import {
|
|
3
|
+
type ClientConversationStore,
|
|
4
|
+
type NavigationCheckpoint,
|
|
5
|
+
SessionStorageStore,
|
|
6
|
+
type ThreadMeta,
|
|
7
|
+
} from "./conversation_store.js";
|
|
8
|
+
|
|
9
|
+
/** One row of the server thread index (django-ag-ui's `ThreadsView` wire shape). */
|
|
10
|
+
interface ServerThreadRow {
|
|
11
|
+
readonly thread_id: string;
|
|
12
|
+
readonly title: string;
|
|
13
|
+
readonly updated_at: string | null;
|
|
14
|
+
readonly preview: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Live header source, read per request so rotated tokens / CSRF reach the server. */
|
|
18
|
+
type HeadersProvider = () => Record<string, string>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A {@link ClientConversationStore} backed by a server thread-index endpoint —
|
|
22
|
+
* django-ag-ui's owner-scoped `ThreadsView`, the URL passed to `<ag-ui-chat>`
|
|
23
|
+
* as `data-threads-url`:
|
|
24
|
+
*
|
|
25
|
+
* - `GET <url>` → list the user's threads (metadata only);
|
|
26
|
+
* - `GET <url><id>/` → that thread's messages;
|
|
27
|
+
* - `PATCH <url><id>/` → rename (`{ "title": … }`);
|
|
28
|
+
* - `DELETE <url><id>/` → delete.
|
|
29
|
+
*
|
|
30
|
+
* It wraps a local store (default {@link SessionStorageStore}) for the
|
|
31
|
+
* client-only concerns — the active thread id, the navigation checkpoint, and a
|
|
32
|
+
* message cache — and as the graceful fallback when a request fails. Rename and
|
|
33
|
+
* delete apply **optimistically** (a small local overlay) so the drawer
|
|
34
|
+
* reflects them at once, before the fire-and-forget server round-trip lands.
|
|
35
|
+
*/
|
|
36
|
+
export class RemoteConversationStore implements ClientConversationStore {
|
|
37
|
+
readonly #url: string;
|
|
38
|
+
readonly #headers: HeadersProvider;
|
|
39
|
+
readonly #local: ClientConversationStore;
|
|
40
|
+
readonly #dropped = new Set<string>();
|
|
41
|
+
readonly #renamed = new Map<string, string>();
|
|
42
|
+
|
|
43
|
+
constructor(
|
|
44
|
+
url: string,
|
|
45
|
+
headers: HeadersProvider = () => ({}),
|
|
46
|
+
local: ClientConversationStore = new SessionStorageStore(),
|
|
47
|
+
) {
|
|
48
|
+
this.#url = url.endsWith("/") ? url : `${url}/`;
|
|
49
|
+
this.#headers = headers;
|
|
50
|
+
this.#local = local;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
threadId(): string {
|
|
54
|
+
return this.#local.threadId();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setActiveThread(threadId: string): void {
|
|
58
|
+
this.#local.setActiveThread(threadId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
saveMessages(threadId: string, messages: readonly Message[]): void {
|
|
62
|
+
// The agent run persists server-side; keep a local cache for offline replay.
|
|
63
|
+
this.#local.saveMessages(threadId, messages);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
loadCheckpoint(threadId: string): NavigationCheckpoint | null {
|
|
67
|
+
return this.#local.loadCheckpoint(threadId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
saveCheckpoint(threadId: string, checkpoint: NavigationCheckpoint | null): void {
|
|
71
|
+
this.#local.saveCheckpoint(threadId, checkpoint);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
renameThread(threadId: string, title: string): void {
|
|
75
|
+
this.#local.renameThread(threadId, title);
|
|
76
|
+
this.#renamed.set(threadId, title);
|
|
77
|
+
void this.#mutate(threadId, "PATCH", { title });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
clear(threadId: string): void {
|
|
81
|
+
this.#local.clear(threadId);
|
|
82
|
+
this.#dropped.add(threadId);
|
|
83
|
+
void this.#mutate(threadId, "DELETE");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async listThreads(): Promise<readonly ThreadMeta[]> {
|
|
87
|
+
const rows = await this.#fetchThreads();
|
|
88
|
+
if (rows === null) {
|
|
89
|
+
return this.#local.listThreads();
|
|
90
|
+
}
|
|
91
|
+
return rows.filter((row) => !this.#dropped.has(row.thread_id)).map((row) => this.#toMeta(row));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async loadMessages(threadId: string): Promise<readonly Message[] | null> {
|
|
95
|
+
const response = await this.#get(this.#url + encodeURIComponent(threadId) + "/");
|
|
96
|
+
if (response === null || !response.ok) {
|
|
97
|
+
return this.#local.loadMessages(threadId);
|
|
98
|
+
}
|
|
99
|
+
const body = (await response.json()) as { messages?: readonly Message[] };
|
|
100
|
+
return body.messages ?? null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async #fetchThreads(): Promise<readonly ServerThreadRow[] | null> {
|
|
104
|
+
const response = await this.#get(this.#url);
|
|
105
|
+
if (response === null || !response.ok) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const body = (await response.json()) as { threads?: readonly ServerThreadRow[] };
|
|
109
|
+
return body.threads ?? [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#toMeta(row: ServerThreadRow): ThreadMeta {
|
|
113
|
+
return {
|
|
114
|
+
threadId: row.thread_id,
|
|
115
|
+
title: this.#renamed.get(row.thread_id) ?? row.title,
|
|
116
|
+
updatedAt: row.updated_at === null ? 0 : Date.parse(row.updated_at),
|
|
117
|
+
preview: row.preview,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** GET that resolves to the `Response`, or `null` on a network error. */
|
|
122
|
+
async #get(url: string): Promise<Response | null> {
|
|
123
|
+
try {
|
|
124
|
+
return await fetch(url, { headers: this.#headers() });
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Fire a best-effort write to the thread endpoint; failures are tolerated. */
|
|
131
|
+
async #mutate(
|
|
132
|
+
threadId: string,
|
|
133
|
+
method: "PATCH" | "DELETE",
|
|
134
|
+
body?: { title: string },
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
const headers = this.#headers();
|
|
137
|
+
try {
|
|
138
|
+
await fetch(this.#url + encodeURIComponent(threadId) + "/", {
|
|
139
|
+
method,
|
|
140
|
+
headers: body === undefined ? headers : { ...headers, "content-type": "application/json" },
|
|
141
|
+
body: body === undefined ? null : JSON.stringify(body),
|
|
142
|
+
});
|
|
143
|
+
} catch {
|
|
144
|
+
// Best-effort; the optimistic overlay keeps the drawer consistent.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -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,10 +28,12 @@ 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,
|
|
34
35
|
SessionStorageStore,
|
|
36
|
+
type ThreadMeta,
|
|
35
37
|
} from "./core/conversation_store.js";
|
|
36
38
|
export {
|
|
37
39
|
type AgentFactory,
|
|
@@ -39,6 +41,12 @@ export {
|
|
|
39
41
|
type HttpAgentOptions,
|
|
40
42
|
} from "./core/create_http_agent.js";
|
|
41
43
|
export { defineAgUiChat } from "./core/define_ag_ui_chat.js";
|
|
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";
|
|
42
50
|
export {
|
|
43
51
|
type FlashOptions,
|
|
44
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
|
+
}
|