@djangocfg/ui-tools 2.1.381 → 2.1.382

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/README.md +132 -899
  2. package/dist/ChatRoot-6IZFM5HM.mjs +5 -0
  3. package/dist/{ChatRoot-EJC5Y2YM.cjs.map → ChatRoot-6IZFM5HM.mjs.map} +1 -1
  4. package/dist/ChatRoot-LW4XNIKP.cjs +14 -0
  5. package/dist/{ChatRoot-QOSKJPM6.mjs.map → ChatRoot-LW4XNIKP.cjs.map} +1 -1
  6. package/dist/DictationField-2ZLQWLYV.mjs +4 -0
  7. package/dist/DictationField-2ZLQWLYV.mjs.map +1 -0
  8. package/dist/DictationField-IPPJ54CU.cjs +13 -0
  9. package/dist/DictationField-IPPJ54CU.cjs.map +1 -0
  10. package/dist/{DocsLayout-2YKPXZYO.mjs → DocsLayout-2P3ONDWJ.mjs} +3 -3
  11. package/dist/{DocsLayout-2YKPXZYO.mjs.map → DocsLayout-2P3ONDWJ.mjs.map} +1 -1
  12. package/dist/{DocsLayout-Q4KS3QWW.cjs → DocsLayout-2YZNS5VK.cjs} +8 -8
  13. package/dist/{DocsLayout-Q4KS3QWW.cjs.map → DocsLayout-2YZNS5VK.cjs.map} +1 -1
  14. package/dist/chunk-4LXG3NBV.mjs +833 -0
  15. package/dist/chunk-4LXG3NBV.mjs.map +1 -0
  16. package/dist/{chunk-XACCHZH2.cjs → chunk-FIRK5CEH.cjs} +42 -4
  17. package/dist/chunk-FIRK5CEH.cjs.map +1 -0
  18. package/dist/{chunk-NWUT327A.mjs → chunk-HIK6BPL7.mjs} +38 -5
  19. package/dist/chunk-HIK6BPL7.mjs.map +1 -0
  20. package/dist/chunk-KMSBGNVC.cjs +835 -0
  21. package/dist/chunk-KMSBGNVC.cjs.map +1 -0
  22. package/dist/chunk-OZAU3QWD.cjs +2493 -0
  23. package/dist/chunk-OZAU3QWD.cjs.map +1 -0
  24. package/dist/chunk-UWVP6LCW.mjs +2447 -0
  25. package/dist/chunk-UWVP6LCW.mjs.map +1 -0
  26. package/dist/index.cjs +1532 -100
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +1148 -107
  29. package/dist/index.d.ts +1148 -107
  30. package/dist/index.mjs +1421 -51
  31. package/dist/index.mjs.map +1 -1
  32. package/package.json +16 -8
  33. package/src/audio-assets.d.ts +8 -0
  34. package/src/components/markdown/MarkdownMessage/CollapseToggle.tsx +3 -1
  35. package/src/components/markdown/MarkdownMessage/components.tsx +2 -5
  36. package/src/stories/index.ts +32 -2
  37. package/src/tools/Chat/README.md +347 -530
  38. package/src/tools/Chat/components/Attachments.tsx +6 -1
  39. package/src/tools/Chat/components/ChatRoot.tsx +30 -2
  40. package/src/tools/Chat/components/Composer.tsx +20 -3
  41. package/src/tools/Chat/components/ErrorBanner.tsx +7 -3
  42. package/src/tools/Chat/components/MessageActions.tsx +3 -1
  43. package/src/tools/Chat/components/MessageBubble.tsx +6 -5
  44. package/src/tools/Chat/components/MessageList.tsx +87 -1
  45. package/src/tools/Chat/components/ToolCalls.tsx +21 -3
  46. package/src/tools/Chat/context/ChatProvider.tsx +21 -3
  47. package/src/tools/Chat/core/audio/audioBus.ts +10 -163
  48. package/src/tools/Chat/core/audio/defaults.ts +43 -0
  49. package/src/tools/Chat/core/audio/index.ts +1 -0
  50. package/src/tools/Chat/core/audio/preferences.ts +5 -59
  51. package/src/tools/Chat/core/audio/sounds/error.mp3 +0 -0
  52. package/src/tools/Chat/core/audio/sounds/mention.mp3 +0 -0
  53. package/src/tools/Chat/core/audio/sounds/notification.mp3 +0 -0
  54. package/src/tools/Chat/core/audio/sounds/received.mp3 +0 -0
  55. package/src/tools/Chat/core/audio/sounds/sent.mp3 +0 -0
  56. package/src/tools/Chat/core/audio/sounds/start.mp3 +0 -0
  57. package/src/tools/Chat/core/audio/types.ts +28 -0
  58. package/src/tools/Chat/core/reducer.ts +33 -0
  59. package/src/tools/Chat/core/transport/index.ts +13 -0
  60. package/src/tools/Chat/core/transport/mappers/index.ts +6 -0
  61. package/src/tools/Chat/core/transport/mappers/pydantic-ai.ts +142 -0
  62. package/src/tools/Chat/core/transport/pydantic-ai-transport.ts +208 -0
  63. package/src/tools/Chat/core/transport/sse.ts +18 -5
  64. package/src/tools/Chat/hooks/index.ts +25 -0
  65. package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +5 -3
  66. package/src/tools/Chat/hooks/useChat.ts +28 -0
  67. package/src/tools/Chat/hooks/useChatAudio.ts +59 -180
  68. package/src/tools/Chat/hooks/useChatDockPrefs.ts +74 -0
  69. package/src/tools/Chat/hooks/useChatReset.ts +70 -0
  70. package/src/tools/Chat/hooks/useChatUnread.ts +87 -0
  71. package/src/tools/Chat/hooks/useFocusOnEmptyClick.ts +111 -0
  72. package/src/tools/Chat/hooks/useVisitorFingerprint.ts +48 -0
  73. package/src/tools/Chat/index.ts +69 -1
  74. package/src/tools/Chat/launcher/ChatDock.tsx +263 -0
  75. package/src/tools/Chat/launcher/ChatFAB.tsx +349 -0
  76. package/src/tools/Chat/launcher/ChatGreeting.tsx +200 -0
  77. package/src/tools/Chat/launcher/ChatHeader.tsx +76 -0
  78. package/src/tools/Chat/launcher/ChatHeaderActionButton.tsx +87 -0
  79. package/src/tools/Chat/launcher/ChatHeaderAudioToggle.tsx +47 -0
  80. package/src/tools/Chat/launcher/ChatHeaderLanguageButton.tsx +179 -0
  81. package/src/tools/Chat/launcher/ChatHeaderModeToggle.tsx +57 -0
  82. package/src/tools/Chat/launcher/ChatHeaderResetButton.tsx +93 -0
  83. package/src/tools/Chat/launcher/ChatLauncher.tsx +321 -0
  84. package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +197 -0
  85. package/src/tools/Chat/launcher/index.ts +46 -0
  86. package/src/tools/Chat/launcher/useChatPresence.ts +44 -0
  87. package/src/tools/Chat/stories/01-basic.story.tsx +64 -0
  88. package/src/tools/Chat/stories/02-bubbles.story.tsx +21 -0
  89. package/src/tools/Chat/stories/03-tool-calls.story.tsx +59 -0
  90. package/src/tools/Chat/stories/04-personas.story.tsx +78 -0
  91. package/src/tools/Chat/stories/05-launcher.story.tsx +321 -0
  92. package/src/tools/Chat/stories/06-header.story.tsx +147 -0
  93. package/src/tools/Chat/stories/07-audio-actions.story.tsx +112 -0
  94. package/src/tools/Chat/stories/shared/Frame.tsx +21 -0
  95. package/src/tools/Chat/stories/shared/index.ts +5 -0
  96. package/src/tools/Chat/stories/shared/messages.ts +39 -0
  97. package/src/tools/Chat/stories/shared/personas.ts +13 -0
  98. package/src/tools/Chat/stories/shared/seeds.ts +92 -0
  99. package/src/tools/Chat/stories/shared/transports.ts +36 -0
  100. package/src/tools/Chat/styles/bubbleTokens.ts +71 -0
  101. package/src/tools/Chat/styles/index.ts +16 -0
  102. package/src/tools/Chat/styles/useChatStyles.ts +101 -0
  103. package/src/tools/Chat/types/attachment.ts +25 -0
  104. package/src/tools/Chat/types/config.ts +48 -0
  105. package/src/tools/Chat/types/events.ts +35 -0
  106. package/src/tools/Chat/types/index.ts +34 -0
  107. package/src/tools/Chat/types/labels.ts +38 -0
  108. package/src/tools/Chat/types/message.ts +32 -0
  109. package/src/tools/Chat/types/persona.ts +31 -0
  110. package/src/tools/Chat/types/session.ts +43 -0
  111. package/src/tools/Chat/types/tool-call.ts +17 -0
  112. package/src/tools/Chat/types/transport.ts +28 -0
  113. package/src/tools/Chat/types.ts +5 -240
  114. package/src/tools/MarkdownEditor/MarkdownEditor.tsx +50 -14
  115. package/src/tools/MarkdownEditor/index.ts +1 -1
  116. package/src/tools/SpeechRecognition/README.md +336 -0
  117. package/src/tools/SpeechRecognition/__tests__/ids.test.ts +15 -0
  118. package/src/tools/SpeechRecognition/__tests__/language.test.ts +59 -0
  119. package/src/tools/SpeechRecognition/__tests__/reducer.test.ts +71 -0
  120. package/src/tools/SpeechRecognition/__tests__/transcript.test.ts +52 -0
  121. package/src/tools/SpeechRecognition/components/DevicePicker.tsx +49 -0
  122. package/src/tools/SpeechRecognition/components/DictationButton.tsx +93 -0
  123. package/src/tools/SpeechRecognition/components/EngineBadge.tsx +30 -0
  124. package/src/tools/SpeechRecognition/components/ErrorBanner.tsx +52 -0
  125. package/src/tools/SpeechRecognition/components/LanguagePicker.tsx +63 -0
  126. package/src/tools/SpeechRecognition/components/MicMeter.tsx +63 -0
  127. package/src/tools/SpeechRecognition/components/PushToTalkHint.tsx +51 -0
  128. package/src/tools/SpeechRecognition/components/TranscriptView.tsx +55 -0
  129. package/src/tools/SpeechRecognition/components/index.ts +16 -0
  130. package/src/tools/SpeechRecognition/context/SpeechRecognitionProvider.tsx +47 -0
  131. package/src/tools/SpeechRecognition/context/index.ts +6 -0
  132. package/src/tools/SpeechRecognition/core/audio/defaults.ts +24 -0
  133. package/src/tools/SpeechRecognition/core/engine/external.ts +222 -0
  134. package/src/tools/SpeechRecognition/core/engine/http.ts +147 -0
  135. package/src/tools/SpeechRecognition/core/engine/index.ts +52 -0
  136. package/src/tools/SpeechRecognition/core/engine/mediarecorder.ts +105 -0
  137. package/src/tools/SpeechRecognition/core/engine/websocket.ts +211 -0
  138. package/src/tools/SpeechRecognition/core/engine/webspeech.ts +188 -0
  139. package/src/tools/SpeechRecognition/core/ids.ts +11 -0
  140. package/src/tools/SpeechRecognition/core/index.ts +14 -0
  141. package/src/tools/SpeechRecognition/core/language.ts +78 -0
  142. package/src/tools/SpeechRecognition/core/languages-catalog.ts +229 -0
  143. package/src/tools/SpeechRecognition/core/logger.ts +3 -0
  144. package/src/tools/SpeechRecognition/core/reducer.ts +105 -0
  145. package/src/tools/SpeechRecognition/core/transcript.ts +36 -0
  146. package/src/tools/SpeechRecognition/hooks/index.ts +14 -0
  147. package/src/tools/SpeechRecognition/hooks/useDictation.ts +59 -0
  148. package/src/tools/SpeechRecognition/hooks/useEnginePrefs.ts +15 -0
  149. package/src/tools/SpeechRecognition/hooks/useMicDevices.ts +57 -0
  150. package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +52 -0
  151. package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +85 -0
  152. package/src/tools/SpeechRecognition/hooks/useResolvedLanguage.ts +28 -0
  153. package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +108 -0
  154. package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +188 -0
  155. package/src/tools/SpeechRecognition/hooks/useVoiceSupport.ts +78 -0
  156. package/src/tools/SpeechRecognition/index.ts +82 -0
  157. package/src/tools/SpeechRecognition/lazy.tsx +19 -0
  158. package/src/tools/SpeechRecognition/store/index.ts +2 -0
  159. package/src/tools/SpeechRecognition/store/prefsStore.ts +54 -0
  160. package/src/tools/SpeechRecognition/stories/01-basic.story.tsx +32 -0
  161. package/src/tools/SpeechRecognition/stories/02-dictation-field.story.tsx +32 -0
  162. package/src/tools/SpeechRecognition/stories/03-push-to-talk.story.tsx +27 -0
  163. package/src/tools/SpeechRecognition/stories/04-mic-meter.story.tsx +35 -0
  164. package/src/tools/SpeechRecognition/stories/05-custom-engine-http.story.tsx +40 -0
  165. package/src/tools/SpeechRecognition/stories/06-custom-engine-ws.story.tsx +48 -0
  166. package/src/tools/SpeechRecognition/stories/07-language-device.story.tsx +57 -0
  167. package/src/tools/SpeechRecognition/stories/08-errors-permissions.story.tsx +25 -0
  168. package/src/tools/SpeechRecognition/stories/09-chat-voice.story.tsx +90 -0
  169. package/src/tools/SpeechRecognition/stories/shared.tsx +123 -0
  170. package/src/tools/SpeechRecognition/types.ts +133 -0
  171. package/src/tools/SpeechRecognition/widgets/DictationField.tsx +105 -0
  172. package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +305 -0
  173. package/src/tools/SpeechRecognition/widgets/VoiceMessageRecorder.tsx +88 -0
  174. package/src/tools/SpeechRecognition/widgets/index.ts +6 -0
  175. package/dist/ChatRoot-EJC5Y2YM.cjs +0 -14
  176. package/dist/ChatRoot-QOSKJPM6.mjs +0 -5
  177. package/dist/chunk-NWUT327A.mjs.map +0 -1
  178. package/dist/chunk-QLMKCSR6.mjs +0 -2420
  179. package/dist/chunk-QLMKCSR6.mjs.map +0 -1
  180. package/dist/chunk-SI5RD2GD.cjs +0 -2460
  181. package/dist/chunk-SI5RD2GD.cjs.map +0 -1
  182. package/dist/chunk-XACCHZH2.cjs.map +0 -1
  183. package/src/tools/Chat/Chat.story.tsx +0 -1457
@@ -0,0 +1,52 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ /**
6
+ * RMS level meter driven by an `AnalyserNode`. Attach a `MediaStream`
7
+ * (the one returned from `startMicCapture`) and read `level` (0..1) for
8
+ * VU meters / mic-pulse animations. Returns 0 when no stream is bound.
9
+ */
10
+ export function useMicLevel(stream: MediaStream | null): number {
11
+ const [level, setLevel] = useState(0);
12
+ const raf = useRef<number | null>(null);
13
+
14
+ useEffect(() => {
15
+ if (!stream) {
16
+ setLevel(0);
17
+ return undefined;
18
+ }
19
+ const AC =
20
+ (window as unknown as { AudioContext?: typeof AudioContext }).AudioContext ??
21
+ (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
22
+ if (!AC) return undefined;
23
+ const ctx = new AC();
24
+ const source = ctx.createMediaStreamSource(stream);
25
+ const analyser = ctx.createAnalyser();
26
+ analyser.fftSize = 1024;
27
+ analyser.smoothingTimeConstant = 0.7;
28
+ source.connect(analyser);
29
+ const buf = new Float32Array(analyser.fftSize);
30
+
31
+ const tick = (): void => {
32
+ analyser.getFloatTimeDomainData(buf);
33
+ let sum = 0;
34
+ for (let i = 0; i < buf.length; i += 1) sum += buf[i] * buf[i];
35
+ const rms = Math.sqrt(sum / buf.length);
36
+ // soft compression so loud peaks don't dominate the meter
37
+ setLevel(Math.min(1, rms * 2.5));
38
+ raf.current = requestAnimationFrame(tick);
39
+ };
40
+ tick();
41
+
42
+ return () => {
43
+ if (raf.current != null) cancelAnimationFrame(raf.current);
44
+ raf.current = null;
45
+ source.disconnect();
46
+ analyser.disconnect();
47
+ void ctx.close();
48
+ };
49
+ }, [stream]);
50
+
51
+ return level;
52
+ }
@@ -0,0 +1,85 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+
5
+ import type { UseSpeechRecognitionReturn } from '../types';
6
+
7
+ export interface UsePushToTalkOptions {
8
+ /** Key to hold. Combine modifiers with `+`, e.g. `'alt'`, `'mod+alt'`. */
9
+ key: string;
10
+ /** Disable the binding without unmounting. */
11
+ enabled?: boolean;
12
+ }
13
+
14
+ const MOD_KEYS = new Set(['shift', 'ctrl', 'alt', 'meta', 'mod']);
15
+
16
+ function parseChord(chord: string): { mods: Set<string>; main: string | null } {
17
+ const parts = chord
18
+ .toLowerCase()
19
+ .split('+')
20
+ .map((s) => s.trim());
21
+ const mods = new Set<string>();
22
+ let main: string | null = null;
23
+ for (const part of parts) {
24
+ if (MOD_KEYS.has(part)) {
25
+ mods.add(part === 'mod' ? 'meta' : part);
26
+ } else {
27
+ main = part;
28
+ }
29
+ }
30
+ return { mods, main };
31
+ }
32
+
33
+ function matches(e: KeyboardEvent, mods: Set<string>, main: string | null): boolean {
34
+ if (mods.has('shift') !== e.shiftKey) return false;
35
+ if (mods.has('ctrl') !== e.ctrlKey) return false;
36
+ if (mods.has('alt') !== e.altKey) return false;
37
+ // 'mod' → meta on mac / ctrl elsewhere; we already normalised to 'meta'.
38
+ if (mods.has('meta') !== (e.metaKey || (!e.metaKey && false))) return false;
39
+ if (main && e.key.toLowerCase() !== main) return false;
40
+ return true;
41
+ }
42
+
43
+ /**
44
+ * Hold-to-talk wiring. Press → `start()`, release → `stop()`. Ignores
45
+ * repeats and skips keydown inside `<input>` / `<textarea>` unless a
46
+ * modifier is in the chord.
47
+ */
48
+ export function usePushToTalk(
49
+ recognition: Pick<UseSpeechRecognitionReturn, 'start' | 'stop' | 'status'>,
50
+ opts: UsePushToTalkOptions,
51
+ ): void {
52
+ const { key, enabled = true } = opts;
53
+
54
+ useEffect(() => {
55
+ if (!enabled || typeof window === 'undefined') return undefined;
56
+ const { mods, main } = parseChord(key);
57
+
58
+ const onDown = (e: KeyboardEvent): void => {
59
+ if (e.repeat) return;
60
+ if (!main && mods.size === 0) return;
61
+ const target = e.target as HTMLElement | null;
62
+ const inField =
63
+ target?.tagName === 'INPUT' ||
64
+ target?.tagName === 'TEXTAREA' ||
65
+ target?.isContentEditable;
66
+ if (inField && mods.size === 0) return;
67
+ if (!matches(e, mods, main)) return;
68
+ if (recognition.status === 'listening' || recognition.status === 'starting') return;
69
+ e.preventDefault();
70
+ void recognition.start();
71
+ };
72
+ const onUp = (e: KeyboardEvent): void => {
73
+ if (!matches(e, mods, main) && main && e.key.toLowerCase() !== main) return;
74
+ if (recognition.status !== 'listening' && recognition.status !== 'starting') return;
75
+ void recognition.stop();
76
+ };
77
+
78
+ window.addEventListener('keydown', onDown);
79
+ window.addEventListener('keyup', onUp);
80
+ return () => {
81
+ window.removeEventListener('keydown', onDown);
82
+ window.removeEventListener('keyup', onUp);
83
+ };
84
+ }, [enabled, key, recognition]);
85
+ }
@@ -0,0 +1,28 @@
1
+ 'use client';
2
+
3
+ import { useLocaleOptional } from '@djangocfg/i18n';
4
+
5
+ import { resolveSpeechLanguage } from '../core/language';
6
+ import { useSpeechPrefs } from '../store/prefsStore';
7
+
8
+ /**
9
+ * Resolves the BCP-47 language tag a speech session should use.
10
+ *
11
+ * Priority: explicit prop → user-picked `useSpeechPrefs.language` →
12
+ * app i18n locale (when an `<I18nProvider>` is mounted) →
13
+ * `navigator.language` → `en-US`.
14
+ *
15
+ * Uses `useLocaleOptional` (not `useLocale`) so that an unmounted
16
+ * provider doesn't silently inject `'en'`. Without that, the i18n
17
+ * default would shadow the user's real browser language — a Russian
18
+ * speaker would always get `en-US` recognition.
19
+ */
20
+ export function useResolvedLanguage(explicit?: string): string {
21
+ const prefs = useSpeechPrefs();
22
+ const locale = useLocaleOptional();
23
+ return resolveSpeechLanguage({
24
+ explicit,
25
+ prefs: prefs.language,
26
+ i18n: locale,
27
+ });
28
+ }
@@ -0,0 +1,108 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+
5
+ import {
6
+ countryFromTag,
7
+ findSpeechLanguage,
8
+ } from '../core/languages-catalog';
9
+ import { useSpeechPrefs } from '../store/prefsStore';
10
+ import { useResolvedLanguage } from './useResolvedLanguage';
11
+
12
+ export interface SpeechLanguageInfo {
13
+ /**
14
+ * BCP-47 tag that `useSpeechRecognition` will actually pass to the
15
+ * engine right now. Always non-null (falls through `prefs → i18n →
16
+ * navigator → 'en-US'`).
17
+ */
18
+ tag: string;
19
+ /**
20
+ * ISO-639 primary subtag of `tag` (`ru`, `en`, `cmn`). Useful as a
21
+ * map key when the host wires its own per-language behaviour
22
+ * (avatars, copy variants, etc.).
23
+ */
24
+ iso: string;
25
+ /**
26
+ * ISO-3166 alpha-2 country code extracted from `tag` (`RU`, `US`,
27
+ * `CN`). `null` when the tag has no region subtag — rare for our
28
+ * catalogue (every entry ships at least one regional dialect) but
29
+ * possible for custom-engine tags supplied by hosts.
30
+ */
31
+ country: string | null;
32
+ /**
33
+ * Native-script language name (`'Русский'`, `'中文'`). `null` for
34
+ * tags outside our catalogue.
35
+ */
36
+ name: string | null;
37
+ /**
38
+ * Lowercase English name (`'russian'`, `'chinese'`). `null` for
39
+ * tags outside our catalogue. Handy for analytics or English-only
40
+ * UI surfaces.
41
+ */
42
+ englishName: string | null;
43
+ /**
44
+ * Region label for the current dialect (`'Russia'`, `'United
45
+ * Kingdom'`, `'香港'`). `null` for unknown tags.
46
+ */
47
+ region: string | null;
48
+ /**
49
+ * `true` iff the user explicitly picked a language via
50
+ * `<ChatHeaderLanguageButton>` / `useSpeechPrefs.setLanguage(...)`.
51
+ * `false` means the resolved tag came from a lower-priority source
52
+ * (i18n locale, `navigator.language`, default).
53
+ *
54
+ * Use this when you want to distinguish "user told us to use ru-RU"
55
+ * from "we guessed ru-RU from browser headers". Analytics often
56
+ * cares about that distinction.
57
+ */
58
+ hasUserChoice: boolean;
59
+ }
60
+
61
+ /**
62
+ * One-shot read of "what speech language is active right now and why".
63
+ *
64
+ * Pulls together `useResolvedLanguage`, `useSpeechPrefs.language`, and
65
+ * the static catalogue (`findSpeechLanguage` / `countryFromTag`) into
66
+ * a single memoised object so consumers don't have to compose them by
67
+ * hand for every header badge / analytics call / persisted-state push.
68
+ *
69
+ * Reactive: re-renders when the user picks a different language in
70
+ * the chat header, when i18n locale changes, or when the underlying
71
+ * `navigator.language` reading flips (mount-time only).
72
+ *
73
+ * @example Render a status badge somewhere in the app shell
74
+ * ```tsx
75
+ * const { tag, name, country } = useSpeechLanguageInfo();
76
+ * return (
77
+ * <Badge>
78
+ * <Flag countryCode={country} />
79
+ * {name ?? tag}
80
+ * </Badge>
81
+ * );
82
+ * ```
83
+ *
84
+ * @example Sync the user's STT pick to a backend setting
85
+ * ```tsx
86
+ * const { tag, hasUserChoice } = useSpeechLanguageInfo();
87
+ * useEffect(() => {
88
+ * if (!hasUserChoice) return; // skip auto-resolved values
89
+ * void api.user.update({ speechLanguage: tag });
90
+ * }, [tag, hasUserChoice]);
91
+ * ```
92
+ */
93
+ export function useSpeechLanguageInfo(): SpeechLanguageInfo {
94
+ const prefs = useSpeechPrefs();
95
+ const tag = useResolvedLanguage();
96
+ return useMemo<SpeechLanguageInfo>(() => {
97
+ const found = findSpeechLanguage(tag);
98
+ return {
99
+ tag,
100
+ iso: found?.language.iso ?? tag.split('-')[0].toLowerCase(),
101
+ country: countryFromTag(tag),
102
+ name: found?.language.name ?? null,
103
+ englishName: found?.language.englishName ?? null,
104
+ region: found?.dialect.region ?? null,
105
+ hasUserChoice: prefs.language !== null,
106
+ };
107
+ }, [tag, prefs.language]);
108
+ }
@@ -0,0 +1,188 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
4
+
5
+ import {
6
+ EMPTY_TRANSCRIPT,
7
+ INITIAL_STATE,
8
+ buildTranscript,
9
+ reducer,
10
+ sttLogger,
11
+ } from '../core';
12
+ import { createWebSpeechEngine } from '../core/engine/webspeech';
13
+ import { useSpeechPrefs } from '../store/prefsStore';
14
+ import type {
15
+ RecognitionEngine,
16
+ Segment,
17
+ UseSpeechRecognitionConfig,
18
+ UseSpeechRecognitionReturn,
19
+ } from '../types';
20
+ import { useMicLevel } from './useMicLevel';
21
+ import { useResolvedLanguage } from './useResolvedLanguage';
22
+
23
+ /**
24
+ * Main entry point. With no config it uses the browser Web Speech API
25
+ * and the persisted language from `useSpeechPrefs`. Pass a custom
26
+ * `engine` to route through Deepgram / Whisper / custom WebSocket.
27
+ */
28
+ export function useSpeechRecognition(
29
+ config: UseSpeechRecognitionConfig = {},
30
+ ): UseSpeechRecognitionReturn {
31
+ const prefs = useSpeechPrefs();
32
+ const language = useResolvedLanguage(config.language);
33
+ const engine = useMemo<RecognitionEngine>(
34
+ () => config.engine ?? createWebSpeechEngine(),
35
+ [config.engine],
36
+ );
37
+
38
+ const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
39
+ const [stream, setStream] = useState<MediaStream | null>(null);
40
+ const level = useMicLevel(stream);
41
+
42
+ // Latest-callback refs so engine subscriptions never tear down on
43
+ // every render — same trick the Chat reducer uses.
44
+ const cbRef = useRef(config);
45
+ cbRef.current = config;
46
+
47
+ // Engine subscription lifecycle.
48
+ useEffect(() => {
49
+ const offs = [
50
+ engine.on('partial', (text, segmentId) => {
51
+ dispatch({ type: 'PARTIAL', text, segmentId });
52
+ const seg: Segment = {
53
+ id: segmentId,
54
+ text,
55
+ isFinal: false,
56
+ startedAt: Date.now(),
57
+ };
58
+ cbRef.current.onPartial?.(text, seg);
59
+ }),
60
+ engine.on('final', (text, segmentId, confidence) => {
61
+ dispatch({ type: 'FINAL', text, segmentId, confidence });
62
+ const seg: Segment = {
63
+ id: segmentId,
64
+ text,
65
+ isFinal: true,
66
+ confidence,
67
+ startedAt: Date.now(),
68
+ endedAt: Date.now(),
69
+ };
70
+ cbRef.current.onFinal?.(text, seg);
71
+ }),
72
+ engine.on('error', (err) => {
73
+ dispatch({ type: 'ERROR', error: err });
74
+ cbRef.current.onError?.(err);
75
+ }),
76
+ engine.on('state', (s) => {
77
+ if (s === 'listening') {
78
+ dispatch({ type: 'STARTED' });
79
+ cbRef.current.onStart?.();
80
+ setStream(engine.getStream?.() ?? null);
81
+ } else if (s === 'closed') {
82
+ dispatch({ type: 'STOPPED' });
83
+ cbRef.current.onStop?.();
84
+ setStream(null);
85
+ }
86
+ }),
87
+ ];
88
+ return () => {
89
+ offs.forEach((off) => off());
90
+ };
91
+ }, [engine]);
92
+
93
+ // AutoStop driven by silence + maxMs caps.
94
+ const silenceTimer = useRef<number | null>(null);
95
+ const maxTimer = useRef<number | null>(null);
96
+ useEffect(() => {
97
+ if (state.status !== 'listening') return undefined;
98
+ const { silenceMs, maxMs, silenceThreshold = 0.02 } = config.autoStop ?? {};
99
+ if (maxMs) {
100
+ maxTimer.current = window.setTimeout(() => {
101
+ sttLogger.debug('[autoStop] max duration hit');
102
+ void engine.stop();
103
+ }, maxMs);
104
+ }
105
+ if (silenceMs) {
106
+ const checkInterval = window.setInterval(() => {
107
+ if (level < silenceThreshold) {
108
+ if (silenceTimer.current == null) {
109
+ silenceTimer.current = window.setTimeout(() => {
110
+ sttLogger.debug('[autoStop] silence detected');
111
+ void engine.stop();
112
+ }, silenceMs);
113
+ }
114
+ } else if (silenceTimer.current != null) {
115
+ clearTimeout(silenceTimer.current);
116
+ silenceTimer.current = null;
117
+ }
118
+ }, 200);
119
+ return () => {
120
+ clearInterval(checkInterval);
121
+ if (silenceTimer.current != null) clearTimeout(silenceTimer.current);
122
+ silenceTimer.current = null;
123
+ if (maxTimer.current != null) clearTimeout(maxTimer.current);
124
+ maxTimer.current = null;
125
+ };
126
+ }
127
+ return () => {
128
+ if (maxTimer.current != null) clearTimeout(maxTimer.current);
129
+ maxTimer.current = null;
130
+ };
131
+ }, [state.status, config.autoStop, level, engine]);
132
+
133
+ const start = useCallback(async () => {
134
+ if (state.status === 'listening' || state.status === 'starting') return;
135
+ dispatch({ type: 'START' });
136
+ try {
137
+ await engine.start({
138
+ language,
139
+ interim: config.interim ?? true,
140
+ deviceId: config.deviceId ?? prefs.deviceId ?? undefined,
141
+ });
142
+ } catch (cause) {
143
+ // engine already emitted 'error'; reducer caught it via subscription
144
+ sttLogger.debug('[start] engine threw', cause);
145
+ }
146
+ }, [engine, language, config.interim, config.deviceId, prefs.deviceId, state.status]);
147
+
148
+ const stop = useCallback(async () => {
149
+ if (state.status === 'idle' || state.status === 'stopping') return;
150
+ dispatch({ type: 'STOP' });
151
+ await engine.stop();
152
+ }, [engine, state.status]);
153
+
154
+ const abort = useCallback(() => {
155
+ engine.abort();
156
+ dispatch({ type: 'ABORT' });
157
+ }, [engine]);
158
+
159
+ const toggle = useCallback(async () => {
160
+ if (state.status === 'listening' || state.status === 'starting') {
161
+ await stop();
162
+ } else {
163
+ await start();
164
+ }
165
+ }, [state.status, start, stop]);
166
+
167
+ const reset = useCallback(() => {
168
+ dispatch({ type: 'RESET' });
169
+ }, []);
170
+
171
+ const transcript = useMemo(
172
+ () => (state.segments.length === 0 ? EMPTY_TRANSCRIPT : buildTranscript(state.segments)),
173
+ [state.segments],
174
+ );
175
+
176
+ return {
177
+ status: state.status,
178
+ isSupported: engine.isSupported,
179
+ transcript,
180
+ error: state.error,
181
+ level,
182
+ start,
183
+ stop,
184
+ abort,
185
+ toggle,
186
+ reset,
187
+ };
188
+ }
@@ -0,0 +1,78 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+
5
+ import { useBrowserDetect, useDeviceDetect } from '@djangocfg/ui-core/hooks';
6
+
7
+ import type { RecognitionEngine } from '../types';
8
+ import { createWebSpeechEngine } from '../core/engine/webspeech';
9
+
10
+ export type VoiceUnsupportedReason =
11
+ | 'no-engine'
12
+ | 'no-mediadevices'
13
+ | 'in-app-browser'
14
+ | 'unknown';
15
+
16
+ export interface VoiceSupport {
17
+ /** Should the host render the voice button at all? */
18
+ supported: boolean;
19
+ /** Why is it off? `null` when supported. */
20
+ reason: VoiceUnsupportedReason | null;
21
+ /** Engine id that would be used if rendered (for badges / telemetry). */
22
+ engineId: string | null;
23
+ /** True on a mobile UA — useful for hiding on phones if the host wants. */
24
+ isMobile: boolean;
25
+ /** True inside Facebook / Instagram / TikTok / etc. in-app browsers. */
26
+ isInApp: boolean;
27
+ }
28
+
29
+ /**
30
+ * Decides whether the `<VoiceComposerSlot>` should render the mic
31
+ * button. Three gates:
32
+ *
33
+ * 1. A `RecognitionEngine` reports `isSupported === true`. With no
34
+ * custom engine passed we probe `createWebSpeechEngine()` —
35
+ * Chrome / Edge / Safari pass, Firefox does not.
36
+ * 2. `navigator.mediaDevices.getUserMedia` exists. Required even when
37
+ * using Web Speech (browser still asks the user for mic access).
38
+ * 3. The host browser is not a known in-app WebView (Instagram,
39
+ * Facebook, TikTok, …) — mic permission flows are broken in most
40
+ * of them. The host can override this gate by passing a custom
41
+ * engine that already handles the case.
42
+ */
43
+ export function useVoiceSupport(engine?: RecognitionEngine): VoiceSupport {
44
+ const browser = useBrowserDetect();
45
+ const device = useDeviceDetect();
46
+
47
+ return useMemo<VoiceSupport>(() => {
48
+ const isMobile = device.selectors.isMobile;
49
+ const isInApp = browser.isInAppBrowser;
50
+
51
+ const hasMediaDevices =
52
+ typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
53
+ if (!hasMediaDevices) {
54
+ return { supported: false, reason: 'no-mediadevices', engineId: null, isMobile, isInApp };
55
+ }
56
+
57
+ // If the host didn't pass a custom engine we can only fall back to
58
+ // the browser Web Speech API — bail when it's not available
59
+ // (Firefox / some WebViews).
60
+ const probe = engine ?? createWebSpeechEngine();
61
+ if (!probe.isSupported) {
62
+ // In-app browsers are the most common reason a probe fails on
63
+ // mobile; surface a friendlier code so the UI can hide silently.
64
+ if (isInApp) {
65
+ return { supported: false, reason: 'in-app-browser', engineId: null, isMobile, isInApp };
66
+ }
67
+ return { supported: false, reason: 'no-engine', engineId: null, isMobile, isInApp };
68
+ }
69
+
70
+ return {
71
+ supported: true,
72
+ reason: null,
73
+ engineId: probe.id,
74
+ isMobile,
75
+ isInApp,
76
+ };
77
+ }, [engine, browser.isInAppBrowser, device.selectors.isMobile]);
78
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @djangocfg/ui-tools/speech-recognition
3
+ *
4
+ * Decomposed Speech-to-Text tool. Default backend is the browser Web
5
+ * Speech API; custom engines can be plugged in for cloud or self-hosted
6
+ * STT (Deepgram, Whisper, AssemblyAI, custom WebSocket).
7
+ */
8
+
9
+ 'use client';
10
+
11
+ export type {
12
+ AutoStopOptions,
13
+ EngineEventMap,
14
+ EngineStartOptions,
15
+ EngineState,
16
+ RecognitionEngine,
17
+ RecognitionError,
18
+ RecognitionErrorCode,
19
+ RecognitionStatus,
20
+ Segment,
21
+ Transcript,
22
+ Unsub,
23
+ UseSpeechRecognitionConfig,
24
+ UseSpeechRecognitionReturn,
25
+ } from './types';
26
+
27
+ export {
28
+ EMPTY_TRANSCRIPT,
29
+ buildTranscript,
30
+ joinFinal,
31
+ newSegmentId,
32
+ normaliseFinal,
33
+ } from './core';
34
+
35
+ export { createEngineBus } from './core/engine';
36
+ export { createWebSpeechEngine } from './core/engine/webspeech';
37
+ export type { WebSpeechEngineOptions } from './core/engine/webspeech';
38
+ export { createHttpEngine } from './core/engine/http';
39
+ export type {
40
+ HttpEngineOptions,
41
+ HttpEngineParseResult,
42
+ } from './core/engine/http';
43
+ export { createWebSocketEngine } from './core/engine/websocket';
44
+ export type {
45
+ WebSocketEngineOptions,
46
+ WsParsedEvent,
47
+ } from './core/engine/websocket';
48
+ export { createExternalEngine } from './core/engine/external';
49
+ export type {
50
+ ExternalEngineHandle,
51
+ ExternalEngineOptions,
52
+ } from './core/engine/external';
53
+ export { pickMime, startMicCapture } from './core/engine/mediarecorder';
54
+ export type {
55
+ MicCaptureHandle,
56
+ MicCaptureOptions,
57
+ } from './core/engine/mediarecorder';
58
+
59
+ export * from './hooks';
60
+ export * from './components';
61
+ export * from './widgets';
62
+ export * from './context';
63
+ export { LazyDictationField } from './lazy';
64
+ export { useSpeechPrefs } from './store';
65
+ export type { SpeechPrefs } from './store';
66
+ export { DEFAULT_VOICE_SOUNDS } from './core/audio/defaults';
67
+ export type { VoiceSoundEvent } from './core/audio/defaults';
68
+ export {
69
+ DEFAULT_ISO_TO_BCP47,
70
+ resolveSpeechLanguage,
71
+ toBCP47,
72
+ } from './core/language';
73
+ export {
74
+ WEB_SPEECH_LANGUAGES,
75
+ WEB_SPEECH_TAGS,
76
+ findSpeechLanguage,
77
+ countryFromTag,
78
+ } from './core/languages-catalog';
79
+ export type {
80
+ SpeechLanguage,
81
+ SpeechLanguageDialect,
82
+ } from './core/languages-catalog';
@@ -0,0 +1,19 @@
1
+ 'use client';
2
+
3
+ import { createLazyComponent } from '../../components';
4
+ import type { DictationFieldProps } from './widgets/DictationField';
5
+
6
+ export const LazyDictationField = createLazyComponent<DictationFieldProps>(
7
+ () =>
8
+ import('./widgets/DictationField').then((mod) => ({
9
+ default: mod.DictationField,
10
+ })),
11
+ {
12
+ displayName: 'LazyDictationField',
13
+ fallback: (
14
+ <div className="rounded-lg border border-border/60 bg-card px-3 py-2 text-xs text-muted-foreground">
15
+ Loading dictation…
16
+ </div>
17
+ ),
18
+ },
19
+ );
@@ -0,0 +1,2 @@
1
+ export { useSpeechPrefs } from './prefsStore';
2
+ export type { SpeechPrefs } from './prefsStore';