@happyvertical/smrt-chat 0.55.6 → 0.55.8

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.
@@ -15,6 +15,8 @@ import {
15
15
  type DictationSourceProvider,
16
16
  DictationStatus,
17
17
  type DictationTranscribe,
18
+ type HandsFreeCaptureFactory,
19
+ type HandsFreeVadOptions,
18
20
  insertTextAtCursor,
19
21
  longPress,
20
22
  primeReadyBeep,
@@ -22,7 +24,7 @@ import {
22
24
  } from '@happyvertical/smrt-ui/forms';
23
25
  import { useI18n } from '@happyvertical/smrt-ui/i18n';
24
26
  import { Button } from '@happyvertical/smrt-ui/ui';
25
- import { onDestroy } from 'svelte';
27
+ import { onDestroy, untrack } from 'svelte';
26
28
  import { M } from '../../i18n.js';
27
29
  import type { AssistantAttachmentRef } from './assistant-transport.js';
28
30
 
@@ -66,6 +68,43 @@ export interface Props {
66
68
  * own, without `dictation`.
67
69
  */
68
70
  transcribe?: DictationTranscribe | null;
71
+ /**
72
+ * `'hands-free'`: the microphone stays on and each sentence is written
73
+ * down when the speaker pauses, with no tap per phrase; tapping the
74
+ * microphone (or sending, or Escape) ends it. Needs `handsFreeCapture` and
75
+ * an on-device speech source (smrt-svelte's `whisper-local` / `moonshine`);
76
+ * otherwise it is ordinary press-to-talk. Default `'push'`.
77
+ */
78
+ dictationMode?: 'push' | 'hands-free';
79
+ /**
80
+ * The hands-free microphone, `createHandsFreeCapture` from
81
+ * `@happyvertical/smrt-ui/forms/hands-free`.
82
+ */
83
+ handsFreeCapture?: HandsFreeCaptureFactory | null;
84
+ /** Pause length and sensitivity for hands-free (see `HandsFreeVadOptions`). */
85
+ handsFreeVad?: HandsFreeVadOptions;
86
+ /**
87
+ * Hands-free only: send the message once the speaker has been quiet for
88
+ * `sendOnPauseMs` after the last sentence was written down (the same path
89
+ * as pressing Send). A new sentence, a key press, Escape or the microphone
90
+ * button cancels the pending send. Never sends an empty message. While a
91
+ * send is in flight, or the composer is disabled, the send waits and the
92
+ * text stays in the box until the composer can accept it again. Default
93
+ * `false`.
94
+ */
95
+ sendOnPause?: boolean;
96
+ /** Quiet time before `sendOnPause` sends, in ms. Default 1200. */
97
+ sendOnPauseMs?: number;
98
+ /**
99
+ * The assistant's reply is being played aloud (text to speech). While
100
+ * `true`, hands-free listening is suspended (half-duplex: the microphone
101
+ * stays open but nothing is heard, so the assistant is not transcribed into
102
+ * the box) and resumes after a short guard when it turns `false`. The
103
+ * microphone shows "Paused while the assistant speaks". A pending send on
104
+ * pause is unaffected; tapping the microphone still ends hands-free.
105
+ * Default `false`.
106
+ */
107
+ speaking?: boolean;
69
108
  }
70
109
 
71
110
  let {
@@ -76,6 +115,12 @@ let {
76
115
  value: content = $bindable(''),
77
116
  dictation: dictationSource = null,
78
117
  transcribe = null,
118
+ dictationMode = 'push',
119
+ handsFreeCapture = null,
120
+ handsFreeVad,
121
+ sendOnPause = false,
122
+ sendOnPauseMs = 1200,
123
+ speaking = false,
79
124
  }: Props = $props();
80
125
  const canDictate = $derived(Boolean(dictationSource || transcribe));
81
126
  let stagedAttachments = $state<AssistantAttachmentRef[]>([]);
@@ -105,6 +150,26 @@ function messageField(): HTMLTextAreaElement | null {
105
150
  return textareaEl ?? null;
106
151
  }
107
152
 
153
+ /** Moves focus to the message field (a host that opens the composer
154
+ * directly, e.g. AssistantDock `conversations="single"`). A no-op while the
155
+ * composer is disabled. */
156
+ export function focus(): void {
157
+ messageField()?.focus();
158
+ }
159
+
160
+ // Send on pause: true once a hands-free sentence has been written into the
161
+ // box and not yet sent, `autoSendCountdown` while the grace period runs, and
162
+ // `autoSendWaiting` when the grace period is over but a send is in flight.
163
+ let sendDictated = $state(false);
164
+ let autoSendCountdown = $state(false);
165
+ let autoSendWaiting = $state(false);
166
+
167
+ function cancelAutoSend() {
168
+ sendDictated = false;
169
+ autoSendCountdown = false;
170
+ autoSendWaiting = false;
171
+ }
172
+
108
173
  // One dictation per composer; the source is read when listening starts.
109
174
  const dictation = new Dictation({
110
175
  source: () => {
@@ -112,6 +177,8 @@ const dictation = new Dictation({
112
177
  return dictationSource();
113
178
  },
114
179
  onText: (text) => {
180
+ // A new sentence (re)arms the send-on-pause countdown.
181
+ sendDictated = true;
115
182
  const field = messageField();
116
183
  if (field) insertTextAtCursor(field, text);
117
184
  else content = content ? `${content} ${text}` : text;
@@ -119,11 +186,74 @@ const dictation = new Dictation({
119
186
  });
120
187
  // The recording fallback follows the prop (a host may set it later).
121
188
  $effect.pre(() => {
122
- dictation.setOptions({ transcribe });
189
+ dictation.setOptions({
190
+ transcribe,
191
+ mode: dictationMode,
192
+ handsFreeCapture,
193
+ vad: handsFreeVad,
194
+ });
123
195
  });
124
196
 
125
197
  onDestroy(() => dictation.dispose());
126
198
 
199
+ // Half-duplex: do not listen while the assistant's reply is played aloud.
200
+ $effect(() => {
201
+ const on = speaking;
202
+ untrack(() => (on ? dictation.suspend() : dictation.resume()));
203
+ });
204
+
205
+ // The grace period: quiet, nothing being written down, something to send.
206
+ // Speech starting (or a sentence still queued) clears it; the next written
207
+ // sentence starts it again.
208
+ $effect(() => {
209
+ const armed =
210
+ sendOnPause &&
211
+ sendDictated &&
212
+ dictation.handsFree &&
213
+ dictation.state === 'listening' &&
214
+ !dictation.speaking &&
215
+ dictation.queued === 0 &&
216
+ content.trim().length > 0;
217
+ if (!armed) {
218
+ autoSendCountdown = false;
219
+ return;
220
+ }
221
+ autoSendCountdown = true;
222
+ const timer = setTimeout(
223
+ () => {
224
+ autoSendCountdown = false;
225
+ sendDictated = false;
226
+ autoSendWaiting = true;
227
+ },
228
+ Math.max(0, sendOnPauseMs),
229
+ );
230
+ return () => clearTimeout(timer);
231
+ });
232
+
233
+ // Ends of the wait: send as soon as the composer can take input, but only
234
+ // while still hands-free (stopping the microphone cancels a pending send).
235
+ $effect(() => {
236
+ if (!autoSendWaiting) return;
237
+ if (!sendOnPause || !dictation.handsFree || !content.trim()) {
238
+ autoSendWaiting = false;
239
+ return;
240
+ }
241
+ if (dictation.speaking || dictation.queued > 0) {
242
+ // More was said while waiting: back to the grace period.
243
+ autoSendWaiting = false;
244
+ sendDictated = true;
245
+ return;
246
+ }
247
+ if (sending || disabled || uploading) return;
248
+ autoSendWaiting = false;
249
+ void handleSend(true);
250
+ });
251
+
252
+ // Hands-free ended (microphone tapped, Escape, an error): nothing is sent.
253
+ $effect(() => {
254
+ if (!dictation.handsFree) cancelAutoSend();
255
+ });
256
+
127
257
  function startDictationFromHold() {
128
258
  if (disabled || uploading || dictation.active) return;
129
259
  void dictation.start();
@@ -192,25 +322,34 @@ async function handleDrop(event: DragEvent) {
192
322
  }
193
323
  }
194
324
 
195
- async function handleSend() {
325
+ /** `keepListening`: a hands-free send on pause keeps the microphone on so the
326
+ * conversation can go on; pressing Send or Enter ends dictation. */
327
+ async function handleSend(keepListening = false) {
328
+ if (disabled || sending) return;
329
+ cancelAutoSend();
330
+ // End dictation and wait for its last words before the draft is read.
331
+ if (dictation.active && !keepListening) {
332
+ await dictation.stop();
333
+ await dictation.whenSettled();
334
+ }
196
335
  const trimmed = content.trim();
197
336
  if (!trimmed || disabled || sending) return;
198
- if (dictation.active) void dictation.stop();
199
337
  sendError = null;
200
338
  sending = true;
201
- // #2904 review finding 4: keep the draft text/attachments until onsend
202
- // settles — clearing them synchronously (the previous behavior) lost the
203
- // user's message forever on a transport failure, with no visible error.
339
+ // The sent text leaves the box as soon as the send starts, so a manual or
340
+ // automatic send never leaves it sitting there for the whole reply, and
341
+ // anything said or typed meanwhile lands in a fresh box. A failed send puts
342
+ // it back (#2904 review finding 4: never lose the message on failure).
343
+ const sentAttachments = stagedAttachments;
344
+ content = '';
345
+ stagedAttachments = [];
346
+ if (textareaEl) textareaEl.style.height = 'auto';
204
347
  try {
205
- await onsend(trimmed, stagedAttachments);
206
- // #2991: text typed while the send was in flight is newer than what was
207
- // sent; keep it.
208
- if (content.trim() === trimmed) content = '';
209
- stagedAttachments = [];
210
- if (textareaEl) {
211
- textareaEl.style.height = 'auto';
212
- }
348
+ await onsend(trimmed, sentAttachments);
213
349
  } catch (error) {
350
+ const newer = content.trim();
351
+ content = newer ? `${trimmed}\n${newer}` : trimmed;
352
+ stagedAttachments = [...sentAttachments, ...stagedAttachments];
214
353
  sendError =
215
354
  error instanceof Error
216
355
  ? error.message
@@ -221,17 +360,20 @@ async function handleSend() {
221
360
  }
222
361
 
223
362
  function handleKeydown(event: KeyboardEvent) {
224
- if (event.key === 'Escape' && dictation.active) {
363
+ if (event.key === 'Escape' && (dictation.active || sendDictated)) {
225
364
  event.preventDefault();
226
- void dictation.stop();
365
+ cancelAutoSend();
366
+ if (dictation.active) void dictation.stop();
227
367
  return;
228
368
  }
369
+ // Typing means the person has taken over: no automatic send.
370
+ if (event.key.length === 1 || event.key === 'Backspace') cancelAutoSend();
229
371
  // Matches ../messages/MessageInput.svelte's handleKeydown convention:
230
372
  // plain Enter sends, Shift+Enter inserts a newline. `isComposing` guards
231
373
  // against an IME's confirmation Enter being treated as a send.
232
374
  if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
233
375
  event.preventDefault();
234
- handleSend();
376
+ void handleSend();
235
377
  }
236
378
  }
237
379
 
@@ -284,7 +426,7 @@ function removeAttachment(id: string) {
284
426
  </ul>
285
427
  {/if}
286
428
  {#if canDictate}
287
- <DictationStatus {dictation} />
429
+ <DictationStatus {dictation} sending={autoSendCountdown || autoSendWaiting} />
288
430
  {/if}
289
431
  <div class="assistant-composer-row">
290
432
  {#if onupload}
@@ -349,7 +491,7 @@ function removeAttachment(id: string) {
349
491
  <Button
350
492
  type="button"
351
493
  class="assistant-composer-send"
352
- onclick={handleSend}
494
+ onclick={() => handleSend()}
353
495
  disabled={disabled || uploading || sending || !content.trim()}
354
496
  >
355
497
  {t(M['chat.assistant_composer.send'])}
@@ -363,8 +505,10 @@ function removeAttachment(id: string) {
363
505
  background: var(--smrt-color-surface, #ffffff);
364
506
  padding: var(--smrt-spacing-2, 8px);
365
507
  flex-shrink: 0;
508
+ container-type: inline-size;
366
509
  }
367
510
 
511
+
368
512
  .assistant-composer-row {
369
513
  display: flex;
370
514
  align-items: flex-end;
@@ -419,13 +563,7 @@ function removeAttachment(id: string) {
419
563
  /* Phones: 16px text so iOS Safari doesn't zoom the page when the
420
564
  message field gets focus. */
421
565
  @media (max-width: 48rem) {
422
- .assistant-composer-field {
423
- flex: 1;
424
- display: flex;
425
- min-inline-size: 0;
426
- }
427
-
428
- :global(.assistant-composer-textarea) {
566
+ :global(.assistant-composer-textarea) {
429
567
  font-size: max(1rem, 16px);
430
568
  }
431
569
  }
@@ -491,4 +629,22 @@ function removeAttachment(id: string) {
491
629
  color: var(--smrt-color-on-error-container, #410002);
492
630
  font: var(--smrt-typography-body-small-font, 0.8125rem/1.4 sans-serif);
493
631
  }
632
+
633
+ /* A narrow dock (a sidebar a couple of hundred pixels wide): the message
634
+ field takes the whole row and the attach, dictate and send controls sit
635
+ under it, so the placeholder never wraps a syllable per line. */
636
+ @container (max-width: 22rem) {
637
+ .assistant-composer-row {
638
+ flex-wrap: wrap;
639
+ }
640
+
641
+ .assistant-composer-field {
642
+ flex: 1 1 100%;
643
+ order: -1;
644
+ }
645
+
646
+ :global(.assistant-composer-send) {
647
+ margin-inline-start: auto;
648
+ }
649
+ }
494
650
  </style>
@@ -8,7 +8,7 @@
8
8
  * flow, which is sized for a dedicated upload panel, not a one-line composer
9
9
  * (#2904 review fix).
10
10
  */
11
- import { type DictationSourceProvider, type DictationTranscribe } from '@happyvertical/smrt-ui/forms';
11
+ import { type DictationSourceProvider, type DictationTranscribe, type HandsFreeCaptureFactory, type HandsFreeVadOptions } from '@happyvertical/smrt-ui/forms';
12
12
  import type { AssistantAttachmentRef } from './assistant-transport.js';
13
13
  export interface Props {
14
14
  /** Called with the trimmed message text and any staged attachments when
@@ -45,8 +45,47 @@ export interface Props {
45
45
  * own, without `dictation`.
46
46
  */
47
47
  transcribe?: DictationTranscribe | null;
48
+ /**
49
+ * `'hands-free'`: the microphone stays on and each sentence is written
50
+ * down when the speaker pauses, with no tap per phrase; tapping the
51
+ * microphone (or sending, or Escape) ends it. Needs `handsFreeCapture` and
52
+ * an on-device speech source (smrt-svelte's `whisper-local` / `moonshine`);
53
+ * otherwise it is ordinary press-to-talk. Default `'push'`.
54
+ */
55
+ dictationMode?: 'push' | 'hands-free';
56
+ /**
57
+ * The hands-free microphone, `createHandsFreeCapture` from
58
+ * `@happyvertical/smrt-ui/forms/hands-free`.
59
+ */
60
+ handsFreeCapture?: HandsFreeCaptureFactory | null;
61
+ /** Pause length and sensitivity for hands-free (see `HandsFreeVadOptions`). */
62
+ handsFreeVad?: HandsFreeVadOptions;
63
+ /**
64
+ * Hands-free only: send the message once the speaker has been quiet for
65
+ * `sendOnPauseMs` after the last sentence was written down (the same path
66
+ * as pressing Send). A new sentence, a key press, Escape or the microphone
67
+ * button cancels the pending send. Never sends an empty message. While a
68
+ * send is in flight, or the composer is disabled, the send waits and the
69
+ * text stays in the box until the composer can accept it again. Default
70
+ * `false`.
71
+ */
72
+ sendOnPause?: boolean;
73
+ /** Quiet time before `sendOnPause` sends, in ms. Default 1200. */
74
+ sendOnPauseMs?: number;
75
+ /**
76
+ * The assistant's reply is being played aloud (text to speech). While
77
+ * `true`, hands-free listening is suspended (half-duplex: the microphone
78
+ * stays open but nothing is heard, so the assistant is not transcribed into
79
+ * the box) and resumes after a short guard when it turns `false`. The
80
+ * microphone shows "Paused while the assistant speaks". A pending send on
81
+ * pause is unaffected; tapping the microphone still ends hands-free.
82
+ * Default `false`.
83
+ */
84
+ speaking?: boolean;
48
85
  }
49
- declare const AssistantComposer: import("svelte").Component<Props, {}, "value">;
86
+ declare const AssistantComposer: import("svelte").Component<Props, {
87
+ focus: () => void;
88
+ }, "value">;
50
89
  type AssistantComposer = ReturnType<typeof AssistantComposer>;
51
90
  export default AssistantComposer;
52
91
  //# sourceMappingURL=AssistantComposer.svelte.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"AssistantComposer.svelte.d.ts","sourceRoot":"","sources":["../../../../src/svelte/components/assistant/AssistantComposer.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AACH,OAAO,EAGL,KAAK,uBAAuB,EAE5B,KAAK,mBAAmB,EAKzB,MAAM,8BAA8B,CAAC;AAKtC,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAGvE,MAAM,WAAW,KAAK;IACpB;;;;4DAIwD;IACxD,MAAM,EAAE,CACN,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,sBAAsB,EAAE,KAClC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B;sEACkE;IAClE,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,QAAQ,KAAK,OAAO,CAAC,sBAAsB,EAAE,CAAC,CAAC;IAClE,yDAAyD;IACzD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+CAA+C;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;0CAEsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,uBAAuB,GAAG,IAAI,CAAC;IAC3C;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,mBAAmB,GAAG,IAAI,CAAC;CACzC;AA4PD,QAAA,MAAM,iBAAiB,gDAAwC,CAAC;AAChE,KAAK,iBAAiB,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAC9D,eAAe,iBAAiB,CAAC"}
1
+ {"version":3,"file":"AssistantComposer.svelte.d.ts","sourceRoot":"","sources":["../../../../src/svelte/components/assistant/AssistantComposer.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AACH,OAAO,EAGL,KAAK,uBAAuB,EAE5B,KAAK,mBAAmB,EACxB,KAAK,uBAAuB,EAC5B,KAAK,mBAAmB,EAKzB,MAAM,8BAA8B,CAAC;AAKtC,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAGvE,MAAM,WAAW,KAAK;IACpB;;;;4DAIwD;IACxD,MAAM,EAAE,CACN,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,sBAAsB,EAAE,KAClC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B;sEACkE;IAClE,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,QAAQ,KAAK,OAAO,CAAC,sBAAsB,EAAE,CAAC,CAAC;IAClE,yDAAyD;IACzD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+CAA+C;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;0CAEsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,uBAAuB,GAAG,IAAI,CAAC;IAC3C;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,mBAAmB,GAAG,IAAI,CAAC;IACxC;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,MAAM,GAAG,YAAY,CAAC;IACtC;;;OAGG;IACH,gBAAgB,CAAC,EAAE,uBAAuB,GAAG,IAAI,CAAC;IAClD,+EAA+E;IAC/E,YAAY,CAAC,EAAE,mBAAmB,CAAC;IACnC;;;;;;;;OAQG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,kEAAkE;IAClE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAmWD,QAAA,MAAM,iBAAiB;iBAxSJ,IAAI;WAwSwC,CAAC;AAChE,KAAK,iBAAiB,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAC9D,eAAe,iBAAiB,CAAC"}