@artooi/ag-ui-web-component 0.8.1 → 0.10.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 +82 -4
- package/README.md +45 -7
- package/dist/ag-ui-web-component.bundle.js +163 -57
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/core/ag_ui_chat.d.ts +27 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +6 -0
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/attachment.d.ts +5 -0
- package/dist/core/attachment.d.ts.map +1 -1
- package/dist/core/conversation_store.d.ts +8 -0
- package/dist/core/conversation_store.d.ts.map +1 -1
- package/dist/core/remote_conversation_store.d.ts.map +1 -1
- package/dist/core/transcribe_audio.d.ts +25 -0
- package/dist/core/transcribe_audio.d.ts.map +1 -0
- package/dist/core/upload_attachment.d.ts +8 -2
- package/dist/core/upload_attachment.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +735 -55
- package/dist/index.js.map +4 -4
- package/dist/ui/attachment_tray.d.ts +7 -1
- package/dist/ui/attachment_tray.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/thoughts_block.d.ts +30 -0
- package/dist/ui/thoughts_block.d.ts.map +1 -0
- package/dist/ui/thread_drawer.d.ts.map +1 -1
- package/dist/ui/tool_call_card.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +14 -1
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/dist/ui/voice_input.d.ts +41 -0
- package/dist/ui/voice_input.d.ts.map +1 -0
- package/dist/version.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/core/ag_ui_chat.ts +217 -7
- package/src/core/agui_client.ts +31 -2
- package/src/core/attachment.ts +21 -1
- package/src/core/conversation_store.ts +84 -18
- package/src/core/remote_conversation_store.ts +24 -3
- package/src/core/transcribe_audio.ts +62 -0
- package/src/core/upload_attachment.ts +8 -1
- package/src/index.ts +5 -0
- package/src/ui/attachment_tray.ts +42 -5
- package/src/ui/relative_time.ts +8 -3
- package/src/ui/styles.ts +113 -7
- package/src/ui/thoughts_block.ts +83 -0
- package/src/ui/thread_drawer.ts +83 -9
- package/src/ui/tool_call_card.ts +6 -0
- package/src/ui/ui_strings.ts +20 -1
- package/src/ui/voice_input.ts +169 -0
- package/src/version.ts +1 -1
|
@@ -96,7 +96,13 @@ export class RemoteConversationStore implements ClientConversationStore {
|
|
|
96
96
|
if (response === null || !response.ok) {
|
|
97
97
|
return this.#local.loadMessages(threadId);
|
|
98
98
|
}
|
|
99
|
-
|
|
99
|
+
// A 200 whose body isn't the expected JSON (a proxy's HTML error page, a
|
|
100
|
+
// truncated stream) must not throw an unhandled rejection that the caller's
|
|
101
|
+
// `void #rehydrate()` swallows — fall back to the local cache instead.
|
|
102
|
+
const body = await this.#readJson<{ messages?: readonly Message[] }>(response);
|
|
103
|
+
if (body === null) {
|
|
104
|
+
return this.#local.loadMessages(threadId);
|
|
105
|
+
}
|
|
100
106
|
return body.messages ?? null;
|
|
101
107
|
}
|
|
102
108
|
|
|
@@ -105,15 +111,30 @@ export class RemoteConversationStore implements ClientConversationStore {
|
|
|
105
111
|
if (response === null || !response.ok) {
|
|
106
112
|
return null;
|
|
107
113
|
}
|
|
108
|
-
const body =
|
|
114
|
+
const body = await this.#readJson<{ threads?: readonly ServerThreadRow[] }>(response);
|
|
115
|
+
if (body === null) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
109
118
|
return body.threads ?? [];
|
|
110
119
|
}
|
|
111
120
|
|
|
121
|
+
/** Parse a `Response` body as JSON, or `null` when it isn't valid JSON. */
|
|
122
|
+
async #readJson<T>(response: Response): Promise<T | null> {
|
|
123
|
+
try {
|
|
124
|
+
return (await response.json()) as T;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
112
130
|
#toMeta(row: ServerThreadRow): ThreadMeta {
|
|
113
131
|
return {
|
|
114
132
|
threadId: row.thread_id,
|
|
115
133
|
title: this.#renamed.get(row.thread_id) ?? row.title,
|
|
116
|
-
|
|
134
|
+
// `null` or an unparseable date both become `NaN` (Date.parse's own
|
|
135
|
+
// signal), which `relativeTime` renders as a neutral label rather than
|
|
136
|
+
// "~2950w ago" (epoch 0) or "NaNw ago".
|
|
137
|
+
updatedAt: row.updated_at === null ? Number.NaN : Date.parse(row.updated_at),
|
|
117
138
|
preview: row.preview,
|
|
118
139
|
};
|
|
119
140
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer's voice-transcription contract: take a recorded audio `Blob` and
|
|
3
|
+
* resolve to the transcript text. The built-in handler is {@link transcribeAudio}
|
|
4
|
+
* (multipart POST to `data-transcribe-url`); a host swaps in its own — e.g. a
|
|
5
|
+
* browser Web Speech adapter or a direct-to-provider call — via
|
|
6
|
+
* `AgUiChat.transcribeHandler`, without touching the mic button.
|
|
7
|
+
*/
|
|
8
|
+
export type TranscribeHandler = (audio: Blob) => Promise<string>;
|
|
9
|
+
|
|
10
|
+
/** Options for {@link transcribeAudio}. */
|
|
11
|
+
export interface TranscribeOptions {
|
|
12
|
+
/** The transcription endpoint (`data-transcribe-url`). */
|
|
13
|
+
readonly url: string;
|
|
14
|
+
/** Extra HTTP headers (CSRF / auth), read fresh per request. */
|
|
15
|
+
readonly headers?: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* POST a recorded clip to the transcription endpoint and resolve to its text.
|
|
20
|
+
*
|
|
21
|
+
* The clip is sent as multipart under the `audio` field with the element's
|
|
22
|
+
* `headers`, mirroring {@link uploadAttachment}; the server replies
|
|
23
|
+
* `{ "text": "<transcript>" }`. A non-2xx response or a network error rejects so
|
|
24
|
+
* the mic button can surface the failure.
|
|
25
|
+
*/
|
|
26
|
+
export async function transcribeAudio(audio: Blob, options: TranscribeOptions): Promise<string> {
|
|
27
|
+
const form = new FormData();
|
|
28
|
+
// A filename hints the server/codec; the extension is cosmetic (the server
|
|
29
|
+
// reads the blob's content type).
|
|
30
|
+
form.append("audio", audio, "recording.webm");
|
|
31
|
+
|
|
32
|
+
const response = await fetch(options.url, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { ...(options.headers ?? {}) },
|
|
35
|
+
body: form,
|
|
36
|
+
});
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
throw new Error(await errorMessage(response));
|
|
39
|
+
}
|
|
40
|
+
const body: unknown = await response.json();
|
|
41
|
+
if (
|
|
42
|
+
typeof body === "object" &&
|
|
43
|
+
body !== null &&
|
|
44
|
+
typeof (body as { text?: unknown }).text === "string"
|
|
45
|
+
) {
|
|
46
|
+
return (body as { text: string }).text;
|
|
47
|
+
}
|
|
48
|
+
throw new Error("transcription returned an unreadable response");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A human-readable message from a non-2xx transcription response. */
|
|
52
|
+
async function errorMessage(response: Response): Promise<string> {
|
|
53
|
+
try {
|
|
54
|
+
const body = (await response.json()) as { error?: unknown };
|
|
55
|
+
if (typeof body.error === "string") {
|
|
56
|
+
return body.error;
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// Non-JSON error body — fall through to the status code.
|
|
60
|
+
}
|
|
61
|
+
return `transcription failed (${response.status})`;
|
|
62
|
+
}
|
|
@@ -7,10 +7,17 @@ import type { AttachmentRef } from "./attachment.js";
|
|
|
7
7
|
* `tus-js-client` or direct-to-S3 adapter — via `AgUiChat.uploadHandler`,
|
|
8
8
|
* **without** touching the tray, the chips, or the AG-UI wire (refs are
|
|
9
9
|
* transport-agnostic).
|
|
10
|
+
*
|
|
11
|
+
* The optional third `signal` argument lets the tray abort an in-flight upload
|
|
12
|
+
* when its chip is removed (or the element is torn down), so a cancelled upload
|
|
13
|
+
* doesn't orphan a server-side file. It is non-breaking: existing two-argument
|
|
14
|
+
* handlers keep working (the extra argument is simply ignored), and a custom
|
|
15
|
+
* handler that honours it should abort its own transport when the signal fires.
|
|
10
16
|
*/
|
|
11
17
|
export type UploadHandler = (
|
|
12
18
|
file: File,
|
|
13
19
|
onProgress: (fraction: number) => void,
|
|
20
|
+
signal?: AbortSignal,
|
|
14
21
|
) => Promise<AttachmentRef>;
|
|
15
22
|
|
|
16
23
|
/** Options for {@link uploadAttachment}. */
|
|
@@ -22,7 +29,7 @@ export interface UploadOptions {
|
|
|
22
29
|
/** Progress callback, `0..1`, fired as the body uploads. */
|
|
23
30
|
readonly onProgress?: (fraction: number) => void;
|
|
24
31
|
/** Abort signal to cancel the in-flight upload. */
|
|
25
|
-
readonly signal?: AbortSignal;
|
|
32
|
+
readonly signal?: AbortSignal | undefined;
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
/**
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,11 @@ export {
|
|
|
43
43
|
} from "./core/create_http_agent.js";
|
|
44
44
|
export { defineAgUiChat } from "./core/define_ag_ui_chat.js";
|
|
45
45
|
export { RemoteConversationStore } from "./core/remote_conversation_store.js";
|
|
46
|
+
export {
|
|
47
|
+
type TranscribeHandler,
|
|
48
|
+
type TranscribeOptions,
|
|
49
|
+
transcribeAudio,
|
|
50
|
+
} from "./core/transcribe_audio.js";
|
|
46
51
|
export {
|
|
47
52
|
type UploadHandler,
|
|
48
53
|
type UploadOptions,
|
|
@@ -30,6 +30,8 @@ interface TrayItem {
|
|
|
30
30
|
progress: number;
|
|
31
31
|
ref: AttachmentRef | null;
|
|
32
32
|
error: string;
|
|
33
|
+
/** Aborts the in-flight upload when the chip is removed / the tray cleared. */
|
|
34
|
+
controller: AbortController | null;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/**
|
|
@@ -68,6 +70,7 @@ export class AttachmentTray {
|
|
|
68
70
|
progress: 0,
|
|
69
71
|
ref: null,
|
|
70
72
|
error: "",
|
|
73
|
+
controller: null,
|
|
71
74
|
};
|
|
72
75
|
this.#items.push(item);
|
|
73
76
|
const rejection = this.#reject(file);
|
|
@@ -110,12 +113,26 @@ export class AttachmentTray {
|
|
|
110
113
|
this.#render();
|
|
111
114
|
}
|
|
112
115
|
|
|
113
|
-
/** Drop every chip (a reset / new-chat). */
|
|
116
|
+
/** Drop every chip (a reset / new-chat), aborting any in-flight upload. */
|
|
114
117
|
clear(): void {
|
|
118
|
+
for (const item of this.#items) {
|
|
119
|
+
item.controller?.abort();
|
|
120
|
+
}
|
|
115
121
|
this.#items = [];
|
|
116
122
|
this.#render();
|
|
117
123
|
}
|
|
118
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Abort every in-flight upload without touching the rendered chips — the
|
|
127
|
+
* teardown path when the host element is removed mid-upload, so a cancelled
|
|
128
|
+
* transfer doesn't orphan a server-side file.
|
|
129
|
+
*/
|
|
130
|
+
dispose(): void {
|
|
131
|
+
for (const item of this.#items) {
|
|
132
|
+
item.controller?.abort();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
119
136
|
/** The size/type rejection reason for a file, or `null` when accepted. */
|
|
120
137
|
#reject(file: File): string | null {
|
|
121
138
|
if (this.#config.maxBytes > 0 && file.size > this.#config.maxBytes) {
|
|
@@ -128,15 +145,32 @@ export class AttachmentTray {
|
|
|
128
145
|
}
|
|
129
146
|
|
|
130
147
|
#upload(item: TrayItem): void {
|
|
148
|
+
// Re-apply the client guard on every attempt, not just the first `add` — an
|
|
149
|
+
// ERROR chip always renders retry, so without this a size/type-rejected file
|
|
150
|
+
// would upload in full on ↻.
|
|
151
|
+
const rejection = this.#reject(item.file);
|
|
152
|
+
if (rejection !== null) {
|
|
153
|
+
item.status = ATTACHMENT_STATUS.ERROR;
|
|
154
|
+
item.error = rejection;
|
|
155
|
+
this.#render();
|
|
156
|
+
this.#config.onChange?.();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
131
159
|
item.status = ATTACHMENT_STATUS.UPLOADING;
|
|
132
160
|
item.progress = 0;
|
|
133
161
|
item.error = "";
|
|
162
|
+
const controller = new AbortController();
|
|
163
|
+
item.controller = controller;
|
|
134
164
|
this.#render();
|
|
135
165
|
this.#config
|
|
136
|
-
.upload(
|
|
137
|
-
item.
|
|
138
|
-
|
|
139
|
-
|
|
166
|
+
.upload(
|
|
167
|
+
item.file,
|
|
168
|
+
(fraction) => {
|
|
169
|
+
item.progress = fraction;
|
|
170
|
+
this.#render();
|
|
171
|
+
},
|
|
172
|
+
controller.signal,
|
|
173
|
+
)
|
|
140
174
|
.then((ref) => {
|
|
141
175
|
item.status = ATTACHMENT_STATUS.READY;
|
|
142
176
|
item.ref = ref;
|
|
@@ -146,12 +180,15 @@ export class AttachmentTray {
|
|
|
146
180
|
item.error = error instanceof Error ? error.message : this.#strings.uploadFailed;
|
|
147
181
|
})
|
|
148
182
|
.finally(() => {
|
|
183
|
+
item.controller = null;
|
|
149
184
|
this.#render();
|
|
150
185
|
this.#config.onChange?.();
|
|
151
186
|
});
|
|
152
187
|
}
|
|
153
188
|
|
|
154
189
|
#remove(item: TrayItem): void {
|
|
190
|
+
// Abort an in-flight upload so removing its chip doesn't orphan the file.
|
|
191
|
+
item.controller?.abort();
|
|
155
192
|
this.#items = this.#items.filter((other) => other !== item);
|
|
156
193
|
this.#render();
|
|
157
194
|
this.#config.onChange?.();
|
package/src/ui/relative_time.ts
CHANGED
|
@@ -6,15 +6,20 @@ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
|
|
|
6
6
|
*
|
|
7
7
|
* `now` is injectable so callers (and tests) can pin the reference point; it
|
|
8
8
|
* defaults to the current time. A timestamp in the future (clock skew) reads as
|
|
9
|
-
* `"just now"`.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* `"just now"`. A non-finite timestamp — an unparseable or missing `updated_at`
|
|
10
|
+
* that arrived as `NaN` — has no meaningful age, so it falls back to `justNow`
|
|
11
|
+
* rather than rendering `"NaNw ago"` or `"~2950w ago"`. The unit words come from
|
|
12
|
+
* {@link UiStrings} (the `{n}` token is filled in here) so a localized host
|
|
13
|
+
* translates them; the bucketing stays integer-rounded and locale-neutral.
|
|
12
14
|
*/
|
|
13
15
|
export function relativeTime(
|
|
14
16
|
timestamp: number,
|
|
15
17
|
now: number = Date.now(),
|
|
16
18
|
strings: UiStrings = DEFAULT_UI_STRINGS,
|
|
17
19
|
): string {
|
|
20
|
+
if (!Number.isFinite(timestamp)) {
|
|
21
|
+
return strings.justNow;
|
|
22
|
+
}
|
|
18
23
|
const seconds = Math.round((now - timestamp) / 1000);
|
|
19
24
|
if (seconds < 60) {
|
|
20
25
|
return strings.justNow;
|
package/src/ui/styles.ts
CHANGED
|
@@ -146,7 +146,7 @@ export const STYLES = `
|
|
|
146
146
|
--ag-ui-radius: 0;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
/* Page
|
|
149
|
+
/* Page: full-bleed background with a centred reading column. Unlike
|
|
150
150
|
"full" (edge-to-edge, left-aligned messages) the content sits in a column
|
|
151
151
|
capped at --ag-ui-content-max-width. The column is produced by symmetric auto
|
|
152
152
|
padding on the scroll area + composer (no per-row wrapper), so user pills
|
|
@@ -188,7 +188,7 @@ export const STYLES = `
|
|
|
188
188
|
max-width: 100%;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
/* Sidebar
|
|
191
|
+
/* Sidebar: a full-height docked panel that slides open/closed and
|
|
192
192
|
collapses to a slim icon rail (not the floating launcher). Docked right by
|
|
193
193
|
default; data-side="left" docks it left. Overlay by default — set
|
|
194
194
|
--ag-ui-position: static (and place this element in your own layout) for a
|
|
@@ -298,7 +298,7 @@ export const STYLES = `
|
|
|
298
298
|
white-space: nowrap;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
/* Header / launcher icon holder
|
|
301
|
+
/* Header / launcher icon holder: a slot, with a data-icon-url <img>
|
|
302
302
|
fallback, sized via --ag-ui-icon-size. */
|
|
303
303
|
.icon-holder {
|
|
304
304
|
display: inline-flex;
|
|
@@ -372,7 +372,7 @@ export const STYLES = `
|
|
|
372
372
|
gap: var(--ag-ui-space);
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
-
/* Empty-state region (
|
|
375
|
+
/* Empty-state region (slot): centred while it's the only thing in the
|
|
376
376
|
list, hidden as soon as a message, card, or pending indicator renders. */
|
|
377
377
|
.empty {
|
|
378
378
|
margin: auto;
|
|
@@ -384,7 +384,7 @@ export const STYLES = `
|
|
|
384
384
|
display: none;
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
-
/* ── Answer group / well
|
|
387
|
+
/* ── Answer group / well ─────────────────────────────────────────
|
|
388
388
|
One .answer per assistant turn wraps the streamed text, its tool cards,
|
|
389
389
|
and the pending indicator so a whole answer reads (and can be boxed) as one
|
|
390
390
|
unit. A flex column on the message-list gap, stretched to the list width so
|
|
@@ -553,6 +553,69 @@ export const STYLES = `
|
|
|
553
553
|
}
|
|
554
554
|
}
|
|
555
555
|
|
|
556
|
+
/* ── Thoughts region ────────────────────────────────────────────
|
|
557
|
+
A muted, collapsible chain-of-thought at the top of the answer group: open
|
|
558
|
+
while the model reasons, folded once the answer text starts. */
|
|
559
|
+
.thoughts {
|
|
560
|
+
align-self: stretch;
|
|
561
|
+
display: flex;
|
|
562
|
+
flex-direction: column;
|
|
563
|
+
gap: 4px;
|
|
564
|
+
font-size: 12px;
|
|
565
|
+
color: var(--ag-ui-muted);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.thoughts-toggle {
|
|
569
|
+
align-self: flex-start;
|
|
570
|
+
border: none;
|
|
571
|
+
padding: 0;
|
|
572
|
+
background: none;
|
|
573
|
+
font: inherit;
|
|
574
|
+
font-size: 12px;
|
|
575
|
+
font-weight: 600;
|
|
576
|
+
color: var(--ag-ui-muted);
|
|
577
|
+
cursor: pointer;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
.thoughts-toggle::before {
|
|
581
|
+
content: "▾ ";
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
.thoughts-toggle[aria-expanded="false"]::before {
|
|
585
|
+
content: "▸ ";
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/* A gentle pulse on the label while reasoning is still streaming. */
|
|
589
|
+
.thoughts[data-streaming] .thoughts-label {
|
|
590
|
+
animation: ag-ui-thoughts-pulse 1.4s ease-in-out infinite;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
@keyframes ag-ui-thoughts-pulse {
|
|
594
|
+
0%, 100% { opacity: 0.55; }
|
|
595
|
+
50% { opacity: 1; }
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
.thoughts-body {
|
|
599
|
+
margin: 0;
|
|
600
|
+
padding: 4px 0 4px 10px;
|
|
601
|
+
border-left: 2px solid var(--ag-ui-border);
|
|
602
|
+
max-height: 220px;
|
|
603
|
+
overflow: auto;
|
|
604
|
+
white-space: pre-wrap;
|
|
605
|
+
word-break: break-word;
|
|
606
|
+
font-family: inherit;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
.thoughts-body[hidden] {
|
|
610
|
+
display: none;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
@media (prefers-reduced-motion: reduce) {
|
|
614
|
+
.thoughts[data-streaming] .thoughts-label {
|
|
615
|
+
animation: none;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
556
619
|
.tool-call {
|
|
557
620
|
align-self: flex-start;
|
|
558
621
|
max-width: 80%;
|
|
@@ -583,7 +646,7 @@ export const STYLES = `
|
|
|
583
646
|
word-break: break-word;
|
|
584
647
|
}
|
|
585
648
|
|
|
586
|
-
/* Leading status icon
|
|
649
|
+
/* Leading status icon. Empty in the DOM — the glyph/spinner is drawn
|
|
587
650
|
here from the card's data-status, so it stays themeable. */
|
|
588
651
|
.tool-call-icon {
|
|
589
652
|
flex: none;
|
|
@@ -631,7 +694,7 @@ export const STYLES = `
|
|
|
631
694
|
}
|
|
632
695
|
}
|
|
633
696
|
|
|
634
|
-
/* Inline display mode
|
|
697
|
+
/* Inline display mode: the lightest card — drop the box chrome so the
|
|
635
698
|
status row reads as one line of the answer; the result toggle still expands
|
|
636
699
|
below it. */
|
|
637
700
|
.tool-call[data-display="inline"] {
|
|
@@ -761,6 +824,49 @@ export const STYLES = `
|
|
|
761
824
|
display: none;
|
|
762
825
|
}
|
|
763
826
|
|
|
827
|
+
/* The 🎤 mic button; shown only once #wireVoice mounts it. */
|
|
828
|
+
.voice-slot {
|
|
829
|
+
display: contents;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
.voice-btn {
|
|
833
|
+
border: 1px solid var(--ag-ui-border);
|
|
834
|
+
border-radius: 8px;
|
|
835
|
+
padding: 0 10px;
|
|
836
|
+
background: var(--ag-ui-input-bg);
|
|
837
|
+
color: inherit;
|
|
838
|
+
font: inherit;
|
|
839
|
+
cursor: pointer;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
.voice-btn:hover {
|
|
843
|
+
border-color: var(--ag-ui-accent);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
.voice-btn:disabled {
|
|
847
|
+
cursor: default;
|
|
848
|
+
opacity: 0.6;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/* Recording: a red tint + a gentle pulse so it's clearly "live". */
|
|
852
|
+
.voice-btn[data-state="recording"] {
|
|
853
|
+
border-color: var(--ag-ui-danger);
|
|
854
|
+
background: var(--ag-ui-danger);
|
|
855
|
+
color: #ffffff;
|
|
856
|
+
animation: ag-ui-voice-pulse 1.2s ease-in-out infinite;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
@keyframes ag-ui-voice-pulse {
|
|
860
|
+
0%, 100% { opacity: 1; }
|
|
861
|
+
50% { opacity: 0.6; }
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
@media (prefers-reduced-motion: reduce) {
|
|
865
|
+
.voice-btn[data-state="recording"] {
|
|
866
|
+
animation: none;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
764
870
|
/* Pending-attachments tray, above the input row; collapses (hidden) when empty. */
|
|
765
871
|
.attachment-slot {
|
|
766
872
|
display: contents;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A muted, collapsible "thinking" region for a reasoning model's streamed
|
|
5
|
+
* chain-of-thought.
|
|
6
|
+
*
|
|
7
|
+
* Lives at the top of the current answer group (the turn container): it
|
|
8
|
+
* opens expanded while the model reasons — {@link stream} replaces its body with
|
|
9
|
+
* the running reasoning buffer — and {@link collapse} folds it away once the
|
|
10
|
+
* answer's first text token arrives, so the thoughts don't crowd the answer.
|
|
11
|
+
* The header toggle lets the reader reopen it.
|
|
12
|
+
*
|
|
13
|
+
* Pure DOM (no framework); the host inserts {@link element} and themes it via
|
|
14
|
+
* the `--ag-ui-*` custom properties or the `thoughts*` `part`s. All visible
|
|
15
|
+
* chrome text is sourced from {@link UiStrings}.
|
|
16
|
+
*/
|
|
17
|
+
export class ThoughtsBlock {
|
|
18
|
+
/** The block's root element; insert this at the top of the answer group. */
|
|
19
|
+
readonly element: HTMLDivElement;
|
|
20
|
+
|
|
21
|
+
readonly #label: HTMLSpanElement;
|
|
22
|
+
readonly #body: HTMLPreElement;
|
|
23
|
+
readonly #toggle: HTMLButtonElement;
|
|
24
|
+
readonly #strings: UiStrings;
|
|
25
|
+
#collapsed = false;
|
|
26
|
+
|
|
27
|
+
constructor(strings: UiStrings = DEFAULT_UI_STRINGS) {
|
|
28
|
+
this.#strings = strings;
|
|
29
|
+
|
|
30
|
+
this.element = document.createElement("div");
|
|
31
|
+
this.element.className = "thoughts";
|
|
32
|
+
this.element.setAttribute("part", "thoughts");
|
|
33
|
+
// `data-streaming` lets CSS animate the header (e.g. a pulse) while the
|
|
34
|
+
// model is still reasoning; dropped on collapse.
|
|
35
|
+
this.element.setAttribute("data-streaming", "");
|
|
36
|
+
|
|
37
|
+
this.#toggle = document.createElement("button");
|
|
38
|
+
this.#toggle.type = "button";
|
|
39
|
+
this.#toggle.className = "thoughts-toggle";
|
|
40
|
+
this.#toggle.setAttribute("part", "thoughts-toggle");
|
|
41
|
+
this.#toggle.setAttribute("aria-expanded", "true");
|
|
42
|
+
|
|
43
|
+
this.#label = document.createElement("span");
|
|
44
|
+
this.#label.className = "thoughts-label";
|
|
45
|
+
this.#label.textContent = strings.thinking;
|
|
46
|
+
this.#toggle.append(this.#label);
|
|
47
|
+
|
|
48
|
+
this.#body = document.createElement("pre");
|
|
49
|
+
this.#body.className = "thoughts-body";
|
|
50
|
+
this.#body.setAttribute("part", "thoughts-body");
|
|
51
|
+
|
|
52
|
+
this.#toggle.addEventListener("click", () => {
|
|
53
|
+
this.#setCollapsed(!this.#collapsed);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
this.element.append(this.#toggle, this.#body);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Replace the reasoning body with the running buffer (the full text so far). */
|
|
60
|
+
stream(buffer: string): void {
|
|
61
|
+
this.#body.textContent = buffer;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fold the region away — called when the answer's first text token arrives.
|
|
66
|
+
* Idempotent (the per-token text handler calls it repeatedly) and flips the
|
|
67
|
+
* header label from "thinking…" to the settled "Thoughts" affordance.
|
|
68
|
+
*/
|
|
69
|
+
collapse(): void {
|
|
70
|
+
if (this.#collapsed) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.element.removeAttribute("data-streaming");
|
|
74
|
+
this.#label.textContent = this.#strings.thoughts;
|
|
75
|
+
this.#setCollapsed(true);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#setCollapsed(collapsed: boolean): void {
|
|
79
|
+
this.#collapsed = collapsed;
|
|
80
|
+
this.#body.hidden = collapsed;
|
|
81
|
+
this.#toggle.setAttribute("aria-expanded", String(!collapsed));
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/ui/thread_drawer.ts
CHANGED
|
@@ -37,6 +37,8 @@ export class ThreadDrawer {
|
|
|
37
37
|
#strings: UiStrings;
|
|
38
38
|
#threads: readonly ThreadMeta[] = [];
|
|
39
39
|
#activeId = "";
|
|
40
|
+
/** The element focused before the drawer opened, restored on close. */
|
|
41
|
+
#lastFocused: HTMLElement | null = null;
|
|
40
42
|
|
|
41
43
|
constructor(callbacks: ThreadDrawerCallbacks, strings: UiStrings = DEFAULT_UI_STRINGS) {
|
|
42
44
|
this.#callbacks = callbacks;
|
|
@@ -56,7 +58,10 @@ export class ThreadDrawer {
|
|
|
56
58
|
this.#panel.className = "drawer-panel";
|
|
57
59
|
this.#panel.setAttribute("part", "drawer-panel");
|
|
58
60
|
this.#panel.setAttribute("role", "dialog");
|
|
61
|
+
this.#panel.setAttribute("aria-modal", "true");
|
|
59
62
|
this.#panel.setAttribute("aria-label", strings.chatHistory);
|
|
63
|
+
// Escape closes the drawer; Tab is trapped within the panel while it's open.
|
|
64
|
+
this.#panel.addEventListener("keydown", (event) => this.#onPanelKeydown(event));
|
|
60
65
|
|
|
61
66
|
const header = document.createElement("div");
|
|
62
67
|
header.className = "drawer-header";
|
|
@@ -98,15 +103,61 @@ export class ThreadDrawer {
|
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
open(): void {
|
|
106
|
+
if (this.isOpen()) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// Remember what had focus so it's restored on close, then move focus into
|
|
110
|
+
// the panel (its first control) so keyboard users land inside the dialog.
|
|
111
|
+
this.#lastFocused = this.#activeElement() as HTMLElement | null;
|
|
101
112
|
this.element.hidden = false;
|
|
113
|
+
this.#newButton.focus();
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
close(): void {
|
|
117
|
+
if (!this.isOpen()) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
105
120
|
this.element.hidden = true;
|
|
121
|
+
this.#lastFocused?.focus();
|
|
122
|
+
this.#lastFocused = null;
|
|
106
123
|
}
|
|
107
124
|
|
|
108
125
|
toggle(): void {
|
|
109
|
-
this.
|
|
126
|
+
if (this.isOpen()) {
|
|
127
|
+
this.close();
|
|
128
|
+
} else {
|
|
129
|
+
this.open();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The currently-focused element within the drawer's root (shadow-aware). */
|
|
134
|
+
#activeElement(): Element | null {
|
|
135
|
+
return (this.element.getRootNode() as Document | ShadowRoot).activeElement;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Escape-to-close and a Tab focus trap while the dialog is open. */
|
|
139
|
+
#onPanelKeydown(event: KeyboardEvent): void {
|
|
140
|
+
if (event.key === "Escape") {
|
|
141
|
+
event.preventDefault();
|
|
142
|
+
this.close();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (event.key !== "Tab") {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const focusables = Array.from(
|
|
149
|
+
this.#panel.querySelectorAll<HTMLElement>("button, input, [tabindex]"),
|
|
150
|
+
).filter((el) => !el.hidden);
|
|
151
|
+
const first = focusables[0];
|
|
152
|
+
const last = focusables[focusables.length - 1];
|
|
153
|
+
const active = this.#activeElement();
|
|
154
|
+
if (event.shiftKey && active === first) {
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
last?.focus();
|
|
157
|
+
} else if (!event.shiftKey && active === last) {
|
|
158
|
+
event.preventDefault();
|
|
159
|
+
first?.focus();
|
|
160
|
+
}
|
|
110
161
|
}
|
|
111
162
|
|
|
112
163
|
/** Render the rows (or the empty state), highlighting the active thread. */
|
|
@@ -182,24 +233,47 @@ export class ThreadDrawer {
|
|
|
182
233
|
return row;
|
|
183
234
|
}
|
|
184
235
|
|
|
185
|
-
/** Swap a row for an inline rename input; Enter commits, Escape cancels. */
|
|
236
|
+
/** Swap a row for an inline rename input; Enter/blur commits, Escape cancels. */
|
|
186
237
|
#startRename(row: HTMLDivElement, meta: ThreadMeta): void {
|
|
187
238
|
const input = document.createElement("input");
|
|
188
239
|
input.type = "text";
|
|
189
240
|
input.className = "drawer-rename-input";
|
|
190
241
|
input.value = meta.title;
|
|
242
|
+
// One-shot: Enter, Escape, and blur can all fire for a single edit (Enter
|
|
243
|
+
// commits and re-renders, which blurs the detached input); the flag makes
|
|
244
|
+
// the later events no-ops so a rename isn't submitted twice.
|
|
245
|
+
let done = false;
|
|
246
|
+
const commit = (): void => {
|
|
247
|
+
if (done) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
done = true;
|
|
251
|
+
const value = input.value.trim();
|
|
252
|
+
if (value === "" || value === meta.title) {
|
|
253
|
+
this.#renderList();
|
|
254
|
+
} else {
|
|
255
|
+
this.#callbacks.onRename(meta.threadId, value);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
const cancel = (): void => {
|
|
259
|
+
if (done) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
done = true;
|
|
263
|
+
this.#renderList();
|
|
264
|
+
};
|
|
191
265
|
input.addEventListener("keydown", (event) => {
|
|
192
266
|
if (event.key === "Enter") {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
this.#renderList();
|
|
196
|
-
} else {
|
|
197
|
-
this.#callbacks.onRename(meta.threadId, value);
|
|
198
|
-
}
|
|
267
|
+
event.preventDefault();
|
|
268
|
+
commit();
|
|
199
269
|
} else if (event.key === "Escape") {
|
|
200
|
-
|
|
270
|
+
// Stop the panel's Escape handler from also closing the drawer.
|
|
271
|
+
event.preventDefault();
|
|
272
|
+
event.stopPropagation();
|
|
273
|
+
cancel();
|
|
201
274
|
}
|
|
202
275
|
});
|
|
276
|
+
input.addEventListener("blur", () => commit());
|
|
203
277
|
row.replaceChildren(input);
|
|
204
278
|
input.focus();
|
|
205
279
|
input.select();
|
package/src/ui/tool_call_card.ts
CHANGED
|
@@ -126,6 +126,12 @@ export class ToolCallCard {
|
|
|
126
126
|
* `inline`), or the args + result together (`compact`).
|
|
127
127
|
*/
|
|
128
128
|
settle(status: SettledStatus, text: string): void {
|
|
129
|
+
// Idempotent: a duplicate `TOOL_CALL_RESULT`, or a replayed tool message
|
|
130
|
+
// for an already-settled card, must not append a second toggle+body. The
|
|
131
|
+
// first settle wins; later calls are ignored.
|
|
132
|
+
if (this.#settled) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
129
135
|
this.#settled = true;
|
|
130
136
|
this.element.setAttribute("data-status", status);
|
|
131
137
|
this.#status.textContent = statusLabels(this.#strings)[status];
|