@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.
package/dist/index.js CHANGED
@@ -506,8 +506,10 @@ var DEFAULT_UI_STRINGS = {
506
506
  newChat: "New chat",
507
507
  collapse: "Collapse",
508
508
  expand: "Expand",
509
+ toggleTheme: "Toggle theme",
509
510
  conversation: "Conversation",
510
511
  thinking: "Assistant is thinking\u2026",
512
+ thoughts: "Thoughts",
511
513
  stopped: "\u23F9 Stopped",
512
514
  connectionLost: "Connection lost",
513
515
  noResult: "No result returned.",
@@ -519,6 +521,10 @@ var DEFAULT_UI_STRINGS = {
519
521
  send: "Send",
520
522
  stop: "Stop",
521
523
  attachFiles: "Attach files",
524
+ recordVoice: "Record voice",
525
+ stopRecording: "Stop recording",
526
+ transcribing: "Transcribing\u2026",
527
+ transcriptionFailed: "Transcription failed",
522
528
  toolRunning: "running\u2026",
523
529
  toolDone: "\u2713 done",
524
530
  toolError: "\u26A0 error",
@@ -3633,6 +3639,20 @@ var STYLES = `
3633
3639
  padding-inline: max(12px, calc((100% - var(--ag-ui-content-max-width)) / 2));
3634
3640
  }
3635
3641
 
3642
+ /* The rows between the message list and the composer (skill chips, the
3643
+ /-command palette, the missing-placeholder hint, the upload tray) line up
3644
+ with the column too \u2014 chips are padding-based, the palette/hint/tray are
3645
+ margin-based, so each gets its own inline axis nudged by the same gutter. */
3646
+ :host([placement="page"]) .skill-chips,
3647
+ :host([placement="page"]) .attachment-tray {
3648
+ padding-inline: max(12px, calc((100% - var(--ag-ui-content-max-width)) / 2));
3649
+ }
3650
+
3651
+ :host([placement="page"]) .skill-palette,
3652
+ :host([placement="page"]) .skill-hint {
3653
+ margin-inline: max(12px, calc((100% - var(--ag-ui-content-max-width)) / 2));
3654
+ }
3655
+
3636
3656
  /* In the reading column the assistant well uses the full width; the user
3637
3657
  message stays a right-aligned pill (its default align-self + max-width). */
3638
3658
  :host([placement="page"]) .message--assistant {
@@ -4004,6 +4024,69 @@ var STYLES = `
4004
4024
  }
4005
4025
  }
4006
4026
 
4027
+ /* \u2500\u2500 Thoughts region (THINK-1) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4028
+ A muted, collapsible chain-of-thought at the top of the answer group: open
4029
+ while the model reasons, folded once the answer text starts. */
4030
+ .thoughts {
4031
+ align-self: stretch;
4032
+ display: flex;
4033
+ flex-direction: column;
4034
+ gap: 4px;
4035
+ font-size: 12px;
4036
+ color: var(--ag-ui-muted);
4037
+ }
4038
+
4039
+ .thoughts-toggle {
4040
+ align-self: flex-start;
4041
+ border: none;
4042
+ padding: 0;
4043
+ background: none;
4044
+ font: inherit;
4045
+ font-size: 12px;
4046
+ font-weight: 600;
4047
+ color: var(--ag-ui-muted);
4048
+ cursor: pointer;
4049
+ }
4050
+
4051
+ .thoughts-toggle::before {
4052
+ content: "\u25BE ";
4053
+ }
4054
+
4055
+ .thoughts-toggle[aria-expanded="false"]::before {
4056
+ content: "\u25B8 ";
4057
+ }
4058
+
4059
+ /* A gentle pulse on the label while reasoning is still streaming. */
4060
+ .thoughts[data-streaming] .thoughts-label {
4061
+ animation: ag-ui-thoughts-pulse 1.4s ease-in-out infinite;
4062
+ }
4063
+
4064
+ @keyframes ag-ui-thoughts-pulse {
4065
+ 0%, 100% { opacity: 0.55; }
4066
+ 50% { opacity: 1; }
4067
+ }
4068
+
4069
+ .thoughts-body {
4070
+ margin: 0;
4071
+ padding: 4px 0 4px 10px;
4072
+ border-left: 2px solid var(--ag-ui-border);
4073
+ max-height: 220px;
4074
+ overflow: auto;
4075
+ white-space: pre-wrap;
4076
+ word-break: break-word;
4077
+ font-family: inherit;
4078
+ }
4079
+
4080
+ .thoughts-body[hidden] {
4081
+ display: none;
4082
+ }
4083
+
4084
+ @media (prefers-reduced-motion: reduce) {
4085
+ .thoughts[data-streaming] .thoughts-label {
4086
+ animation: none;
4087
+ }
4088
+ }
4089
+
4007
4090
  .tool-call {
4008
4091
  align-self: flex-start;
4009
4092
  max-width: 80%;
@@ -4212,6 +4295,49 @@ var STYLES = `
4212
4295
  display: none;
4213
4296
  }
4214
4297
 
4298
+ /* The \u{1F3A4} mic button (VOICE-1); shown only once #wireVoice mounts it. */
4299
+ .voice-slot {
4300
+ display: contents;
4301
+ }
4302
+
4303
+ .voice-btn {
4304
+ border: 1px solid var(--ag-ui-border);
4305
+ border-radius: 8px;
4306
+ padding: 0 10px;
4307
+ background: var(--ag-ui-input-bg);
4308
+ color: inherit;
4309
+ font: inherit;
4310
+ cursor: pointer;
4311
+ }
4312
+
4313
+ .voice-btn:hover {
4314
+ border-color: var(--ag-ui-accent);
4315
+ }
4316
+
4317
+ .voice-btn:disabled {
4318
+ cursor: default;
4319
+ opacity: 0.6;
4320
+ }
4321
+
4322
+ /* Recording: a red tint + a gentle pulse so it's clearly "live". */
4323
+ .voice-btn[data-state="recording"] {
4324
+ border-color: var(--ag-ui-danger);
4325
+ background: var(--ag-ui-danger);
4326
+ color: #ffffff;
4327
+ animation: ag-ui-voice-pulse 1.2s ease-in-out infinite;
4328
+ }
4329
+
4330
+ @keyframes ag-ui-voice-pulse {
4331
+ 0%, 100% { opacity: 1; }
4332
+ 50% { opacity: 0.6; }
4333
+ }
4334
+
4335
+ @media (prefers-reduced-motion: reduce) {
4336
+ .voice-btn[data-state="recording"] {
4337
+ animation: none;
4338
+ }
4339
+ }
4340
+
4215
4341
  /* Pending-attachments tray, above the input row; collapses (hidden) when empty. */
4216
4342
  .attachment-slot {
4217
4343
  display: contents;
@@ -4626,6 +4752,62 @@ var STYLES = `
4626
4752
  }
4627
4753
  `;
4628
4754
 
4755
+ // src/ui/thoughts_block.ts
4756
+ var ThoughtsBlock = class {
4757
+ /** The block's root element; insert this at the top of the answer group. */
4758
+ element;
4759
+ #label;
4760
+ #body;
4761
+ #toggle;
4762
+ #strings;
4763
+ #collapsed = false;
4764
+ constructor(strings = DEFAULT_UI_STRINGS) {
4765
+ this.#strings = strings;
4766
+ this.element = document.createElement("div");
4767
+ this.element.className = "thoughts";
4768
+ this.element.setAttribute("part", "thoughts");
4769
+ this.element.setAttribute("data-streaming", "");
4770
+ this.#toggle = document.createElement("button");
4771
+ this.#toggle.type = "button";
4772
+ this.#toggle.className = "thoughts-toggle";
4773
+ this.#toggle.setAttribute("part", "thoughts-toggle");
4774
+ this.#toggle.setAttribute("aria-expanded", "true");
4775
+ this.#label = document.createElement("span");
4776
+ this.#label.className = "thoughts-label";
4777
+ this.#label.textContent = strings.thinking;
4778
+ this.#toggle.append(this.#label);
4779
+ this.#body = document.createElement("pre");
4780
+ this.#body.className = "thoughts-body";
4781
+ this.#body.setAttribute("part", "thoughts-body");
4782
+ this.#toggle.addEventListener("click", () => {
4783
+ this.#setCollapsed(!this.#collapsed);
4784
+ });
4785
+ this.element.append(this.#toggle, this.#body);
4786
+ }
4787
+ /** Replace the reasoning body with the running buffer (the full text so far). */
4788
+ stream(buffer) {
4789
+ this.#body.textContent = buffer;
4790
+ }
4791
+ /**
4792
+ * Fold the region away — called when the answer's first text token arrives.
4793
+ * Idempotent (the per-token text handler calls it repeatedly) and flips the
4794
+ * header label from "thinking…" to the settled "Thoughts" affordance.
4795
+ */
4796
+ collapse() {
4797
+ if (this.#collapsed) {
4798
+ return;
4799
+ }
4800
+ this.element.removeAttribute("data-streaming");
4801
+ this.#label.textContent = this.#strings.thoughts;
4802
+ this.#setCollapsed(true);
4803
+ }
4804
+ #setCollapsed(collapsed) {
4805
+ this.#collapsed = collapsed;
4806
+ this.#body.hidden = collapsed;
4807
+ this.#toggle.setAttribute("aria-expanded", String(!collapsed));
4808
+ }
4809
+ };
4810
+
4629
4811
  // src/ui/relative_time.ts
4630
4812
  function relativeTime(timestamp, now = Date.now(), strings = DEFAULT_UI_STRINGS) {
4631
4813
  const seconds = Math.round((now - timestamp) / 1e3);
@@ -4929,6 +5111,115 @@ ${text2}`;
4929
5111
  }
4930
5112
  };
4931
5113
 
5114
+ // src/ui/voice_input.ts
5115
+ var VoiceInput = class {
5116
+ /** The mic button; mount this in the composer. */
5117
+ element;
5118
+ #transcribe;
5119
+ #onText;
5120
+ #strings;
5121
+ #state = "idle";
5122
+ #recorder = null;
5123
+ #stream = null;
5124
+ #chunks = [];
5125
+ constructor(options) {
5126
+ this.#transcribe = options.transcribe;
5127
+ this.#onText = options.onText;
5128
+ this.#strings = options.strings ?? DEFAULT_UI_STRINGS;
5129
+ this.element = document.createElement("button");
5130
+ this.element.type = "button";
5131
+ this.element.className = "voice-btn";
5132
+ this.element.setAttribute("part", "voice-button");
5133
+ this.element.textContent = "\u{1F3A4}";
5134
+ this.#setState("idle");
5135
+ this.element.addEventListener("click", () => {
5136
+ void this.toggle();
5137
+ });
5138
+ }
5139
+ /** Start recording when idle, stop (and transcribe) when recording. */
5140
+ async toggle() {
5141
+ if (this.#state === "recording") {
5142
+ this.#stop();
5143
+ return;
5144
+ }
5145
+ if (this.#state === "transcribing") {
5146
+ return;
5147
+ }
5148
+ await this.#start();
5149
+ }
5150
+ async #start() {
5151
+ let stream;
5152
+ try {
5153
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
5154
+ } catch {
5155
+ this.#fail(this.#strings.transcriptionFailed);
5156
+ return;
5157
+ }
5158
+ this.#stream = stream;
5159
+ this.#chunks = [];
5160
+ const recorder = new MediaRecorder(stream);
5161
+ recorder.addEventListener("dataavailable", (event) => {
5162
+ this.#chunks.push(event.data);
5163
+ });
5164
+ recorder.addEventListener("stop", () => {
5165
+ void this.#finish(recorder.mimeType);
5166
+ });
5167
+ this.#recorder = recorder;
5168
+ recorder.start();
5169
+ this.#setState("recording");
5170
+ }
5171
+ #stop() {
5172
+ this.#recorder?.stop();
5173
+ }
5174
+ async #finish(mimeType) {
5175
+ this.#releaseStream();
5176
+ this.#setState("transcribing");
5177
+ const audio = new Blob(this.#chunks, { type: mimeType || "audio/webm" });
5178
+ try {
5179
+ const text2 = await this.#transcribe(audio);
5180
+ this.#setState("idle");
5181
+ if (text2 !== "") {
5182
+ this.#onText(text2);
5183
+ }
5184
+ } catch (error) {
5185
+ this.#fail(error instanceof Error ? error.message : this.#strings.transcriptionFailed);
5186
+ } finally {
5187
+ this.#recorder = null;
5188
+ }
5189
+ }
5190
+ /** Stop the mic tracks so the browser's recording indicator clears. */
5191
+ #releaseStream() {
5192
+ for (const track of this.#stream?.getTracks() ?? []) {
5193
+ track.stop();
5194
+ }
5195
+ this.#stream = null;
5196
+ }
5197
+ #fail(message) {
5198
+ this.#releaseStream();
5199
+ this.#recorder = null;
5200
+ this.#setState("idle");
5201
+ this.element.title = message;
5202
+ }
5203
+ #setState(state) {
5204
+ this.#state = state;
5205
+ this.element.dataset["state"] = state;
5206
+ const label = this.#labelFor(state);
5207
+ this.element.title = label;
5208
+ this.element.setAttribute("aria-label", label);
5209
+ this.element.setAttribute("aria-pressed", String(state === "recording"));
5210
+ this.element.disabled = state === "transcribing";
5211
+ }
5212
+ #labelFor(state) {
5213
+ if (state === "recording") {
5214
+ return this.#strings.stopRecording;
5215
+ }
5216
+ if (state === "transcribing") {
5217
+ return this.#strings.transcribing;
5218
+ }
5219
+ return this.#strings.recordVoice;
5220
+ }
5221
+ };
5222
+
4932
5223
  // src/core/agui_client.ts
4933
5224
  import { randomUUID as randomUUID2 } from "@ag-ui/client";
4934
5225
  var ConnectionLostError = class extends Error {
@@ -5100,6 +5391,18 @@ var AgUiClient = class {
5100
5391
  onToolCallResultEvent({ event }) {
5101
5392
  h.onToolResult(event.toolCallId, event.content);
5102
5393
  },
5394
+ // Reasoning (THINK-1). `@ag-ui/client` already maps the deprecated
5395
+ // THINKING_* events onto these REASONING_* callbacks, so handling the
5396
+ // reasoning family alone covers both protocol versions.
5397
+ onReasoningStartEvent() {
5398
+ h.onReasoningStart();
5399
+ },
5400
+ onReasoningMessageContentEvent({ reasoningMessageBuffer }) {
5401
+ h.onReasoningDelta(reasoningMessageBuffer);
5402
+ },
5403
+ onReasoningEndEvent() {
5404
+ h.onReasoningEnd();
5405
+ },
5103
5406
  onRunErrorEvent({ event }) {
5104
5407
  runState.terminal = true;
5105
5408
  h.onError(event.message);
@@ -5376,6 +5679,35 @@ var RemoteConversationStore = class {
5376
5679
  }
5377
5680
  };
5378
5681
 
5682
+ // src/core/transcribe_audio.ts
5683
+ async function transcribeAudio(audio, options) {
5684
+ const form = new FormData();
5685
+ form.append("audio", audio, "recording.webm");
5686
+ const response = await fetch(options.url, {
5687
+ method: "POST",
5688
+ headers: { ...options.headers ?? {} },
5689
+ body: form
5690
+ });
5691
+ if (!response.ok) {
5692
+ throw new Error(await errorMessage(response));
5693
+ }
5694
+ const body = await response.json();
5695
+ if (typeof body === "object" && body !== null && typeof body.text === "string") {
5696
+ return body.text;
5697
+ }
5698
+ throw new Error("transcription returned an unreadable response");
5699
+ }
5700
+ async function errorMessage(response) {
5701
+ try {
5702
+ const body = await response.json();
5703
+ if (typeof body.error === "string") {
5704
+ return body.error;
5705
+ }
5706
+ } catch {
5707
+ }
5708
+ return `transcription failed (${response.status})`;
5709
+ }
5710
+
5379
5711
  // src/core/upload_attachment.ts
5380
5712
  function uploadAttachment(file, options) {
5381
5713
  return new Promise((resolve, reject) => {
@@ -5402,7 +5734,7 @@ function uploadAttachment(file, options) {
5402
5734
  reject(new Error("upload returned an unreadable response"));
5403
5735
  }
5404
5736
  } else {
5405
- reject(new Error(errorMessage(xhr)));
5737
+ reject(new Error(errorMessage2(xhr)));
5406
5738
  }
5407
5739
  });
5408
5740
  xhr.addEventListener("error", () => reject(new Error("upload failed")));
@@ -5429,7 +5761,7 @@ function parseRef(body) {
5429
5761
  }
5430
5762
  return typeof url === "string" ? { id, name, mime, size, url } : { id, name, mime, size };
5431
5763
  }
5432
- function errorMessage(xhr) {
5764
+ function errorMessage2(xhr) {
5433
5765
  try {
5434
5766
  const body = JSON.parse(xhr.responseText);
5435
5767
  if (typeof body.error === "string") {
@@ -5442,6 +5774,7 @@ function errorMessage(xhr) {
5442
5774
 
5443
5775
  // src/core/ag_ui_chat.ts
5444
5776
  var COLLAPSED_KEY = "ag-ui-chat:collapsed";
5777
+ var THEME_KEY = "ag-ui-chat:theme";
5445
5778
  var AgUiChat = class extends HTMLElement {
5446
5779
  /** Agent factory; override to inject a custom or fake agent (tests). */
5447
5780
  agentFactory = createHttpAgent;
@@ -5519,6 +5852,15 @@ var AgUiChat = class extends HTMLElement {
5519
5852
  * with no `data-attachments-url`; the handler owns its own endpoint + headers.
5520
5853
  */
5521
5854
  uploadHandler = null;
5855
+ /**
5856
+ * How recorded voice clips are transcribed. `null` (default) POSTs the clip
5857
+ * to `data-transcribe-url` (django-ag-ui's `TranscribeView`). Set a custom
5858
+ * {@link TranscribeHandler} — `(audio: Blob) => Promise<string>` — to swap the
5859
+ * transport (a different STT endpoint, a browser Web Speech adapter) without
5860
+ * touching the mic button. When set, the 🎤 affordance appears even with no
5861
+ * `data-transcribe-url`.
5862
+ */
5863
+ transcribeHandler = null;
5522
5864
  /**
5523
5865
  * Builds the tool result a navigating tool resumes with after the page
5524
5866
  * reloads. Defaults to the landed URL; a host (e.g. the admin package) can
@@ -5587,12 +5929,18 @@ var AgUiChat = class extends HTMLElement {
5587
5929
  #attachButton;
5588
5930
  #fileInput;
5589
5931
  #attachSlot;
5932
+ /** Optional built-in header theme toggle (THEME-1); shown only with `data-theme-toggle`. */
5933
+ #themeToggle;
5590
5934
  /** The collapsed-sidebar rail (an expand affordance; shown only for `placement="sidebar"`). */
5591
5935
  #rail;
5592
5936
  /** Empty-state region at the top of the message list; hidden once anything renders. */
5593
5937
  #emptyWrap;
5594
5938
  /** Upload tray; created on connect only when `data-attachments-url` is set. */
5595
5939
  #attachTray = null;
5940
+ /** Mic button mount point (input row); the control mounts on connect when enabled. */
5941
+ #voiceSlot;
5942
+ /** Voice-input control; created on connect when transcription is available. */
5943
+ #voice = null;
5596
5944
  /** Refs attached to the message currently being sent (the context manifest). */
5597
5945
  #runAttachments = [];
5598
5946
  #client = null;
@@ -5616,6 +5964,10 @@ var AgUiChat = class extends HTMLElement {
5616
5964
  // frontend-tool loop (which is several AG-UI runs), not one run. `null`
5617
5965
  // between turns; user bubbles never enter it.
5618
5966
  #currentGroup = null;
5967
+ // The current turn's streamed-reasoning region (THINK-1), shown at the top of
5968
+ // the answer group while a reasoning model thinks and collapsed once the
5969
+ // answer's first text token arrives. `null` outside a reasoning turn.
5970
+ #thoughts = null;
5619
5971
  #threadId = "";
5620
5972
  #initialMessages = [];
5621
5973
  // Skill catalog by source; merged backend → embed → client (later wins).
@@ -5634,6 +5986,8 @@ var AgUiChat = class extends HTMLElement {
5634
5986
  this.#attachButton = document.createElement("button");
5635
5987
  this.#fileInput = document.createElement("input");
5636
5988
  this.#attachSlot = document.createElement("div");
5989
+ this.#voiceSlot = document.createElement("span");
5990
+ this.#themeToggle = document.createElement("button");
5637
5991
  this.#rail = document.createElement("button");
5638
5992
  this.#emptyWrap = document.createElement("div");
5639
5993
  this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
@@ -5759,6 +6113,12 @@ var AgUiChat = class extends HTMLElement {
5759
6113
  }
5760
6114
  connectedCallback() {
5761
6115
  this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
6116
+ if (this.getAttribute("data-theme-toggle") !== null) {
6117
+ const saved = sessionStorage.getItem(THEME_KEY);
6118
+ if (saved !== null) {
6119
+ this.setAttribute("theme", saved);
6120
+ }
6121
+ }
5762
6122
  this.#render();
5763
6123
  this.#drawer.setStrings(this.#strings);
5764
6124
  if (sessionStorage.getItem(COLLAPSED_KEY) === "1") {
@@ -5769,6 +6129,7 @@ var AgUiChat = class extends HTMLElement {
5769
6129
  void this.#fetchToolCatalog();
5770
6130
  this.#wireThreadStore();
5771
6131
  this.#wireAttachments();
6132
+ this.#wireVoice();
5772
6133
  this.#threadId = this.conversationStore.threadId();
5773
6134
  void this.#rehydrate();
5774
6135
  }
@@ -5819,6 +6180,40 @@ var AgUiChat = class extends HTMLElement {
5819
6180
  }
5820
6181
  return (file, onProgress) => uploadAttachment(file, { url, headers: this.headers, onProgress });
5821
6182
  }
6183
+ /**
6184
+ * Reveal the composer's 🎤 mic button when transcription is possible — either
6185
+ * a custom {@link transcribeHandler} is set or `data-transcribe-url` provides
6186
+ * the built-in POST endpoint. The control records via `MediaRecorder` and
6187
+ * drops the transcript into the composer; with neither configured the mic
6188
+ * stays hidden and the chat is text-only.
6189
+ */
6190
+ #wireVoice() {
6191
+ const url = this.getAttribute("data-transcribe-url");
6192
+ const transcribe = this.transcribeHandler ?? this.#defaultTranscribeHandler(url);
6193
+ if (transcribe === null) {
6194
+ return;
6195
+ }
6196
+ this.#voice = new VoiceInput({
6197
+ transcribe,
6198
+ onText: (text2) => this.#insertVoiceText(text2),
6199
+ strings: this.#strings
6200
+ });
6201
+ this.#voiceSlot.appendChild(this.#voice.element);
6202
+ }
6203
+ /** The built-in transcription handler for `data-transcribe-url`, or `null`. */
6204
+ #defaultTranscribeHandler(url) {
6205
+ if (url === null) {
6206
+ return null;
6207
+ }
6208
+ return (audio) => transcribeAudio(audio, { url, headers: this.headers });
6209
+ }
6210
+ /** Drop a voice transcript into the composer (appended to any typed text). */
6211
+ #insertVoiceText(text2) {
6212
+ const current = this.#input.value.trim();
6213
+ this.#input.value = current === "" ? text2 : `${current} ${text2}`;
6214
+ this.#onInput();
6215
+ this.#input.focus();
6216
+ }
5822
6217
  /** The client-side upload size cap from `data-attachment-max-bytes`. */
5823
6218
  #attachmentMaxBytes() {
5824
6219
  const attr = this.getAttribute("data-attachment-max-bytes");
@@ -6001,6 +6396,23 @@ Use the read_attachment tool with an id to read a file's contents.`
6001
6396
  toggleCollapsed() {
6002
6397
  this.setCollapsed(!this.collapsed);
6003
6398
  }
6399
+ /**
6400
+ * Flip the `theme` attribute between `light` and `dark` and persist the choice
6401
+ * per tab. Bound to the optional built-in header theme toggle
6402
+ * (`data-theme-toggle`); any non-dark theme (incl. `auto` / `code`) flips to
6403
+ * `dark` first.
6404
+ */
6405
+ toggleTheme() {
6406
+ const next = this.getAttribute("theme") === "dark" ? "light" : "dark";
6407
+ this.setAttribute("theme", next);
6408
+ sessionStorage.setItem(THEME_KEY, next);
6409
+ this.#syncThemeGlyph();
6410
+ }
6411
+ /** Reflect the current theme on the toggle: show the destination's glyph. */
6412
+ #syncThemeGlyph() {
6413
+ const dark = this.getAttribute("theme") === "dark";
6414
+ this.#themeToggle.textContent = dark ? "\u2600\uFE0F" : "\u{1F319}";
6415
+ }
6004
6416
  /**
6005
6417
  * Start a fresh conversation: forget the persisted history, drop the
6006
6418
  * in-memory run state, clear the transcript, and mint a new thread id.
@@ -6017,6 +6429,7 @@ Use the read_attachment tool with an id to read a file's contents.`
6017
6429
  this.#client = null;
6018
6430
  this.#streamingBubble = null;
6019
6431
  this.#currentGroup = null;
6432
+ this.#thoughts = null;
6020
6433
  this.#hidePending();
6021
6434
  this.#toolCards.clear();
6022
6435
  this.#serverSettled.clear();
@@ -6216,7 +6629,18 @@ Use the read_attachment tool with an id to read a file's contents.`
6216
6629
  newChat.addEventListener("click", () => this.newChat());
6217
6630
  const collapse = this.#headerButton("collapse", this.#strings.collapse, "\u2014");
6218
6631
  collapse.addEventListener("click", () => this.toggleCollapsed());
6219
- controls.append(history, newChat, collapse);
6632
+ controls.append(history, newChat);
6633
+ if (this.getAttribute("data-theme-toggle") !== null) {
6634
+ this.#themeToggle.type = "button";
6635
+ this.#themeToggle.className = "header-btn header-btn--theme";
6636
+ this.#themeToggle.setAttribute("part", "header-button theme-toggle");
6637
+ this.#themeToggle.title = this.#strings.toggleTheme;
6638
+ this.#themeToggle.setAttribute("aria-label", this.#strings.toggleTheme);
6639
+ this.#themeToggle.addEventListener("click", () => this.toggleTheme());
6640
+ this.#syncThemeGlyph();
6641
+ controls.append(this.#themeToggle);
6642
+ }
6643
+ controls.append(collapse);
6220
6644
  header.append(title, headerActions, controls);
6221
6645
  this.#messages.className = "messages";
6222
6646
  this.#messages.setAttribute("part", "messages");
@@ -6268,9 +6692,10 @@ Use the read_attachment tool with an id to read a file's contents.`
6268
6692
  this.#fileInput.hidden = true;
6269
6693
  this.#fileInput.addEventListener("change", () => this.#onFilesPicked());
6270
6694
  this.#attachSlot.className = "attachment-slot";
6695
+ this.#voiceSlot.className = "voice-slot";
6271
6696
  const footer = document.createElement("slot");
6272
6697
  footer.name = "footer";
6273
- inputRow.append(this.#attachButton, this.#input, this.#send, this.#fileInput);
6698
+ inputRow.append(this.#attachButton, this.#voiceSlot, this.#input, this.#send, this.#fileInput);
6274
6699
  this.#chat.append(
6275
6700
  header,
6276
6701
  this.#messages,
@@ -6495,8 +6920,18 @@ Use the read_attachment tool with an id to read a file's contents.`
6495
6920
  this.#ensureGroup();
6496
6921
  this.#showPending();
6497
6922
  },
6923
+ onReasoningStart: () => {
6924
+ this.#hidePending();
6925
+ this.#showThoughts();
6926
+ },
6927
+ onReasoningDelta: (buffer) => {
6928
+ this.#showThoughts().stream(buffer);
6929
+ },
6930
+ onReasoningEnd: () => {
6931
+ },
6498
6932
  onTextDelta: (buffer) => {
6499
6933
  this.#hidePending();
6934
+ this.#thoughts?.collapse();
6500
6935
  this.#streamInto(buffer);
6501
6936
  this.#streamDeltas += 1;
6502
6937
  },
@@ -6548,6 +6983,7 @@ Use the read_attachment tool with an id to read a file's contents.`
6548
6983
  this.#updateEmptyState();
6549
6984
  }
6550
6985
  this.#currentGroup = null;
6986
+ this.#thoughts = null;
6551
6987
  }
6552
6988
  };
6553
6989
  }
@@ -6591,6 +7027,21 @@ Use the read_attachment tool with an id to read a file's contents.`
6591
7027
  this.#pending?.remove();
6592
7028
  this.#pending = null;
6593
7029
  }
7030
+ /**
7031
+ * The current turn's thoughts region, creating it (at the top of the answer
7032
+ * group, above any streamed text or tool cards) on first sight. Idempotent
7033
+ * across a turn's reasoning tokens.
7034
+ */
7035
+ #showThoughts() {
7036
+ if (this.#thoughts === null) {
7037
+ this.#thoughts = new ThoughtsBlock(this.#strings);
7038
+ const group = this.#ensureGroup();
7039
+ group.insertBefore(this.#thoughts.element, group.firstChild);
7040
+ this.#updateEmptyState();
7041
+ this.#messages.scrollTop = this.#messages.scrollHeight;
7042
+ }
7043
+ return this.#thoughts;
7044
+ }
6594
7045
  #streamInto(buffer) {
6595
7046
  if (this.#streamingBubble === null) {
6596
7047
  this.#streamingBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, "");
@@ -6662,7 +7113,7 @@ function setControlValue(el, value) {
6662
7113
  }
6663
7114
 
6664
7115
  // src/version.ts
6665
- var VERSION = "0.8.0";
7116
+ var VERSION = "0.9.0";
6666
7117
  export {
6667
7118
  AgUiChat,
6668
7119
  AgUiClient,
@@ -6714,6 +7165,7 @@ export {
6714
7165
  setNativeValue,
6715
7166
  toggleCheckbox,
6716
7167
  toggleControl,
7168
+ transcribeAudio,
6717
7169
  typeInto,
6718
7170
  uploadAttachment
6719
7171
  };