@artooi/ag-ui-web-component 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
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,
package/src/ui/styles.ts CHANGED
@@ -168,6 +168,20 @@ export const STYLES = `
168
168
  padding-inline: max(12px, calc((100% - var(--ag-ui-content-max-width)) / 2));
169
169
  }
170
170
 
171
+ /* The rows between the message list and the composer (skill chips, the
172
+ /-command palette, the missing-placeholder hint, the upload tray) line up
173
+ with the column too — chips are padding-based, the palette/hint/tray are
174
+ margin-based, so each gets its own inline axis nudged by the same gutter. */
175
+ :host([placement="page"]) .skill-chips,
176
+ :host([placement="page"]) .attachment-tray {
177
+ padding-inline: max(12px, calc((100% - var(--ag-ui-content-max-width)) / 2));
178
+ }
179
+
180
+ :host([placement="page"]) .skill-palette,
181
+ :host([placement="page"]) .skill-hint {
182
+ margin-inline: max(12px, calc((100% - var(--ag-ui-content-max-width)) / 2));
183
+ }
184
+
171
185
  /* In the reading column the assistant well uses the full width; the user
172
186
  message stays a right-aligned pill (its default align-self + max-width). */
173
187
  :host([placement="page"]) .message--assistant {
@@ -539,6 +553,69 @@ export const STYLES = `
539
553
  }
540
554
  }
541
555
 
556
+ /* ── Thoughts region (THINK-1) ────────────────────────────────────────────
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
+
542
619
  .tool-call {
543
620
  align-self: flex-start;
544
621
  max-width: 80%;
@@ -747,6 +824,49 @@ export const STYLES = `
747
824
  display: none;
748
825
  }
749
826
 
827
+ /* The 🎤 mic button (VOICE-1); 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
+
750
870
  /* Pending-attachments tray, above the input row; collapses (hidden) when empty. */
751
871
  .attachment-slot {
752
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 (THINK-1).
6
+ *
7
+ * Lives at the top of the current answer group (the WELL-1 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
+ }
@@ -26,12 +26,17 @@ export interface UiStrings {
26
26
  collapse: string;
27
27
  /** Expand affordance (the sidebar rail toggle). */
28
28
  expand: string;
29
+ /** Built-in header theme toggle (light ⇄ dark). */
30
+ toggleTheme: string;
29
31
 
30
32
  // ── Messages region ─────────────────────────────────────────────────────────
31
33
  /** `aria-label` of the scrolling message log. */
32
34
  conversation: string;
33
- /** `aria-label` of the "thinking" pending indicator. */
35
+ /** `aria-label` of the "thinking" pending indicator, and the thoughts region's
36
+ * header while the model is still reasoning. */
34
37
  thinking: string;
38
+ /** The thoughts region's header once reasoning has streamed (collapsed label). */
39
+ thoughts: string;
35
40
  /** The muted note after a cancelled run. */
36
41
  stopped: string;
37
42
  /** Error shown when the stream drops without a terminal AG-UI event. */
@@ -56,6 +61,14 @@ export interface UiStrings {
56
61
  stop: string;
57
62
  /** Attach-files button. */
58
63
  attachFiles: string;
64
+ /** Mic button while idle (start recording). */
65
+ recordVoice: string;
66
+ /** Mic button while recording (stop + transcribe). */
67
+ stopRecording: string;
68
+ /** Mic button while the clip is being transcribed. */
69
+ transcribing: string;
70
+ /** Mic button fallback message when transcription fails. */
71
+ transcriptionFailed: string;
59
72
 
60
73
  // ── Tool-call card ──────────────────────────────────────────────────────────
61
74
  /** Status pill while the call runs. */
@@ -137,9 +150,11 @@ export const DEFAULT_UI_STRINGS: UiStrings = {
137
150
  newChat: "New chat",
138
151
  collapse: "Collapse",
139
152
  expand: "Expand",
153
+ toggleTheme: "Toggle theme",
140
154
 
141
155
  conversation: "Conversation",
142
156
  thinking: "Assistant is thinking…",
157
+ thoughts: "Thoughts",
143
158
  stopped: "⏹ Stopped",
144
159
  connectionLost: "Connection lost",
145
160
  noResult: "No result returned.",
@@ -152,6 +167,10 @@ export const DEFAULT_UI_STRINGS: UiStrings = {
152
167
  send: "Send",
153
168
  stop: "Stop",
154
169
  attachFiles: "Attach files",
170
+ recordVoice: "Record voice",
171
+ stopRecording: "Stop recording",
172
+ transcribing: "Transcribing…",
173
+ transcriptionFailed: "Transcription failed",
155
174
 
156
175
  toolRunning: "running…",
157
176
  toolDone: "✓ done",
@@ -0,0 +1,149 @@
1
+ import type { TranscribeHandler } from "../core/transcribe_audio.js";
2
+ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
3
+
4
+ /** Lifecycle of the mic button, reflected on its `data-state` for CSS. */
5
+ type VoiceState = "idle" | "recording" | "transcribing";
6
+
7
+ /** Construction options for {@link VoiceInput}. */
8
+ export interface VoiceInputOptions {
9
+ /** Turns a recorded clip into text (the built-in or a custom transport). */
10
+ readonly transcribe: TranscribeHandler;
11
+ /** Called with the transcript when a recording transcribes successfully. */
12
+ readonly onText: (text: string) => void;
13
+ /** UI string table (labels/tooltips). */
14
+ readonly strings?: UiStrings;
15
+ }
16
+
17
+ /**
18
+ * The composer's voice-input control (VOICE-1): a mic button that records via
19
+ * `MediaRecorder`, then POSTs the clip through a {@link TranscribeHandler} and
20
+ * hands the transcript back via `onText`.
21
+ *
22
+ * Click to start recording (browser mic permission prompt), click again to stop
23
+ * — the clip is transcribed and dropped into the composer. The button reflects
24
+ * its `idle` / `recording` / `transcribing` state on `data-state` for theming
25
+ * and is exposed as `part="voice-button"`. A capture or transcription failure
26
+ * returns the button to idle and surfaces the message on its tooltip.
27
+ *
28
+ * Pure DOM (no framework); the host mounts {@link element} in the input row.
29
+ */
30
+ export class VoiceInput {
31
+ /** The mic button; mount this in the composer. */
32
+ readonly element: HTMLButtonElement;
33
+
34
+ readonly #transcribe: TranscribeHandler;
35
+ readonly #onText: (text: string) => void;
36
+ readonly #strings: UiStrings;
37
+ #state: VoiceState = "idle";
38
+ #recorder: MediaRecorder | null = null;
39
+ #stream: MediaStream | null = null;
40
+ #chunks: Blob[] = [];
41
+
42
+ constructor(options: VoiceInputOptions) {
43
+ this.#transcribe = options.transcribe;
44
+ this.#onText = options.onText;
45
+ this.#strings = options.strings ?? DEFAULT_UI_STRINGS;
46
+
47
+ this.element = document.createElement("button");
48
+ this.element.type = "button";
49
+ this.element.className = "voice-btn";
50
+ this.element.setAttribute("part", "voice-button");
51
+ this.element.textContent = "🎤";
52
+ this.#setState("idle");
53
+ this.element.addEventListener("click", () => {
54
+ void this.toggle();
55
+ });
56
+ }
57
+
58
+ /** Start recording when idle, stop (and transcribe) when recording. */
59
+ async toggle(): Promise<void> {
60
+ if (this.#state === "recording") {
61
+ this.#stop();
62
+ return;
63
+ }
64
+ if (this.#state === "transcribing") {
65
+ return;
66
+ }
67
+ await this.#start();
68
+ }
69
+
70
+ async #start(): Promise<void> {
71
+ let stream: MediaStream;
72
+ try {
73
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
74
+ } catch {
75
+ this.#fail(this.#strings.transcriptionFailed);
76
+ return;
77
+ }
78
+ this.#stream = stream;
79
+ this.#chunks = [];
80
+ const recorder = new MediaRecorder(stream);
81
+ recorder.addEventListener("dataavailable", (event) => {
82
+ this.#chunks.push(event.data);
83
+ });
84
+ recorder.addEventListener("stop", () => {
85
+ void this.#finish(recorder.mimeType);
86
+ });
87
+ this.#recorder = recorder;
88
+ recorder.start();
89
+ this.#setState("recording");
90
+ }
91
+
92
+ #stop(): void {
93
+ // ``stop`` flushes a final ``dataavailable`` then fires ``stop`` → #finish.
94
+ this.#recorder?.stop();
95
+ }
96
+
97
+ async #finish(mimeType: string): Promise<void> {
98
+ this.#releaseStream();
99
+ this.#setState("transcribing");
100
+ const audio = new Blob(this.#chunks, { type: mimeType || "audio/webm" });
101
+ try {
102
+ const text = await this.#transcribe(audio);
103
+ this.#setState("idle");
104
+ if (text !== "") {
105
+ this.#onText(text);
106
+ }
107
+ } catch (error) {
108
+ this.#fail(error instanceof Error ? error.message : this.#strings.transcriptionFailed);
109
+ } finally {
110
+ this.#recorder = null;
111
+ }
112
+ }
113
+
114
+ /** Stop the mic tracks so the browser's recording indicator clears. */
115
+ #releaseStream(): void {
116
+ for (const track of this.#stream?.getTracks() ?? []) {
117
+ track.stop();
118
+ }
119
+ this.#stream = null;
120
+ }
121
+
122
+ #fail(message: string): void {
123
+ this.#releaseStream();
124
+ this.#recorder = null;
125
+ this.#setState("idle");
126
+ this.element.title = message;
127
+ }
128
+
129
+ #setState(state: VoiceState): void {
130
+ this.#state = state;
131
+ this.element.dataset["state"] = state;
132
+ const label = this.#labelFor(state);
133
+ this.element.title = label;
134
+ this.element.setAttribute("aria-label", label);
135
+ this.element.setAttribute("aria-pressed", String(state === "recording"));
136
+ // The control is inert while a clip transcribes (no second recording yet).
137
+ this.element.disabled = state === "transcribing";
138
+ }
139
+
140
+ #labelFor(state: VoiceState): string {
141
+ if (state === "recording") {
142
+ return this.#strings.stopRecording;
143
+ }
144
+ if (state === "transcribing") {
145
+ return this.#strings.transcribing;
146
+ }
147
+ return this.#strings.recordVoice;
148
+ }
149
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION: string = "0.8.0";
1
+ export const VERSION: string = "0.9.0";