@artooi/ag-ui-web-component 0.38.0 → 0.40.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 (142) hide show
  1. package/CHANGELOG.md +515 -1
  2. package/README.md +232 -56
  3. package/dist/ag-ui-web-component.bundle.js +370 -139
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +17 -14
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/activity_registry.d.ts +46 -0
  8. package/dist/core/activity_registry.d.ts.map +1 -0
  9. package/dist/core/ag_ui_chat.d.ts +23 -10
  10. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  11. package/dist/core/agui_client.d.ts +28 -0
  12. package/dist/core/agui_client.d.ts.map +1 -1
  13. package/dist/core/client_seed.d.ts +27 -0
  14. package/dist/core/client_seed.d.ts.map +1 -0
  15. package/dist/core/read_max_tool_rounds.d.ts +9 -0
  16. package/dist/core/read_max_tool_rounds.d.ts.map +1 -0
  17. package/dist/core/run_handlers.d.ts +80 -0
  18. package/dist/core/run_handlers.d.ts.map +1 -0
  19. package/dist/core/storage_scope.d.ts +110 -0
  20. package/dist/core/storage_scope.d.ts.map +1 -0
  21. package/dist/core/tool_outcome.d.ts +2 -3
  22. package/dist/core/tool_outcome.d.ts.map +1 -1
  23. package/dist/core/utils.d.ts +46 -0
  24. package/dist/core/utils.d.ts.map +1 -1
  25. package/dist/index.js +16268 -14747
  26. package/dist/index.js.map +4 -4
  27. package/dist/skills/skill_catalog.d.ts +67 -0
  28. package/dist/skills/skill_catalog.d.ts.map +1 -0
  29. package/dist/skills/skill_name_from.d.ts +12 -0
  30. package/dist/skills/skill_name_from.d.ts.map +1 -0
  31. package/dist/tools/tool_catalog.d.ts +79 -0
  32. package/dist/tools/tool_catalog.d.ts.map +1 -0
  33. package/dist/tools/tool_dispatch.d.ts +104 -0
  34. package/dist/tools/tool_dispatch.d.ts.map +1 -0
  35. package/dist/ui/composer/attachment_tray.d.ts.map +1 -1
  36. package/dist/ui/composer/auto_grow.d.ts +10 -0
  37. package/dist/ui/composer/auto_grow.d.ts.map +1 -0
  38. package/dist/ui/composer/composer_attachments.d.ts +57 -0
  39. package/dist/ui/composer/composer_attachments.d.ts.map +1 -0
  40. package/dist/ui/composer/composer_voice.d.ts +48 -0
  41. package/dist/ui/composer/composer_voice.d.ts.map +1 -0
  42. package/dist/ui/composer/voice_input.d.ts.map +1 -1
  43. package/dist/ui/excerpts/transcript_quote_offer.d.ts +57 -0
  44. package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -0
  45. package/dist/ui/fill_ui_string.d.ts +32 -0
  46. package/dist/ui/fill_ui_string.d.ts.map +1 -0
  47. package/dist/ui/history/conversation_history.d.ts +203 -0
  48. package/dist/ui/history/conversation_history.d.ts.map +1 -0
  49. package/dist/ui/history/relative_time.d.ts.map +1 -1
  50. package/dist/ui/history/thread_drawer.d.ts +0 -14
  51. package/dist/ui/history/thread_drawer.d.ts.map +1 -1
  52. package/dist/ui/interrupts/confirmation_card.d.ts.map +1 -1
  53. package/dist/ui/interrupts/pending_decision.d.ts +23 -0
  54. package/dist/ui/interrupts/pending_decision.d.ts.map +1 -0
  55. package/dist/ui/placement/is_collapsible_placement.d.ts +18 -0
  56. package/dist/ui/placement/is_collapsible_placement.d.ts.map +1 -0
  57. package/dist/ui/placement/is_draggable_placement.d.ts +9 -0
  58. package/dist/ui/placement/is_draggable_placement.d.ts.map +1 -0
  59. package/dist/ui/placement/launcher_drag.d.ts +6 -0
  60. package/dist/ui/placement/launcher_drag.d.ts.map +1 -1
  61. package/dist/ui/placement/panel_placement.d.ts +119 -0
  62. package/dist/ui/placement/panel_placement.d.ts.map +1 -0
  63. package/dist/ui/progress/run_announcer.d.ts +41 -0
  64. package/dist/ui/progress/run_announcer.d.ts.map +1 -0
  65. package/dist/ui/progress/subagent_progress.d.ts +58 -0
  66. package/dist/ui/progress/subagent_progress.d.ts.map +1 -0
  67. package/dist/ui/progress/tool_call_card.d.ts.map +1 -1
  68. package/dist/ui/shell/adopt_styles.d.ts +23 -0
  69. package/dist/ui/shell/adopt_styles.d.ts.map +1 -0
  70. package/dist/ui/shell/glyph_slot.d.ts +7 -0
  71. package/dist/ui/shell/glyph_slot.d.ts.map +1 -0
  72. package/dist/ui/shell/header_button.d.ts +10 -0
  73. package/dist/ui/shell/header_button.d.ts.map +1 -0
  74. package/dist/ui/shell/icon_element.d.ts +12 -0
  75. package/dist/ui/shell/icon_element.d.ts.map +1 -0
  76. package/dist/ui/shell/is_unread_badge_enabled.d.ts +10 -0
  77. package/dist/ui/shell/is_unread_badge_enabled.d.ts.map +1 -0
  78. package/dist/ui/shell/read_launcher_icon_url.d.ts +8 -0
  79. package/dist/ui/shell/read_launcher_icon_url.d.ts.map +1 -0
  80. package/dist/ui/styles.d.ts +1 -1
  81. package/dist/ui/styles.d.ts.map +1 -1
  82. package/dist/ui/transcript/answer_actions.d.ts +39 -0
  83. package/dist/ui/transcript/answer_actions.d.ts.map +1 -0
  84. package/dist/ui/transcript/answer_stream.d.ts +56 -0
  85. package/dist/ui/transcript/answer_stream.d.ts.map +1 -0
  86. package/dist/ui/transcript/render_or_warn.d.ts +16 -0
  87. package/dist/ui/transcript/render_or_warn.d.ts.map +1 -0
  88. package/dist/ui/transcript/starter_chips.d.ts +21 -0
  89. package/dist/ui/transcript/starter_chips.d.ts.map +1 -0
  90. package/dist/ui/transcript/transcript.d.ts +171 -0
  91. package/dist/ui/transcript/transcript.d.ts.map +1 -0
  92. package/dist/ui/ui_strings.d.ts +29 -1
  93. package/dist/ui/ui_strings.d.ts.map +1 -1
  94. package/package.json +1 -1
  95. package/src/constants.ts +17 -15
  96. package/src/core/activity_registry.ts +133 -0
  97. package/src/core/ag_ui_chat.ts +945 -4419
  98. package/src/core/agui_client.ts +172 -16
  99. package/src/core/client_seed.ts +27 -0
  100. package/src/core/read_max_tool_rounds.ts +13 -0
  101. package/src/core/remote_conversation_store.ts +1 -1
  102. package/src/core/run_handlers.ts +501 -0
  103. package/src/core/storage_scope.ts +279 -0
  104. package/src/core/tool_outcome.ts +5 -3
  105. package/src/core/utils.ts +109 -0
  106. package/src/skills/skill_catalog.ts +166 -0
  107. package/src/skills/skill_name_from.ts +19 -0
  108. package/src/tools/tool_catalog.ts +354 -0
  109. package/src/tools/tool_dispatch.ts +521 -0
  110. package/src/ui/composer/attachment_tray.ts +6 -5
  111. package/src/ui/composer/auto_grow.ts +12 -0
  112. package/src/ui/composer/composer_attachments.ts +324 -0
  113. package/src/ui/composer/composer_voice.ts +93 -0
  114. package/src/ui/composer/voice_input.ts +4 -4
  115. package/src/ui/excerpts/transcript_quote_offer.ts +198 -0
  116. package/src/ui/fill_ui_string.ts +41 -0
  117. package/src/ui/history/conversation_history.ts +699 -0
  118. package/src/ui/history/relative_time.ts +5 -4
  119. package/src/ui/history/thread_drawer.ts +3 -50
  120. package/src/ui/interrupts/confirmation_card.ts +4 -2
  121. package/src/ui/interrupts/pending_decision.ts +34 -0
  122. package/src/ui/placement/is_collapsible_placement.ts +19 -0
  123. package/src/ui/placement/is_draggable_placement.ts +23 -0
  124. package/src/ui/placement/launcher_drag.ts +104 -89
  125. package/src/ui/placement/panel_placement.ts +1130 -0
  126. package/src/ui/progress/run_announcer.ts +67 -0
  127. package/src/ui/progress/subagent_progress.ts +201 -0
  128. package/src/ui/progress/tool_call_card.ts +2 -0
  129. package/src/ui/shell/adopt_styles.ts +28 -0
  130. package/src/ui/shell/glyph_slot.ts +12 -0
  131. package/src/ui/shell/header_button.ts +21 -0
  132. package/src/ui/shell/icon_element.ts +33 -0
  133. package/src/ui/shell/is_unread_badge_enabled.ts +11 -0
  134. package/src/ui/shell/read_launcher_icon_url.ts +9 -0
  135. package/src/ui/styles.ts +301 -70
  136. package/src/ui/transcript/answer_actions.ts +139 -0
  137. package/src/ui/transcript/answer_stream.ts +123 -0
  138. package/src/ui/transcript/render_or_warn.ts +22 -0
  139. package/src/ui/transcript/starter_chips.ts +42 -0
  140. package/src/ui/transcript/transcript.ts +429 -0
  141. package/src/ui/ui_strings.ts +38 -1
  142. package/src/version.ts +1 -1
@@ -0,0 +1,324 @@
1
+ import {
2
+ ATTACHMENT_EVENT,
3
+ DEFAULT_ATTACHMENT_MAX_BYTES,
4
+ PASTE_ATTACH_CHARS,
5
+ } from "../../constants.js";
6
+ import type { AttachmentsDetail } from "../../core/events/attachments_detail.js";
7
+ import { type UploadHandler, uploadAttachment } from "../../core/upload_attachment.js";
8
+ import type { UiStrings } from "../ui_strings.js";
9
+ import { AttachmentTray } from "./attachment_tray.js";
10
+
11
+ /** What the composer's attachments need from the element that owns them. */
12
+ export interface ComposerAttachmentsHost {
13
+ /** The custom element: its upload attributes, and where the event is dispatched. */
14
+ readonly element: HTMLElement;
15
+ /** The chat shell, which a file can be dropped or pasted anywhere on. */
16
+ readonly chat: HTMLElement;
17
+ /** Where the tray mounts, above the composer. */
18
+ readonly slot: HTMLElement;
19
+ /** The hidden multi-file input the paperclip opens. */
20
+ readonly fileInput: HTMLInputElement;
21
+ /** The paperclip, revealed once uploads are possible. */
22
+ readonly button: HTMLButtonElement;
23
+ /** The resolved string table. */
24
+ readonly strings: () => UiStrings;
25
+ /** The host's `uploadHandler`, read when the tray is wired. */
26
+ readonly uploadHandler: () => UploadHandler | null;
27
+ /** The headers a request to `url` carries. */
28
+ readonly headersFor: (url: string) => Record<string, string>;
29
+ /** The cookie policy every request carries, when one is configured. */
30
+ readonly credentialsOption: () => { credentials?: RequestCredentials };
31
+ }
32
+
33
+ /**
34
+ * Files handed to the composer: the tray they upload through, and the four
35
+ * ways into it -- the picker, a drop, a paste, and a host's `attachFile`.
36
+ *
37
+ * Owned one-to-one by an `<ag-ui-chat>`, and holding no state outside the
38
+ * instance.
39
+ */
40
+ export class ComposerAttachments {
41
+ readonly #host: ComposerAttachmentsHost;
42
+ /** Upload tray; created on connect only when `data-attachments-url` is set. */
43
+ #tray: AttachmentTray | null = null;
44
+
45
+ constructor(host: ComposerAttachmentsHost) {
46
+ this.#host = host;
47
+ }
48
+
49
+ /** The tray, once uploads are wired; `null` while the chat is text-only. */
50
+ get tray(): AttachmentTray | null {
51
+ return this.#tray;
52
+ }
53
+
54
+ /**
55
+ * Enable the composer's file-upload tray when uploads are possible — either a
56
+ * custom `uploadHandler` is set or `data-attachments-url` provides the
57
+ * built-in multipart endpoint: reveal the 📎 button, wire the hidden file
58
+ * input + drag-and-drop, and mount the tray. With neither, the affordance
59
+ * stays hidden and the chat degrades to text-only.
60
+ *
61
+ * Called on every connect, so it starts by taking down the tray the last
62
+ * connection mounted: that one was disposed when the element left, and the
63
+ * attributes that decide whether there is a tray at all may have changed
64
+ * since. The shell outlives a connection, so its listeners go under `signal`.
65
+ */
66
+ wire(signal: AbortSignal): void {
67
+ this.#tray?.element.remove();
68
+ this.#tray = null;
69
+ const url = this.#host.element.getAttribute("data-attachments-url");
70
+ const upload = this.#host.uploadHandler() ?? this.#defaultUploadHandler(url);
71
+ if (upload === null) {
72
+ return;
73
+ }
74
+ const accept = this.#host.element.getAttribute("data-attachment-accept") ?? "";
75
+ // Bound to the local rather than the field: the hook can only fire from a
76
+ // tray that exists, so passing it removes a null check no caller can reach.
77
+ const tray: AttachmentTray = new AttachmentTray({
78
+ upload,
79
+ maxBytes: this.#maxBytes(),
80
+ accept,
81
+ strings: this.#host.strings(),
82
+ // The tray's change hook, surfaced to the host as an event. A host
83
+ // driving its own composer could otherwise not tell a settled upload from
84
+ // one still in flight, which is the state sendMessage() has to be called
85
+ // with knowledge of.
86
+ onChange: () => this.#dispatch(tray),
87
+ });
88
+ this.#tray = tray;
89
+ this.#host.slot.appendChild(this.#tray.element);
90
+ this.#host.fileInput.accept = accept;
91
+ this.#host.button.hidden = false;
92
+ this.#enableDragAndDrop(signal);
93
+ this.#enablePaste(tray, signal);
94
+ }
95
+
96
+ /** The queueing behind `AgUiChat.attachFile`, whose doc is the contract. */
97
+ attach(file: File): boolean {
98
+ if (this.#tray === null) {
99
+ return false;
100
+ }
101
+ this.#tray.add(file);
102
+ return true;
103
+ }
104
+
105
+ /** Queue every file from the picker into the tray, then reset the input. */
106
+ onFilesPicked(): void {
107
+ const input = this.#host.fileInput;
108
+ const files = input.files;
109
+ if (files !== null) {
110
+ for (const file of Array.from(files)) {
111
+ this.#tray?.add(file);
112
+ }
113
+ }
114
+ // Reset so re-picking the same file fires `change` again.
115
+ input.value = "";
116
+ }
117
+
118
+ /** The built-in multipart upload handler for `data-attachments-url`, or `null`. */
119
+ #defaultUploadHandler(url: string | null): UploadHandler | null {
120
+ if (url === null) {
121
+ return null;
122
+ }
123
+ // Forward the tray's abort signal so removing a chip (or tearing the
124
+ // element down) cancels the XHR.
125
+ return (file, onProgress, signal) =>
126
+ uploadAttachment(file, {
127
+ url,
128
+ headers: this.#host.headersFor(url),
129
+ ...this.#host.credentialsOption(),
130
+ onProgress,
131
+ signal,
132
+ });
133
+ }
134
+
135
+ /** The client-side upload size cap from `data-attachment-max-bytes`. */
136
+ #maxBytes(): number {
137
+ const attr = this.#host.element.getAttribute("data-attachment-max-bytes");
138
+ if (attr === null) {
139
+ return DEFAULT_ATTACHMENT_MAX_BYTES;
140
+ }
141
+ const parsed = Number.parseInt(attr, 10);
142
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_ATTACHMENT_MAX_BYTES;
143
+ }
144
+
145
+ /** Accept files dropped anywhere on the chat shell into the tray. */
146
+ #enableDragAndDrop(signal: AbortSignal): void {
147
+ const chat = this.#host.chat;
148
+ chat.addEventListener(
149
+ "dragover",
150
+ (event) => {
151
+ event.preventDefault();
152
+ chat.classList.add("chat--dragover");
153
+ },
154
+ { signal },
155
+ );
156
+ chat.addEventListener(
157
+ "dragleave",
158
+ () => {
159
+ chat.classList.remove("chat--dragover");
160
+ },
161
+ { signal },
162
+ );
163
+ chat.addEventListener(
164
+ "drop",
165
+ (event) => {
166
+ event.preventDefault();
167
+ chat.classList.remove("chat--dragover");
168
+ const files = event.dataTransfer?.files;
169
+ if (files !== undefined) {
170
+ for (const file of Array.from(files)) {
171
+ this.#tray?.add(file);
172
+ }
173
+ }
174
+ },
175
+ { signal },
176
+ );
177
+ }
178
+
179
+ /**
180
+ * Turn a very long text paste into an attachment instead of a wall of text.
181
+ *
182
+ * A composer capped at `40vh` is not where forty thousand characters go: the
183
+ * user cannot read what they pasted, cannot edit around it, and sends one
184
+ * enormous turn. As a file it stays whole, the model still receives it, and
185
+ * the box is left for the question about it.
186
+ *
187
+ * Only where the host has configured uploads -- and structurally so, rather
188
+ * than by a check here: the paste listener is wired inside the attachment
189
+ * setup, so with no tray there is no listener at all and an ordinary paste is
190
+ * untouched. Quietly dropping a paste for being long would be far worse than
191
+ * an awkward composer. The tray is passed rather than read off the field for
192
+ * the same reason its `onChange` hook is: it can only be called from one that
193
+ * exists, so taking it as an argument removes a null check no caller can
194
+ * reach.
195
+ *
196
+ * Nothing is lost by removing the chip: the text is still on the clipboard,
197
+ * so pasting again brings it back. That is why this needs no undo of its own.
198
+ */
199
+ #pasteLongTextAsFile(event: ClipboardEvent, clipboard: DataTransfer, tray: AttachmentTray): void {
200
+ const threshold = this.#pasteAttachThreshold();
201
+ const text = clipboard.getData("text/plain");
202
+ if (threshold === null || text.length < threshold) {
203
+ return;
204
+ }
205
+ event.preventDefault();
206
+ tray.add(new File([text], `pasted-${pasteStamp()}.txt`, { type: "text/plain" }));
207
+ }
208
+
209
+ /**
210
+ * How long a pasted string has to be before it becomes a file, or `null` to
211
+ * leave every paste in the composer.
212
+ *
213
+ * One attribute with three answers rather than three attributes: absent is
214
+ * the default, `off` refuses, and a number states the threshold. A value that
215
+ * is neither says so, because a typo silently meaning "off" is the failure
216
+ * this whole release keeps finding.
217
+ */
218
+ #pasteAttachThreshold(): number | null {
219
+ const raw = this.#host.element.getAttribute("data-paste-attach");
220
+ if (raw === null) {
221
+ return PASTE_ATTACH_CHARS;
222
+ }
223
+ if (raw === "off") {
224
+ return null;
225
+ }
226
+ const stated = Number.parseInt(raw, 10);
227
+ if (Number.isNaN(stated) || stated <= 0) {
228
+ console.warn(
229
+ `<ag-ui-chat>: data-paste-attach="${raw}" is neither "off" nor a positive ` +
230
+ `number of characters, so the default of ${PASTE_ATTACH_CHARS} is used.`,
231
+ );
232
+ return PASTE_ATTACH_CHARS;
233
+ }
234
+ return stated;
235
+ }
236
+
237
+ /**
238
+ * Accept files pasted into the composer.
239
+ *
240
+ * The whole tray already exists behind this: a paste is one more way to hand
241
+ * it a `File`, alongside the picker and a drop.
242
+ *
243
+ * Two rules keep it from stealing a paste that was never about files.
244
+ * `clipboardData.files` is empty for text, so ordinary pasting is untouched.
245
+ * And the default is only prevented when the clipboard carries **no text**:
246
+ * copying a rich selection that happens to contain an image puts both on the
247
+ * clipboard, and swallowing the words someone meant to paste in order to
248
+ * attach a picture they did not is the worse of the two failures.
249
+ */
250
+ #enablePaste(tray: AttachmentTray, signal: AbortSignal): void {
251
+ this.#host.chat.addEventListener(
252
+ "paste",
253
+ (event: ClipboardEvent) => {
254
+ // Nullish rather than a null check: the property is typed as nullable,
255
+ // and an engine that fires a plain Event for a paste leaves it absent
256
+ // instead, which is not the same value and is the same situation.
257
+ const clipboard = event.clipboardData ?? null;
258
+ if (clipboard === null) {
259
+ return;
260
+ }
261
+ const files = Array.from(clipboard.files);
262
+ if (files.length === 0) {
263
+ this.#pasteLongTextAsFile(event, clipboard, tray);
264
+ return;
265
+ }
266
+ if (clipboard.getData("text/plain") === "") {
267
+ event.preventDefault();
268
+ }
269
+ for (const file of files) {
270
+ this.#tray?.add(named(file));
271
+ }
272
+ },
273
+ { signal },
274
+ );
275
+ }
276
+
277
+ /** Tell the host what the tray now holds — see {@link ATTACHMENT_EVENT}. */
278
+ #dispatch(tray: AttachmentTray): void {
279
+ this.#host.element.dispatchEvent(
280
+ new CustomEvent<AttachmentsDetail>(ATTACHMENT_EVENT, {
281
+ detail: { attachments: tray.readyRefs(), pending: tray.pendingCount() },
282
+ bubbles: true,
283
+ composed: true,
284
+ }),
285
+ );
286
+ }
287
+ }
288
+
289
+ /**
290
+ * The moment of a paste, as a string a filename can carry.
291
+ *
292
+ * ISO 8601 with its colons and the decimal point replaced, because both are
293
+ * reserved in a filename on at least one platform a download lands on. Shared
294
+ * by the two paste paths, so a pasted image and a long text pasted as a file
295
+ * are named the same way.
296
+ */
297
+ function pasteStamp(): string {
298
+ return new Date().toISOString().replace(/[:.]/g, "-");
299
+ }
300
+
301
+ /**
302
+ * A pasted file, guaranteed to have a name.
303
+ *
304
+ * A file dropped or picked always carries one; a pasted one need not. Some
305
+ * engines hand over a blob with an empty name, which travels all the way to
306
+ * the upload as an empty `filename` and lands on the server as a file nobody
307
+ * can identify -- while the chip in the tray shows an empty label. A file that
308
+ * already has a name keeps it, including the generic one Chrome gives a pasted
309
+ * screenshot: it is at least what the file is, and the chip shows the size
310
+ * beside it.
311
+ */
312
+ function named(file: File): File {
313
+ if (file.name !== "") {
314
+ return file;
315
+ }
316
+ // The subtype is the extension for every clipboard image type worth naming.
317
+ // A type with no slash in it falls back to the whole string, and an absent
318
+ // one leaves a bare stamp rather than a name ending in a dot.
319
+ const subtype = file.type.split("/")[1] ?? file.type;
320
+ const stamp = pasteStamp();
321
+ return new File([file], subtype === "" ? `pasted-${stamp}` : `pasted-${stamp}.${subtype}`, {
322
+ type: file.type,
323
+ });
324
+ }
@@ -0,0 +1,93 @@
1
+ import { type TranscribeHandler, transcribeAudio } from "../../core/transcribe_audio.js";
2
+ import type { UiStrings } from "../ui_strings.js";
3
+ import { VoiceInput } from "./voice_input.js";
4
+
5
+ /** What the composer's mic needs from the element that owns it. */
6
+ export interface ComposerVoiceHost {
7
+ /** The custom element, whose `data-transcribe-url` names the built-in endpoint. */
8
+ readonly element: HTMLElement;
9
+ /** Where the mic button mounts, in the composer's tool row. */
10
+ readonly slot: HTMLElement;
11
+ /** The composer a transcript is written into. */
12
+ readonly input: HTMLTextAreaElement;
13
+ /** The resolved string table. */
14
+ readonly strings: () => UiStrings;
15
+ /** The host's `transcribeHandler`, read when the mic is wired. */
16
+ readonly transcribeHandler: () => TranscribeHandler | null;
17
+ /** The headers a request to `url` carries. */
18
+ readonly headersFor: (url: string) => Record<string, string>;
19
+ /** The cookie policy every request carries, when one is configured. */
20
+ readonly credentialsOption: () => { credentials?: RequestCredentials };
21
+ /** React to the composer's value changing, as typing does. */
22
+ readonly onInput: () => void;
23
+ }
24
+
25
+ /**
26
+ * The composer's mic: mounted when transcription is possible, and what a
27
+ * transcript does to the composer.
28
+ *
29
+ * Owned one-to-one by an `<ag-ui-chat>`, and holding no state outside the
30
+ * instance.
31
+ */
32
+ export class ComposerVoice {
33
+ readonly #host: ComposerVoiceHost;
34
+ /** Voice-input control; created on connect when transcription is available. */
35
+ #voice: VoiceInput | null = null;
36
+
37
+ constructor(host: ComposerVoiceHost) {
38
+ this.#host = host;
39
+ }
40
+
41
+ /**
42
+ * Reveal the composer's 🎤 mic button when transcription is possible — either
43
+ * a custom `transcribeHandler` is set or `data-transcribe-url` provides the
44
+ * built-in POST endpoint. The control records via `MediaRecorder` and drops
45
+ * the transcript into the composer; with neither configured the mic stays
46
+ * hidden and the chat is text-only.
47
+ *
48
+ * Called on every connect, so it starts by taking down the mic the last
49
+ * connection mounted, which was released when the element left.
50
+ */
51
+ wire(): void {
52
+ this.#voice?.element.remove();
53
+ this.#voice = null;
54
+ const url = this.#host.element.getAttribute("data-transcribe-url");
55
+ const transcribe = this.#host.transcribeHandler() ?? this.#defaultTranscribeHandler(url);
56
+ if (transcribe === null) {
57
+ return;
58
+ }
59
+ this.#voice = new VoiceInput({
60
+ transcribe,
61
+ onText: (text) => this.#insertText(text),
62
+ strings: this.#host.strings(),
63
+ });
64
+ this.#host.slot.appendChild(this.#voice.element);
65
+ }
66
+
67
+ /** Release the mic, so the browser's recording indicator clears. */
68
+ dispose(): void {
69
+ this.#voice?.dispose();
70
+ }
71
+
72
+ /** The built-in transcription handler for `data-transcribe-url`, or `null`. */
73
+ #defaultTranscribeHandler(url: string | null): TranscribeHandler | null {
74
+ if (url === null) {
75
+ return null;
76
+ }
77
+ return (audio) =>
78
+ transcribeAudio(audio, {
79
+ url,
80
+ headers: this.#host.headersFor(url),
81
+ ...this.#host.credentialsOption(),
82
+ });
83
+ }
84
+
85
+ /** Drop a voice transcript into the composer (appended to any typed text). */
86
+ #insertText(text: string): void {
87
+ const input = this.#host.input;
88
+ const current = input.value.trim();
89
+ input.value = current === "" ? text : `${current} ${text}`;
90
+ this.#host.onInput();
91
+ input.focus();
92
+ }
93
+ }
@@ -1,5 +1,6 @@
1
1
  import { ICON_VOICE } from "../../constants.js";
2
2
  import type { TranscribeHandler } from "../../core/transcribe_audio.js";
3
+ import { fillUiString } from "../fill_ui_string.js";
3
4
  import { DEFAULT_UI_STRINGS, type UiStrings } from "../ui_strings.js";
4
5
 
5
6
  /** Lifecycle of the mic button, reflected on its `data-state` for CSS. */
@@ -164,10 +165,9 @@ export class VoiceInput {
164
165
  // #setState has just reset the tooltip to the idle label, so this goes
165
166
  // after it. It says why the mic went quiet on its own — the transcript
166
167
  // below is the proof nothing was thrown away.
167
- this.element.title = this.#strings.recordingLimit.replace(
168
- "{n}",
169
- String(MAX_RECORDING_MS / 60_000),
170
- );
168
+ this.element.title = fillUiString(this.#strings.recordingLimit, {
169
+ n: MAX_RECORDING_MS / 60_000,
170
+ });
171
171
  }
172
172
  if (text !== "") {
173
173
  this.#onText(text);
@@ -0,0 +1,198 @@
1
+ import type { UiStrings } from "../ui_strings.js";
2
+ import { attachQuoteOffer, type PageQuoteOffer } from "./page_quote_offer.js";
3
+ import { asQuote, quotableSelection } from "./quote_selection.js";
4
+
5
+ /** Pixels between a selection and the offer to quote it. */
6
+ const QUOTE_GAP = 6;
7
+
8
+ /** What the quote offer needs from the element that owns it. */
9
+ export interface TranscriptQuoteHost {
10
+ /** The custom element: its opt-out attribute, and what a page offer excludes. */
11
+ readonly element: HTMLElement;
12
+ /** The shadow root a transcript selection is read through. */
13
+ readonly root: ShadowRoot;
14
+ /** The transcript, whose selections are offered. */
15
+ readonly messages: HTMLElement;
16
+ /** The transcript's positioning box, which the offer floats inside. */
17
+ readonly messagesWrap: HTMLElement;
18
+ /** The composer a quotation is written into. */
19
+ readonly input: HTMLTextAreaElement;
20
+ /** The resolved string table. */
21
+ readonly strings: () => UiStrings;
22
+ /** Resize the composer to what it now holds. */
23
+ readonly autoGrow: () => void;
24
+ /** The element's public `quote`, so a host that replaced it is the one called. */
25
+ readonly quote: (text: string) => void;
26
+ }
27
+
28
+ /**
29
+ * Quoting a selection into the composer: the offer floated beside a selection
30
+ * in the transcript, the same offer over the host page, and the quotation
31
+ * itself.
32
+ *
33
+ * Owned one-to-one by an `<ag-ui-chat>`, and holding no state outside the
34
+ * instance.
35
+ */
36
+ export class TranscriptQuoteOffer {
37
+ /**
38
+ * Offer to quote the current selection, floated beside it.
39
+ *
40
+ * Shares the transcript's positioning box with the jump button for the same
41
+ * reason: it is positioned against the transcript, and must not scroll away
42
+ * with the words it is pointing at.
43
+ */
44
+ readonly button: HTMLButtonElement = document.createElement("button");
45
+
46
+ readonly #host: TranscriptQuoteHost;
47
+ /** What {@link button} would quote, while it is showing. */
48
+ #quoting = "";
49
+ /** The host-page offer, while one is attached; see {@link offerInPage}. */
50
+ #pageQuote: PageQuoteOffer | null = null;
51
+
52
+ constructor(host: TranscriptQuoteHost) {
53
+ this.#host = host;
54
+ }
55
+
56
+ /** The quotation behind `AgUiChat.quote`, whose doc is the contract. */
57
+ insert(text: string): void {
58
+ const quoted = asQuote(text);
59
+ if (quoted === "") {
60
+ return;
61
+ }
62
+ const input = this.#host.input;
63
+ // Appended after whatever is already typed, on a fresh paragraph: a second
64
+ // quotation is a second thing being asked about, not a replacement for the
65
+ // first. Trailing blank lines are dropped so repeated quoting does not
66
+ // accumulate gaps.
67
+ const current = input.value.replace(/\s+$/, "");
68
+ input.value = current === "" ? quoted : `${current}\n\n${quoted}`;
69
+ this.#host.autoGrow();
70
+ input.focus();
71
+ const end = input.value.length;
72
+ input.setSelectionRange(end, end);
73
+ }
74
+
75
+ /** The offer behind `AgUiChat.offerQuoteInPage`, whose doc is the contract. */
76
+ offerInPage(within: HTMLElement): () => void {
77
+ this.#pageQuote?.detach();
78
+ const offer = attachQuoteOffer({
79
+ within,
80
+ label: this.#host.strings().quoteSelection,
81
+ exclude: this.#host.element,
82
+ onQuote: (text) => this.#host.quote(text),
83
+ });
84
+ this.#pageQuote = offer;
85
+ return () => {
86
+ offer.detach();
87
+ if (this.#pageQuote === offer) {
88
+ this.#pageQuote = null;
89
+ }
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Take the host-page offer down. It listens on the host's document, not on
95
+ * anything of ours, so nothing else would ever take it down.
96
+ */
97
+ detachPageOffer(): void {
98
+ this.#pageQuote?.detach();
99
+ this.#pageQuote = null;
100
+ }
101
+
102
+ /**
103
+ * Build the offer and listen for settled selections in the transcript.
104
+ *
105
+ * The button and the transcript outlive a connection, so the listeners go
106
+ * under `signal`, which the element aborts when it leaves the document.
107
+ */
108
+ mount(signal: AbortSignal): void {
109
+ const button = this.button;
110
+ button.className = "quote-selection";
111
+ button.type = "button";
112
+ button.setAttribute("part", "quote-selection");
113
+ button.textContent = this.#host.strings().quoteSelection;
114
+ button.hidden = true;
115
+ // `mousedown` rather than `click`: pressing anywhere else collapses the
116
+ // selection first, and by the time a click lands there is nothing left to
117
+ // quote. Preventing the default keeps the selection alive long enough to
118
+ // read it.
119
+ button.addEventListener(
120
+ "mousedown",
121
+ (event) => {
122
+ event.preventDefault();
123
+ },
124
+ { signal },
125
+ );
126
+ button.addEventListener(
127
+ "click",
128
+ () => {
129
+ this.#host.quote(this.#quoting);
130
+ window.getSelection()?.removeAllRanges();
131
+ this.#hide();
132
+ },
133
+ { signal },
134
+ );
135
+
136
+ // A settled selection, by either input. `mouseup` rather than
137
+ // `selectionchange` so the offer does not chase the pointer mid-drag; the
138
+ // second half of the same gesture, `mousedown`, retires the previous offer
139
+ // before the new selection exists.
140
+ const messages = this.#host.messages;
141
+ messages.addEventListener("mouseup", (event) => this.#onSelectionSettled(event), { signal });
142
+ messages.addEventListener("keyup", () => this.#onSelectionSettled(), { signal });
143
+ messages.addEventListener("mousedown", () => this.#hide(), { signal });
144
+ }
145
+
146
+ /** Whether the transcript offers to quote what the user selects. */
147
+ #enabled(): boolean {
148
+ return this.#host.element.getAttribute("data-quote-selection") !== "false";
149
+ }
150
+
151
+ /**
152
+ * Offer to quote the settled selection, or retire the offer.
153
+ *
154
+ * `event` is passed for its coordinates and only those: they say which line
155
+ * of a selection spanning several messages the offer should hang from. A
156
+ * keyboard selection has none, and the first line is used instead.
157
+ */
158
+ #onSelectionSettled(event?: MouseEvent): void {
159
+ if (!this.#enabled()) {
160
+ return;
161
+ }
162
+ const near = event === undefined ? undefined : { x: event.clientX, y: event.clientY };
163
+ const selected = quotableSelection(this.#host.messages, [this.#host.root], near);
164
+ if (selected === null) {
165
+ this.#hide();
166
+ return;
167
+ }
168
+ this.#quoting = selected.text;
169
+ this.#place(selected.rect);
170
+ }
171
+
172
+ /** Float the offer beside `rect`, kept inside the transcript's own box. */
173
+ #place(rect: DOMRect): void {
174
+ const button = this.button;
175
+ // Unhidden first: a hidden element measures zero, and its own size is what
176
+ // decides whether it fits above the selection and how far to pull it left.
177
+ button.hidden = false;
178
+ const wrap = this.#host.messagesWrap.getBoundingClientRect();
179
+ const top = rect.top - wrap.top;
180
+ // Above the selection by default, below it when there is no room --
181
+ // selecting the first line of the transcript is the ordinary case, not an
182
+ // edge one, and an offer clipped by the header is an offer nobody takes.
183
+ const below = top < QUOTE_GAP + button.offsetHeight;
184
+ button.dataset["below"] = String(below);
185
+ button.style.top = `${below ? rect.bottom - wrap.top + QUOTE_GAP : top - QUOTE_GAP}px`;
186
+ // Centred on the selection, then pulled back by its own half-width so a
187
+ // selection at either margin does not push the offer out of the panel.
188
+ const half = button.offsetWidth / 2;
189
+ const centre = rect.left + rect.width / 2 - wrap.left;
190
+ button.style.left = `${Math.min(Math.max(centre, half), wrap.width - half)}px`;
191
+ }
192
+
193
+ /** Retire the offer, and forget what it was pointing at. */
194
+ #hide(): void {
195
+ this.button.hidden = true;
196
+ this.#quoting = "";
197
+ }
198
+ }
@@ -0,0 +1,41 @@
1
+ /** A `{token}` placeholder, named the way every key in the string table names one. */
2
+ const TOKEN_RE = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
3
+
4
+ /**
5
+ * Fill the `{token}` placeholders of a string-table template.
6
+ *
7
+ * Every string-table template goes through here rather than through
8
+ * `template.replace("{token}", value)`, which was wrong in two ways at once:
9
+ *
10
+ * - **A string replacement interprets dollar patterns in the value.** `$&`
11
+ * inserts the matched token, and `` $` `` and `$'` the text either side of it,
12
+ * so a queued message reading `costs $& more` was labelled
13
+ * `Do not send "costs {text} more"`. Several of these values are text a user
14
+ * or a server wrote -- a queued message, a tool name, a skill title, an
15
+ * agent name -- so the value has to land verbatim. A replacer function's
16
+ * return value is never interpreted, which is the whole fix.
17
+ * - **A string pattern replaces only the first occurrence**, so a translation
18
+ * that uses a token twice was left half filled.
19
+ *
20
+ * One pass over the template, so a value that itself contains a `{token}` is
21
+ * inserted as written rather than filled in turn: a skill titled
22
+ * `Fill {fields}` stays that, where chained replacements filled it twice.
23
+ *
24
+ * A token with no entry in `values` is left as written. Every call site passes
25
+ * the tokens its key documents, so that is a translation carrying a token the
26
+ * call site does not know, and printing it is more honest than dropping it.
27
+ * `skills/fill_template.ts` is the prompt-side counterpart, and its contract is
28
+ * different on purpose: a prompt reports what it could not fill so a send can
29
+ * be refused.
30
+ *
31
+ * @param template - A string-table value, e.g. `strings.removeQueued`.
32
+ * @param values - The value for each token the template's key documents.
33
+ */
34
+ export function fillUiString(
35
+ template: string,
36
+ values: Readonly<Record<string, string | number>>,
37
+ ): string {
38
+ return template.replace(TOKEN_RE, (token, key: string) =>
39
+ Object.hasOwn(values, key) ? String(values[key]) : token,
40
+ );
41
+ }