@artooi/ag-ui-web-component 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -1
- package/README.md +208 -7
- package/dist/ag-ui-web-component.bundle.js +268 -58
- 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 +33 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +23 -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/upload_attachment.d.ts +32 -0
- package/dist/core/upload_attachment.d.ts.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1247 -245
- package/dist/index.js.map +4 -4
- package/dist/tools/page_action_tools.d.ts +31 -0
- package/dist/tools/page_action_tools.d.ts.map +1 -0
- package/dist/ui/attachment_chips.d.ts +13 -0
- package/dist/ui/attachment_chips.d.ts.map +1 -0
- package/dist/ui/attachment_tray.d.ts +45 -0
- package/dist/ui/attachment_tray.d.ts.map +1 -0
- package/dist/ui/confirmation_card.d.ts +4 -1
- package/dist/ui/confirmation_card.d.ts.map +1 -1
- package/dist/ui/relative_time.d.ts +5 -3
- package/dist/ui/relative_time.d.ts.map +1 -1
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/thread_drawer.d.ts +7 -1
- package/dist/ui/thread_drawer.d.ts.map +1 -1
- package/dist/ui/tool_call_card.d.ts +6 -2
- package/dist/ui/tool_call_card.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +126 -0
- package/dist/ui/ui_strings.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/constants.ts +18 -0
- package/src/core/ag_ui_chat.ts +389 -51
- package/src/core/agui_client.ts +48 -4
- package/src/core/attachment.ts +39 -0
- package/src/core/upload_attachment.ts +113 -0
- package/src/index.ts +13 -0
- package/src/tools/page_action_tools.ts +130 -0
- package/src/ui/attachment_chips.ts +68 -0
- package/src/ui/attachment_tray.ts +243 -0
- package/src/ui/confirmation_card.ts +15 -5
- package/src/ui/relative_time.ts +15 -8
- package/src/ui/styles.ts +208 -0
- package/src/ui/thread_drawer.ts +53 -25
- package/src/ui/tool_call_card.ts +40 -17
- package/src/ui/ui_strings.ts +208 -0
- package/src/version.ts +1 -1
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 {
|
|
@@ -92,6 +93,26 @@ export interface AgUiClientConfig extends AgUiRunInputs {
|
|
|
92
93
|
* conversation in-memory only.
|
|
93
94
|
*/
|
|
94
95
|
onPersist?: (messages: readonly Message[]) => void;
|
|
96
|
+
/**
|
|
97
|
+
* Error text surfaced to {@link AgUiClientHandlers.onError} when a run's
|
|
98
|
+
* stream closes without a terminal AG-UI event (`RUN_FINISHED`/`RUN_ERROR`) —
|
|
99
|
+
* a dropped connection. Defaults to `"Connection lost"`; the host passes its
|
|
100
|
+
* localized string.
|
|
101
|
+
*/
|
|
102
|
+
connectionLostMessage?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Raised when a run's stream closes cleanly at the transport level but never
|
|
107
|
+
* emits a terminal AG-UI event, so the run neither finished nor errored. Routed
|
|
108
|
+
* to {@link AgUiClientHandlers.onError} (it is not an abort), turning a silent
|
|
109
|
+
* "stuck pending" into a visible "connection lost".
|
|
110
|
+
*/
|
|
111
|
+
export class ConnectionLostError extends Error {
|
|
112
|
+
constructor(message: string) {
|
|
113
|
+
super(message);
|
|
114
|
+
this.name = "ConnectionLostError";
|
|
115
|
+
}
|
|
95
116
|
}
|
|
96
117
|
|
|
97
118
|
/**
|
|
@@ -108,6 +129,7 @@ export class AgUiClient {
|
|
|
108
129
|
readonly #getContext: () => Context[];
|
|
109
130
|
readonly #executeTool: ExecuteTool | null;
|
|
110
131
|
readonly #onPersist: (messages: readonly Message[]) => void;
|
|
132
|
+
readonly #connectionLostMessage: string;
|
|
111
133
|
// Set by cancel(); reset at the top of each #run(). Checked by the loop so
|
|
112
134
|
// a cancel between frontend-tool rounds doesn't start another round.
|
|
113
135
|
#cancelled = false;
|
|
@@ -119,6 +141,7 @@ export class AgUiClient {
|
|
|
119
141
|
this.#getContext = config.getContext ?? (() => []);
|
|
120
142
|
this.#executeTool = config.executeTool ?? null;
|
|
121
143
|
this.#onPersist = config.onPersist ?? (() => {});
|
|
144
|
+
this.#connectionLostMessage = config.connectionLostMessage ?? "Connection lost";
|
|
122
145
|
}
|
|
123
146
|
|
|
124
147
|
/** Whether a run is currently in flight. */
|
|
@@ -137,9 +160,21 @@ export class AgUiClient {
|
|
|
137
160
|
* When the agent calls frontend tools, this executes them and re-runs the
|
|
138
161
|
* agent with the results, looping until the agent stops calling frontend
|
|
139
162
|
* tools (bounded by {@link MAX_TOOL_ROUNDS}).
|
|
163
|
+
*
|
|
164
|
+
* `attachments` ride on the user message as a non-standard field so the
|
|
165
|
+
* default client store round-trips them for history replay; the agent learns
|
|
166
|
+
* the ids from the run context (the server's strict validation ignores the
|
|
167
|
+
* unknown message field), then reads bytes via the `read_attachment` tool.
|
|
140
168
|
*/
|
|
141
|
-
async send(content: string): Promise<void> {
|
|
142
|
-
|
|
169
|
+
async send(content: string, attachments: readonly AttachmentRef[] = []): Promise<void> {
|
|
170
|
+
// Cast at the AG-UI boundary: `attachments` is a web-component augmentation
|
|
171
|
+
// the strict `Message` union doesn't declare, but `addMessage` /
|
|
172
|
+
// `structuredClone` preserve it verbatim.
|
|
173
|
+
const message = { id: randomUUID(), role: "user", content } as Message;
|
|
174
|
+
if (attachments.length > 0) {
|
|
175
|
+
(message as { attachments?: readonly AttachmentRef[] }).attachments = attachments;
|
|
176
|
+
}
|
|
177
|
+
this.#agent.addMessage(message);
|
|
143
178
|
this.#onPersist(this.#agent.messages);
|
|
144
179
|
await this.#run();
|
|
145
180
|
}
|
|
@@ -207,9 +242,10 @@ export class AgUiClient {
|
|
|
207
242
|
return;
|
|
208
243
|
}
|
|
209
244
|
const pending: AgUiToolCall[] = [];
|
|
245
|
+
const runState = { terminal: false };
|
|
210
246
|
await this.#agent.runAgent(
|
|
211
247
|
{ tools: this.#getTools(), context: this.#getContext() },
|
|
212
|
-
this.#buildSubscriber(pending),
|
|
248
|
+
this.#buildSubscriber(pending, runState),
|
|
213
249
|
);
|
|
214
250
|
this.#onPersist(this.#agent.messages);
|
|
215
251
|
// Cancelled mid-stream: the user said stop — don't execute the tool
|
|
@@ -217,6 +253,12 @@ export class AgUiClient {
|
|
|
217
253
|
if (this.#cancelled) {
|
|
218
254
|
return;
|
|
219
255
|
}
|
|
256
|
+
// The stream resolved without RUN_FINISHED / RUN_ERROR: the transport
|
|
257
|
+
// dropped mid-run. Surface it as an error so the UI doesn't rest silently
|
|
258
|
+
// with a stuck pending indicator (caught by #run → onError).
|
|
259
|
+
if (!runState.terminal) {
|
|
260
|
+
throw new ConnectionLostError(this.#connectionLostMessage);
|
|
261
|
+
}
|
|
220
262
|
if (this.#executeTool === null || pending.length === 0) {
|
|
221
263
|
return;
|
|
222
264
|
}
|
|
@@ -246,7 +288,7 @@ export class AgUiClient {
|
|
|
246
288
|
}
|
|
247
289
|
}
|
|
248
290
|
|
|
249
|
-
#buildSubscriber(pending: AgUiToolCall[]): AgentSubscriber {
|
|
291
|
+
#buildSubscriber(pending: AgUiToolCall[], runState: { terminal: boolean }): AgentSubscriber {
|
|
250
292
|
const h = this.#handlers;
|
|
251
293
|
return {
|
|
252
294
|
onRunInitialized() {
|
|
@@ -271,9 +313,11 @@ export class AgUiClient {
|
|
|
271
313
|
h.onToolResult(event.toolCallId, event.content);
|
|
272
314
|
},
|
|
273
315
|
onRunErrorEvent({ event }) {
|
|
316
|
+
runState.terminal = true;
|
|
274
317
|
h.onError(event.message);
|
|
275
318
|
},
|
|
276
319
|
onRunFinalized() {
|
|
320
|
+
runState.terminal = true;
|
|
277
321
|
h.onRunEnd();
|
|
278
322
|
},
|
|
279
323
|
};
|
|
@@ -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
|
+
}
|
|
@@ -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
|
@@ -25,9 +25,11 @@ export {
|
|
|
25
25
|
type AgUiClientHandlers,
|
|
26
26
|
type AgUiRunInputs,
|
|
27
27
|
type AgUiToolCall,
|
|
28
|
+
ConnectionLostError,
|
|
28
29
|
type ExecuteTool,
|
|
29
30
|
type ToolExecution,
|
|
30
31
|
} from "./core/agui_client.js";
|
|
32
|
+
export { type AttachmentRef, messageAttachments } from "./core/attachment.js";
|
|
31
33
|
export {
|
|
32
34
|
type ClientConversationStore,
|
|
33
35
|
type NavigationCheckpoint,
|
|
@@ -41,6 +43,11 @@ export {
|
|
|
41
43
|
} from "./core/create_http_agent.js";
|
|
42
44
|
export { defineAgUiChat } from "./core/define_ag_ui_chat.js";
|
|
43
45
|
export { RemoteConversationStore } from "./core/remote_conversation_store.js";
|
|
46
|
+
export {
|
|
47
|
+
type UploadHandler,
|
|
48
|
+
type UploadOptions,
|
|
49
|
+
uploadAttachment,
|
|
50
|
+
} from "./core/upload_attachment.js";
|
|
44
51
|
export {
|
|
45
52
|
type FlashOptions,
|
|
46
53
|
focusWithFlash,
|
|
@@ -72,6 +79,11 @@ export type { Skill } from "./skills/skill.js";
|
|
|
72
79
|
export { type ClientTool, ClientToolRegistry } from "./tools/client_tool_registry.js";
|
|
73
80
|
export { isDestructive } from "./tools/is_destructive.js";
|
|
74
81
|
export { isNavigates } from "./tools/is_navigates.js";
|
|
82
|
+
export {
|
|
83
|
+
createPageActionTools,
|
|
84
|
+
PAGE_ACTIONS,
|
|
85
|
+
type ResolvePageTarget,
|
|
86
|
+
} from "./tools/page_action_tools.js";
|
|
75
87
|
export { createPageMapContext, type PageMap } from "./tools/page_map.js";
|
|
76
88
|
export { parseToolCatalog, type ToolCatalogEntry } from "./tools/parse_tool_catalog.js";
|
|
77
89
|
export {
|
|
@@ -94,4 +106,5 @@ export {
|
|
|
94
106
|
type ToolCallStatus,
|
|
95
107
|
type ToolDisplayMode,
|
|
96
108
|
} from "./ui/tool_call_card.js";
|
|
109
|
+
export { DEFAULT_UI_STRINGS, mergeUiStrings, type UiStrings } from "./ui/ui_strings.js";
|
|
97
110
|
export { VERSION } from "./version.js";
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { X_SUMMARY_KEY } from "../constants.js";
|
|
2
|
+
import { scrollIntoCenterView } from "../dom/animations.js";
|
|
3
|
+
import type { ClientTool } from "./client_tool_registry.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve a page-action target string to a host-page element, or `null` when it
|
|
7
|
+
* matches nothing. The default (`document.querySelector`) treats the string as a
|
|
8
|
+
* CSS selector; a host with a page map overrides it to map its own element ids,
|
|
9
|
+
* the same way host packages wrap the DOM-driver primitives with environment-
|
|
10
|
+
* aware lookups.
|
|
11
|
+
*/
|
|
12
|
+
export type ResolvePageTarget = (target: string) => HTMLElement | null;
|
|
13
|
+
|
|
14
|
+
/** The built-in page-action tool tokens, opted in via `data-page-actions`. */
|
|
15
|
+
export const PAGE_ACTIONS = {
|
|
16
|
+
SCROLL: "scroll",
|
|
17
|
+
DRAG: "drag",
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The opt-in page-action tools selected by `enabled` (a set of
|
|
22
|
+
* {@link PAGE_ACTIONS} tokens), bound to a {@link ResolvePageTarget}.
|
|
23
|
+
*
|
|
24
|
+
* - `scroll_to` — scroll a target (`top` / `bottom` / a resolver target) into
|
|
25
|
+
* view. Benign; no confirmation.
|
|
26
|
+
* - `drag_and_drop` — drag one element onto another, firing the standard HTML5
|
|
27
|
+
* drag sequence so the host page's own drop handler reacts. Not stamped
|
|
28
|
+
* destructive: a drag rearranges transient state and the durable change
|
|
29
|
+
* happens at the page's explicit commit. A host whose page persists *on drop*
|
|
30
|
+
* gates it with the element's `confirmPredicate`.
|
|
31
|
+
*
|
|
32
|
+
* Both report a clean, model-readable error when a target resolves to nothing,
|
|
33
|
+
* rather than throwing an opaque crash.
|
|
34
|
+
*/
|
|
35
|
+
export function createPageActionTools(
|
|
36
|
+
enabled: ReadonlySet<string>,
|
|
37
|
+
resolveTarget: ResolvePageTarget,
|
|
38
|
+
): ClientTool[] {
|
|
39
|
+
const tools: ClientTool[] = [];
|
|
40
|
+
if (enabled.has(PAGE_ACTIONS.SCROLL)) {
|
|
41
|
+
tools.push(scrollTool(resolveTarget));
|
|
42
|
+
}
|
|
43
|
+
if (enabled.has(PAGE_ACTIONS.DRAG)) {
|
|
44
|
+
tools.push(dragTool(resolveTarget));
|
|
45
|
+
}
|
|
46
|
+
return tools;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function scrollTool(resolveTarget: ResolvePageTarget): ClientTool {
|
|
50
|
+
return {
|
|
51
|
+
name: "scroll_to",
|
|
52
|
+
description:
|
|
53
|
+
"Scroll a target into view. `target` is `top`, `bottom`, or a CSS " +
|
|
54
|
+
"selector / page-map element id. Read-only: it changes nothing on the page.",
|
|
55
|
+
parameters: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: { target: { type: "string" } },
|
|
58
|
+
required: ["target"],
|
|
59
|
+
[X_SUMMARY_KEY]: "Scroll into view",
|
|
60
|
+
},
|
|
61
|
+
handler: (args) => {
|
|
62
|
+
const target = String(args["target"] ?? "");
|
|
63
|
+
if (target === "top" || target === "bottom") {
|
|
64
|
+
const top = target === "top" ? 0 : document.body.scrollHeight;
|
|
65
|
+
window.scrollTo({ top, behavior: "smooth" });
|
|
66
|
+
return { scrolled: true, target };
|
|
67
|
+
}
|
|
68
|
+
const element = resolveTarget(target);
|
|
69
|
+
if (element === null) {
|
|
70
|
+
throw new Error(`no element matching "${target}"`);
|
|
71
|
+
}
|
|
72
|
+
scrollIntoCenterView(element);
|
|
73
|
+
return { scrolled: true, target };
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function dragTool(resolveTarget: ResolvePageTarget): ClientTool {
|
|
79
|
+
return {
|
|
80
|
+
name: "drag_and_drop",
|
|
81
|
+
description:
|
|
82
|
+
"Drag the `from` element onto the `to` element (CSS selectors or page-map " +
|
|
83
|
+
"element ids), firing the page's native drag-and-drop. Use for reordering " +
|
|
84
|
+
"sortable lists. The page decides what the drop commits.",
|
|
85
|
+
parameters: {
|
|
86
|
+
type: "object",
|
|
87
|
+
properties: { from: { type: "string" }, to: { type: "string" } },
|
|
88
|
+
required: ["from", "to"],
|
|
89
|
+
[X_SUMMARY_KEY]: "Drag and drop",
|
|
90
|
+
},
|
|
91
|
+
handler: (args) => {
|
|
92
|
+
const fromTarget = String(args["from"] ?? "");
|
|
93
|
+
const toTarget = String(args["to"] ?? "");
|
|
94
|
+
const from = resolveTarget(fromTarget);
|
|
95
|
+
if (from === null) {
|
|
96
|
+
throw new Error(`no element matching "${fromTarget}"`);
|
|
97
|
+
}
|
|
98
|
+
const to = resolveTarget(toTarget);
|
|
99
|
+
if (to === null) {
|
|
100
|
+
throw new Error(`no element matching "${toTarget}"`);
|
|
101
|
+
}
|
|
102
|
+
dispatchDragSequence(from, to);
|
|
103
|
+
return { dragged: true, from: fromTarget, to: toTarget };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Fire the standard HTML5 drag sequence — `dragstart` on the source, then
|
|
110
|
+
* `dragenter` / `dragover` / `drop` on the target, then `dragend` on the source —
|
|
111
|
+
* sharing one {@link DataTransfer}. Dispatched as typed, bubbling events (the
|
|
112
|
+
* `dataTransfer` is attached explicitly so a drop handler reads it in every
|
|
113
|
+
* environment).
|
|
114
|
+
*/
|
|
115
|
+
function dispatchDragSequence(from: HTMLElement, to: HTMLElement): void {
|
|
116
|
+
const dataTransfer = new DataTransfer();
|
|
117
|
+
fire(from, "dragstart", dataTransfer);
|
|
118
|
+
fire(to, "dragenter", dataTransfer);
|
|
119
|
+
fire(to, "dragover", dataTransfer);
|
|
120
|
+
fire(to, "drop", dataTransfer);
|
|
121
|
+
fire(from, "dragend", dataTransfer);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function fire(target: HTMLElement, type: string, dataTransfer: DataTransfer): void {
|
|
125
|
+
const event = new Event(type, { bubbles: true, cancelable: true }) as Event & {
|
|
126
|
+
dataTransfer: DataTransfer;
|
|
127
|
+
};
|
|
128
|
+
event.dataTransfer = dataTransfer;
|
|
129
|
+
target.dispatchEvent(event);
|
|
130
|
+
}
|
|
@@ -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
|
+
}
|