@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,11 @@
1
+ let counter = 0;
2
+
3
+ /**
4
+ * Cheap monotonic id — collisions are fine across sessions, we just need
5
+ * uniqueness within one component lifecycle. Avoids pulling in nanoid for
6
+ * a tool that already keeps the lazy chunk small.
7
+ */
8
+ export function newSegmentId(): string {
9
+ counter = (counter + 1) % Number.MAX_SAFE_INTEGER;
10
+ return `seg_${Date.now().toString(36)}_${counter.toString(36)}`;
11
+ }
@@ -0,0 +1,14 @@
1
+ export { newSegmentId } from './ids';
2
+ export { sttLogger } from './logger';
3
+ export {
4
+ EMPTY_TRANSCRIPT,
5
+ buildTranscript,
6
+ joinFinal,
7
+ normaliseFinal,
8
+ } from './transcript';
9
+ export {
10
+ INITIAL_STATE,
11
+ reducer,
12
+ type RecognitionAction,
13
+ type RecognitionState,
14
+ } from './reducer';
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Maps 2-letter ISO 639-1 codes (`en`, `ru`, `ko` — what
3
+ * `@djangocfg/i18n` exposes via `useLocale()`) to BCP-47 tags
4
+ * (`en-US`, `ru-RU`, `ko-KR`) that the Web Speech API and most cloud
5
+ * STT services expect.
6
+ *
7
+ * We keep a small built-in table for the locales we ship translations
8
+ * for; everything else falls through to `<code>-<UPPER(code)>`, which
9
+ * works for the majority of regions. The mapping is also re-exported
10
+ * so consumers can extend it.
11
+ */
12
+
13
+ const ISO_TO_BCP47: Record<string, string> = {
14
+ en: 'en-US',
15
+ ru: 'ru-RU',
16
+ ko: 'ko-KR',
17
+ ja: 'ja-JP',
18
+ zh: 'zh-CN',
19
+ de: 'de-DE',
20
+ fr: 'fr-FR',
21
+ it: 'it-IT',
22
+ es: 'es-ES',
23
+ nl: 'nl-NL',
24
+ ar: 'ar-SA',
25
+ tr: 'tr-TR',
26
+ pl: 'pl-PL',
27
+ sv: 'sv-SE',
28
+ no: 'nb-NO',
29
+ da: 'da-DK',
30
+ pt: 'pt-BR',
31
+ };
32
+
33
+ export const DEFAULT_ISO_TO_BCP47 = ISO_TO_BCP47;
34
+
35
+ /**
36
+ * Normalise any of:
37
+ * - BCP-47 ("en-US", "ru-RU") — passed through.
38
+ * - ISO 639-1 ("en", "ru") — mapped via the table above, or
39
+ * falls back to `<code>-<UPPER(code)>`.
40
+ * - `null`/`undefined`/empty — returns `undefined`.
41
+ */
42
+ export function toBCP47(
43
+ code: string | null | undefined,
44
+ table: Record<string, string> = ISO_TO_BCP47,
45
+ ): string | undefined {
46
+ if (!code) return undefined;
47
+ const trimmed = code.trim();
48
+ if (!trimmed) return undefined;
49
+ if (trimmed.includes('-')) return trimmed; // already BCP-47
50
+ const lower = trimmed.toLowerCase();
51
+ return table[lower] ?? `${lower}-${lower.toUpperCase()}`;
52
+ }
53
+
54
+ /**
55
+ * Resolve the language tag for a speech session in priority order:
56
+ * 1. `explicit` prop (always wins) — host-supplied override.
57
+ * 2. `prefs` — value stored in `useSpeechPrefs` (user picked it
58
+ * via `<LanguagePicker>` or programmatically).
59
+ * 3. `i18n` — current i18n locale (2-letter ISO).
60
+ * 4. `navigator.language` — browser default.
61
+ * 5. `'en-US'` — last-resort safety net.
62
+ *
63
+ * All inputs may be ISO-2 or BCP-47; the function normalises before
64
+ * returning.
65
+ */
66
+ export function resolveSpeechLanguage(opts: {
67
+ explicit?: string;
68
+ prefs?: string | null;
69
+ i18n?: string | null;
70
+ }): string {
71
+ return (
72
+ toBCP47(opts.explicit) ??
73
+ toBCP47(opts.prefs) ??
74
+ toBCP47(opts.i18n) ??
75
+ toBCP47(typeof navigator !== 'undefined' ? navigator.language : null) ??
76
+ 'en-US'
77
+ );
78
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Canonical list of BCP-47 language tags that the browser Web Speech
3
+ * API is known to accept. Sourced from the official Google Chrome
4
+ * Speech API demo (`google.com/intl/en/chrome/demos/speech.html`),
5
+ * which is the de-facto reference — the spec itself doesn't expose a
6
+ * way to enumerate supported languages, so this list is the
7
+ * best-effort guarantee for what works in Chromium-based browsers.
8
+ *
9
+ * Each entry groups one human-readable language with its dialect
10
+ * variants. The default tag (first in `dialects`) is what `lang` is
11
+ * set to when the user picks the language without a regional dialect.
12
+ *
13
+ * For custom engines (cmdop wails-whisper, Deepgram, …) hosts can
14
+ * pass their own subset via the `availableLanguages` prop on the
15
+ * picker — backend may support more or fewer tags than the browser.
16
+ */
17
+
18
+ export interface SpeechLanguageDialect {
19
+ /** BCP-47 tag (e.g. `en-US`). */
20
+ code: string;
21
+ /** Region label in the language's native script (e.g. "United States"). */
22
+ region: string;
23
+ }
24
+
25
+ export interface SpeechLanguage {
26
+ /** Native-script name (e.g. "Русский", "中文"). */
27
+ name: string;
28
+ /**
29
+ * English name used as a secondary search key so users typing
30
+ * "russian" / "chinese" / "korean" land on the right row regardless
31
+ * of the native script. Always lowercase.
32
+ */
33
+ englishName: string;
34
+ /**
35
+ * Primary-subtag ISO-639 code (e.g. `en`, `ru`, `cmn`). Used as the
36
+ * map key into the `LanguageSelect` ui-core component.
37
+ */
38
+ iso: string;
39
+ /** One or more region dialects. Length >= 1. */
40
+ dialects: SpeechLanguageDialect[];
41
+ }
42
+
43
+ export const WEB_SPEECH_LANGUAGES: SpeechLanguage[] = [
44
+ { name: 'Afrikaans', iso: 'af', englishName: 'afrikaans', dialects: [{ code: 'af-ZA', region: 'South Africa' }] },
45
+ { name: 'አማርኛ', iso: 'am', englishName: 'amharic', dialects: [{ code: 'am-ET', region: 'Ethiopia' }] },
46
+ { name: 'Azərbaycanca', iso: 'az', englishName: 'azerbaijani', dialects: [{ code: 'az-AZ', region: 'Azerbaijan' }] },
47
+ {
48
+ name: 'বাংলা', iso: 'bn', englishName: 'bengali',
49
+ dialects: [
50
+ { code: 'bn-BD', region: 'Bangladesh' },
51
+ { code: 'bn-IN', region: 'India' },
52
+ ],
53
+ },
54
+ { name: 'Bahasa Indonesia', iso: 'id', englishName: 'indonesian', dialects: [{ code: 'id-ID', region: 'Indonesia' }] },
55
+ { name: 'Bahasa Melayu', iso: 'ms', englishName: 'malay', dialects: [{ code: 'ms-MY', region: 'Malaysia' }] },
56
+ { name: 'Català', iso: 'ca', englishName: 'catalan', dialects: [{ code: 'ca-ES', region: 'Spain' }] },
57
+ { name: 'Čeština', iso: 'cs', englishName: 'czech', dialects: [{ code: 'cs-CZ', region: 'Czechia' }] },
58
+ { name: 'Dansk', iso: 'da', englishName: 'danish', dialects: [{ code: 'da-DK', region: 'Denmark' }] },
59
+ { name: 'Deutsch', iso: 'de', englishName: 'german', dialects: [{ code: 'de-DE', region: 'Germany' }] },
60
+ {
61
+ name: 'English', iso: 'en', englishName: 'english',
62
+ dialects: [
63
+ { code: 'en-US', region: 'United States' },
64
+ { code: 'en-GB', region: 'United Kingdom' },
65
+ { code: 'en-AU', region: 'Australia' },
66
+ { code: 'en-CA', region: 'Canada' },
67
+ { code: 'en-IN', region: 'India' },
68
+ { code: 'en-NZ', region: 'New Zealand' },
69
+ { code: 'en-PH', region: 'Philippines' },
70
+ { code: 'en-ZA', region: 'South Africa' },
71
+ { code: 'en-NG', region: 'Nigeria' },
72
+ { code: 'en-GH', region: 'Ghana' },
73
+ { code: 'en-KE', region: 'Kenya' },
74
+ { code: 'en-TZ', region: 'Tanzania' },
75
+ ],
76
+ },
77
+ {
78
+ name: 'Español', iso: 'es', englishName: 'spanish',
79
+ dialects: [
80
+ { code: 'es-ES', region: 'España' },
81
+ { code: 'es-MX', region: 'México' },
82
+ { code: 'es-US', region: 'Estados Unidos' },
83
+ { code: 'es-AR', region: 'Argentina' },
84
+ { code: 'es-CL', region: 'Chile' },
85
+ { code: 'es-CO', region: 'Colombia' },
86
+ { code: 'es-PE', region: 'Perú' },
87
+ { code: 'es-VE', region: 'Venezuela' },
88
+ { code: 'es-EC', region: 'Ecuador' },
89
+ { code: 'es-GT', region: 'Guatemala' },
90
+ { code: 'es-CR', region: 'Costa Rica' },
91
+ { code: 'es-PA', region: 'Panamá' },
92
+ { code: 'es-DO', region: 'Rep. Dominicana' },
93
+ { code: 'es-UY', region: 'Uruguay' },
94
+ { code: 'es-PY', region: 'Paraguay' },
95
+ { code: 'es-BO', region: 'Bolivia' },
96
+ { code: 'es-SV', region: 'El Salvador' },
97
+ { code: 'es-HN', region: 'Honduras' },
98
+ { code: 'es-NI', region: 'Nicaragua' },
99
+ { code: 'es-PR', region: 'Puerto Rico' },
100
+ ],
101
+ },
102
+ { name: 'Euskara', iso: 'eu', englishName: 'basque', dialects: [{ code: 'eu-ES', region: 'Spain' }] },
103
+ { name: 'Filipino', iso: 'fil', englishName: 'filipino tagalog', dialects: [{ code: 'fil-PH', region: 'Philippines' }] },
104
+ { name: 'Français', iso: 'fr', englishName: 'french', dialects: [{ code: 'fr-FR', region: 'France' }] },
105
+ { name: 'Basa Jawa', iso: 'jv', englishName: 'javanese', dialects: [{ code: 'jv-ID', region: 'Indonesia' }] },
106
+ { name: 'Galego', iso: 'gl', englishName: 'galician', dialects: [{ code: 'gl-ES', region: 'Spain' }] },
107
+ { name: 'ગુજરાતી', iso: 'gu', englishName: 'gujarati', dialects: [{ code: 'gu-IN', region: 'India' }] },
108
+ { name: 'Hrvatski', iso: 'hr', englishName: 'croatian', dialects: [{ code: 'hr-HR', region: 'Croatia' }] },
109
+ { name: 'IsiZulu', iso: 'zu', englishName: 'zulu', dialects: [{ code: 'zu-ZA', region: 'South Africa' }] },
110
+ { name: 'Íslenska', iso: 'is', englishName: 'icelandic', dialects: [{ code: 'is-IS', region: 'Iceland' }] },
111
+ {
112
+ name: 'Italiano', iso: 'it', englishName: 'italian',
113
+ dialects: [
114
+ { code: 'it-IT', region: 'Italia' },
115
+ { code: 'it-CH', region: 'Svizzera' },
116
+ ],
117
+ },
118
+ { name: 'ಕನ್ನಡ', iso: 'kn', englishName: 'kannada', dialects: [{ code: 'kn-IN', region: 'India' }] },
119
+ { name: 'ភាសាខ្មែរ', iso: 'km', englishName: 'khmer cambodian', dialects: [{ code: 'km-KH', region: 'Cambodia' }] },
120
+ { name: 'Latviešu', iso: 'lv', englishName: 'latvian', dialects: [{ code: 'lv-LV', region: 'Latvia' }] },
121
+ { name: 'Lietuvių', iso: 'lt', englishName: 'lithuanian', dialects: [{ code: 'lt-LT', region: 'Lithuania' }] },
122
+ { name: 'മലയാളം', iso: 'ml', englishName: 'malayalam', dialects: [{ code: 'ml-IN', region: 'India' }] },
123
+ { name: 'मराठी', iso: 'mr', englishName: 'marathi', dialects: [{ code: 'mr-IN', region: 'India' }] },
124
+ { name: 'Magyar', iso: 'hu', englishName: 'hungarian', dialects: [{ code: 'hu-HU', region: 'Hungary' }] },
125
+ { name: 'ລາວ', iso: 'lo', englishName: 'lao laotian', dialects: [{ code: 'lo-LA', region: 'Laos' }] },
126
+ { name: 'Nederlands', iso: 'nl', englishName: 'dutch', dialects: [{ code: 'nl-NL', region: 'Netherlands' }] },
127
+ { name: 'नेपाली भाषा', iso: 'ne', englishName: 'nepali', dialects: [{ code: 'ne-NP', region: 'Nepal' }] },
128
+ { name: 'Norsk bokmål', iso: 'nb', englishName: 'norwegian bokmal', dialects: [{ code: 'nb-NO', region: 'Norway' }] },
129
+ { name: 'Polski', iso: 'pl', englishName: 'polish', dialects: [{ code: 'pl-PL', region: 'Poland' }] },
130
+ {
131
+ name: 'Português', iso: 'pt', englishName: 'portuguese',
132
+ dialects: [
133
+ { code: 'pt-BR', region: 'Brasil' },
134
+ { code: 'pt-PT', region: 'Portugal' },
135
+ ],
136
+ },
137
+ { name: 'Română', iso: 'ro', englishName: 'romanian', dialects: [{ code: 'ro-RO', region: 'Romania' }] },
138
+ { name: 'සිංහල', iso: 'si', englishName: 'sinhala sinhalese', dialects: [{ code: 'si-LK', region: 'Sri Lanka' }] },
139
+ { name: 'Slovenščina', iso: 'sl', englishName: 'slovenian', dialects: [{ code: 'sl-SI', region: 'Slovenia' }] },
140
+ { name: 'Basa Sunda', iso: 'su', englishName: 'sundanese', dialects: [{ code: 'su-ID', region: 'Indonesia' }] },
141
+ { name: 'Slovenčina', iso: 'sk', englishName: 'slovak', dialects: [{ code: 'sk-SK', region: 'Slovakia' }] },
142
+ { name: 'Suomi', iso: 'fi', englishName: 'finnish', dialects: [{ code: 'fi-FI', region: 'Finland' }] },
143
+ { name: 'Svenska', iso: 'sv', englishName: 'swedish', dialects: [{ code: 'sv-SE', region: 'Sweden' }] },
144
+ {
145
+ name: 'Kiswahili', iso: 'sw', englishName: 'swahili',
146
+ dialects: [
147
+ { code: 'sw-TZ', region: 'Tanzania' },
148
+ { code: 'sw-KE', region: 'Kenya' },
149
+ ],
150
+ },
151
+ { name: 'ქართული', iso: 'ka', englishName: 'georgian', dialects: [{ code: 'ka-GE', region: 'Georgia' }] },
152
+ { name: 'Հայերեն', iso: 'hy', englishName: 'armenian', dialects: [{ code: 'hy-AM', region: 'Armenia' }] },
153
+ {
154
+ name: 'தமிழ்', iso: 'ta', englishName: 'tamil',
155
+ dialects: [
156
+ { code: 'ta-IN', region: 'இந்தியா' },
157
+ { code: 'ta-SG', region: 'சிங்கப்பூர்' },
158
+ { code: 'ta-LK', region: 'இலங்கை' },
159
+ { code: 'ta-MY', region: 'மலேசியா' },
160
+ ],
161
+ },
162
+ { name: 'తెలుగు', iso: 'te', englishName: 'telugu', dialects: [{ code: 'te-IN', region: 'India' }] },
163
+ { name: 'Tiếng Việt', iso: 'vi', englishName: 'vietnamese', dialects: [{ code: 'vi-VN', region: 'Vietnam' }] },
164
+ { name: 'Türkçe', iso: 'tr', englishName: 'turkish', dialects: [{ code: 'tr-TR', region: 'Türkiye' }] },
165
+ {
166
+ name: 'اُردُو', iso: 'ur', englishName: 'urdu',
167
+ dialects: [
168
+ { code: 'ur-PK', region: 'پاکستان' },
169
+ { code: 'ur-IN', region: 'بھارت' },
170
+ ],
171
+ },
172
+ { name: 'Ελληνικά', iso: 'el', englishName: 'greek', dialects: [{ code: 'el-GR', region: 'Greece' }] },
173
+ { name: 'български', iso: 'bg', englishName: 'bulgarian', dialects: [{ code: 'bg-BG', region: 'Bulgaria' }] },
174
+ { name: 'Русский', iso: 'ru', englishName: 'russian', dialects: [{ code: 'ru-RU', region: 'Russia' }] },
175
+ { name: 'Српски', iso: 'sr', englishName: 'serbian', dialects: [{ code: 'sr-RS', region: 'Serbia' }] },
176
+ { name: 'Українська', iso: 'uk', englishName: 'ukrainian', dialects: [{ code: 'uk-UA', region: 'Ukraine' }] },
177
+ { name: '한국어', iso: 'ko', englishName: 'korean', dialects: [{ code: 'ko-KR', region: 'Korea' }] },
178
+ {
179
+ name: '中文', iso: 'cmn', englishName: 'chinese mandarin cantonese',
180
+ dialects: [
181
+ { code: 'cmn-Hans-CN', region: '普通话 (中国大陆)' },
182
+ { code: 'cmn-Hans-HK', region: '普通话 (香港)' },
183
+ { code: 'cmn-Hant-TW', region: '中文 (台灣)' },
184
+ { code: 'yue-Hant-HK', region: '粵語 (香港)' },
185
+ ],
186
+ },
187
+ { name: '日本語', iso: 'ja', englishName: 'japanese', dialects: [{ code: 'ja-JP', region: 'Japan' }] },
188
+ { name: 'हिन्दी', iso: 'hi', englishName: 'hindi', dialects: [{ code: 'hi-IN', region: 'India' }] },
189
+ { name: 'ภาษาไทย', iso: 'th', englishName: 'thai', dialects: [{ code: 'th-TH', region: 'Thailand' }] },
190
+ ];
191
+
192
+ /** Flat list of every supported BCP-47 tag, useful for validation. */
193
+ export const WEB_SPEECH_TAGS: string[] = WEB_SPEECH_LANGUAGES.flatMap((l) =>
194
+ l.dialects.map((d) => d.code),
195
+ );
196
+
197
+ /**
198
+ * Find the human-readable language entry that owns a given BCP-47 tag.
199
+ * Returns `null` for unknown / custom tags (custom engines may use
200
+ * codes outside this catalogue).
201
+ */
202
+ export function findSpeechLanguage(tag: string | null | undefined): {
203
+ language: SpeechLanguage;
204
+ dialect: SpeechLanguageDialect;
205
+ } | null {
206
+ if (!tag) return null;
207
+ const lower = tag.toLowerCase();
208
+ for (const language of WEB_SPEECH_LANGUAGES) {
209
+ for (const dialect of language.dialects) {
210
+ if (dialect.code.toLowerCase() === lower) return { language, dialect };
211
+ }
212
+ }
213
+ return null;
214
+ }
215
+
216
+ /**
217
+ * Extract the ISO-3166 country code (2 uppercase letters) from a
218
+ * BCP-47 tag, or `null` if the tag has no region subtag. Used by the
219
+ * language flag button to find the right country flag asset.
220
+ */
221
+ export function countryFromTag(tag: string | null | undefined): string | null {
222
+ if (!tag) return null;
223
+ const parts = tag.split('-');
224
+ for (let i = parts.length - 1; i >= 0; i -= 1) {
225
+ const p = parts[i];
226
+ if (p.length === 2 && /^[A-Za-z]{2}$/.test(p)) return p.toUpperCase();
227
+ }
228
+ return null;
229
+ }
@@ -0,0 +1,3 @@
1
+ import { consola } from 'consola';
2
+
3
+ export const sttLogger = consola.withTag('ui-tools:speech');
@@ -0,0 +1,105 @@
1
+ import { newSegmentId } from './ids';
2
+ import type {
3
+ RecognitionError,
4
+ RecognitionStatus,
5
+ Segment,
6
+ } from '../types';
7
+
8
+ export interface RecognitionState {
9
+ status: RecognitionStatus;
10
+ segments: Segment[];
11
+ error: RecognitionError | null;
12
+ startedAt: number | null;
13
+ }
14
+
15
+ export const INITIAL_STATE: RecognitionState = {
16
+ status: 'idle',
17
+ segments: [],
18
+ error: null,
19
+ startedAt: null,
20
+ };
21
+
22
+ export type RecognitionAction =
23
+ | { type: 'START' }
24
+ | { type: 'STARTED' }
25
+ | { type: 'STOP' }
26
+ | { type: 'STOPPED' }
27
+ | { type: 'ABORT' }
28
+ | {
29
+ type: 'PARTIAL';
30
+ text: string;
31
+ segmentId: string;
32
+ confidence?: number;
33
+ }
34
+ | {
35
+ type: 'FINAL';
36
+ text: string;
37
+ segmentId: string;
38
+ confidence?: number;
39
+ }
40
+ | { type: 'ERROR'; error: RecognitionError }
41
+ | { type: 'RESET' };
42
+
43
+ function nowSinceStart(state: RecognitionState): number {
44
+ return state.startedAt ? Date.now() - state.startedAt : 0;
45
+ }
46
+
47
+ function upsertSegment(
48
+ segments: Segment[],
49
+ patch: Segment,
50
+ ): Segment[] {
51
+ const idx = segments.findIndex((s) => s.id === patch.id);
52
+ if (idx === -1) return [...segments, patch];
53
+ const next = segments.slice();
54
+ next[idx] = { ...next[idx], ...patch };
55
+ return next;
56
+ }
57
+
58
+ export function reducer(
59
+ state: RecognitionState,
60
+ action: RecognitionAction,
61
+ ): RecognitionState {
62
+ switch (action.type) {
63
+ case 'START':
64
+ return {
65
+ ...state,
66
+ status: 'starting',
67
+ error: null,
68
+ startedAt: Date.now(),
69
+ };
70
+ case 'STARTED':
71
+ return { ...state, status: 'listening' };
72
+ case 'STOP':
73
+ return { ...state, status: 'stopping' };
74
+ case 'STOPPED':
75
+ case 'ABORT':
76
+ return { ...state, status: 'idle' };
77
+ case 'PARTIAL': {
78
+ const seg: Segment = {
79
+ id: action.segmentId,
80
+ text: action.text,
81
+ isFinal: false,
82
+ confidence: action.confidence,
83
+ startedAt: nowSinceStart(state),
84
+ };
85
+ return { ...state, segments: upsertSegment(state.segments, seg) };
86
+ }
87
+ case 'FINAL': {
88
+ const seg: Segment = {
89
+ id: action.segmentId || newSegmentId(),
90
+ text: action.text,
91
+ isFinal: true,
92
+ confidence: action.confidence,
93
+ startedAt: nowSinceStart(state),
94
+ endedAt: nowSinceStart(state),
95
+ };
96
+ return { ...state, segments: upsertSegment(state.segments, seg) };
97
+ }
98
+ case 'ERROR':
99
+ return { ...state, status: 'error', error: action.error };
100
+ case 'RESET':
101
+ return { ...INITIAL_STATE };
102
+ default:
103
+ return state;
104
+ }
105
+ }
@@ -0,0 +1,36 @@
1
+ import type { Segment, Transcript } from '../types';
2
+
3
+ export const EMPTY_TRANSCRIPT: Transcript = {
4
+ interim: '',
5
+ final: '',
6
+ segments: [],
7
+ };
8
+
9
+ export function joinFinal(segments: Segment[]): string {
10
+ let out = '';
11
+ for (const seg of segments) {
12
+ if (!seg.isFinal) continue;
13
+ const text = seg.text.trim();
14
+ if (!text) continue;
15
+ out = out ? `${out} ${text}` : text;
16
+ }
17
+ return out;
18
+ }
19
+
20
+ export function buildTranscript(segments: Segment[]): Transcript {
21
+ const last = segments[segments.length - 1];
22
+ const interim = last && !last.isFinal ? last.text : '';
23
+ return {
24
+ interim,
25
+ final: joinFinal(segments),
26
+ segments,
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Polite text normalisation between concatenated finals — strips double
32
+ * spaces / leading punctuation that some engines emit when the user pauses.
33
+ */
34
+ export function normaliseFinal(text: string): string {
35
+ return text.replace(/\s+/g, ' ').replace(/\s+([,.!?])/g, '$1').trim();
36
+ }
@@ -0,0 +1,14 @@
1
+ export { useSpeechRecognition } from './useSpeechRecognition';
2
+ export { useDictation } from './useDictation';
3
+ export type { UseDictationConfig, UseDictationReturn } from './useDictation';
4
+ export { useMicDevices } from './useMicDevices';
5
+ export type { MicDevice } from './useMicDevices';
6
+ export { useMicLevel } from './useMicLevel';
7
+ export { usePushToTalk } from './usePushToTalk';
8
+ export type { UsePushToTalkOptions } from './usePushToTalk';
9
+ export { useEnginePrefs } from './useEnginePrefs';
10
+ export { useVoiceSupport } from './useVoiceSupport';
11
+ export type { VoiceSupport, VoiceUnsupportedReason } from './useVoiceSupport';
12
+ export { useResolvedLanguage } from './useResolvedLanguage';
13
+ export { useSpeechLanguageInfo } from './useSpeechLanguageInfo';
14
+ export type { SpeechLanguageInfo } from './useSpeechLanguageInfo';
@@ -0,0 +1,59 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+
5
+ import { normaliseFinal } from '../core/transcript';
6
+ import type { UseSpeechRecognitionConfig, UseSpeechRecognitionReturn } from '../types';
7
+ import { useSpeechRecognition } from './useSpeechRecognition';
8
+
9
+ export interface UseDictationConfig
10
+ extends Omit<UseSpeechRecognitionConfig, 'onFinal'> {
11
+ /** Controlled value the dictation is appending to. */
12
+ value: string;
13
+ /** Called with the next value after each final segment lands. */
14
+ onChange: (next: string) => void;
15
+ /** Joiner between the previous value and the new segment. Default ' '. */
16
+ separator?: string;
17
+ }
18
+
19
+ export interface UseDictationReturn extends UseSpeechRecognitionReturn {
20
+ /** Convenience — same as `toggle`, named for dictation UIs. */
21
+ toggleDictation: () => Promise<void>;
22
+ }
23
+
24
+ /**
25
+ * Convenience adapter that pipes final transcript segments straight into
26
+ * a controlled string (`<textarea>` / `<input>` / TipTap). Interim text
27
+ * is left alone — bind `transcript.interim` separately if you want to
28
+ * show a live ghost.
29
+ */
30
+ export function useDictation(config: UseDictationConfig): UseDictationReturn {
31
+ const { value, onChange, separator = ' ', ...rest } = config;
32
+
33
+ // Stash latest value in a ref so the onFinal closure always sees the
34
+ // freshest text without forcing the underlying hook to resubscribe.
35
+ const valueRef = useRef(value);
36
+ valueRef.current = value;
37
+ const onChangeRef = useRef(onChange);
38
+ onChangeRef.current = onChange;
39
+
40
+ const rec = useSpeechRecognition({
41
+ ...rest,
42
+ onFinal: (text) => {
43
+ const clean = normaliseFinal(text);
44
+ if (!clean) return;
45
+ const prev = valueRef.current;
46
+ const next = prev ? `${prev}${separator}${clean}` : clean;
47
+ onChangeRef.current(next);
48
+ },
49
+ });
50
+
51
+ useEffect(() => {
52
+ valueRef.current = value;
53
+ }, [value]);
54
+
55
+ return {
56
+ ...rec,
57
+ toggleDictation: rec.toggle,
58
+ };
59
+ }
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+
3
+ import { useSpeechPrefs } from '../store/prefsStore';
4
+ import type { SpeechPrefs } from '../store/prefsStore';
5
+
6
+ /** Thin selector hook so consumers can subscribe to prefs without pulling the whole zustand store. */
7
+ export function useEnginePrefs(): SpeechPrefs & {
8
+ setLanguage: (v: string) => void;
9
+ setDeviceId: (v: string | null) => void;
10
+ setEngineId: (v: string | null) => void;
11
+ setEarcons: (v: boolean) => void;
12
+ reset: () => void;
13
+ } {
14
+ return useSpeechPrefs();
15
+ }
@@ -0,0 +1,57 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ import { sttLogger } from '../core/logger';
6
+
7
+ export interface MicDevice {
8
+ deviceId: string;
9
+ label: string;
10
+ groupId: string;
11
+ }
12
+
13
+ /**
14
+ * Enumerates `audioinput` devices. The browser hides labels until the
15
+ * page has been granted mic permission at least once — the consumer
16
+ * should call `useSpeechRecognition().start()` first to populate labels.
17
+ */
18
+ export function useMicDevices(): {
19
+ devices: MicDevice[];
20
+ refresh: () => Promise<void>;
21
+ } {
22
+ const [devices, setDevices] = useState<MicDevice[]>([]);
23
+
24
+ async function refresh(): Promise<void> {
25
+ if (typeof navigator === 'undefined' || !navigator.mediaDevices?.enumerateDevices) {
26
+ return;
27
+ }
28
+ try {
29
+ const list = await navigator.mediaDevices.enumerateDevices();
30
+ setDevices(
31
+ list
32
+ .filter((d) => d.kind === 'audioinput')
33
+ .map((d) => ({
34
+ deviceId: d.deviceId,
35
+ label: d.label || 'Microphone',
36
+ groupId: d.groupId,
37
+ })),
38
+ );
39
+ } catch (cause) {
40
+ sttLogger.warn('[devices] enumerate failed', cause);
41
+ }
42
+ }
43
+
44
+ useEffect(() => {
45
+ void refresh();
46
+ if (typeof navigator === 'undefined' || !navigator.mediaDevices) return undefined;
47
+ const handler = (): void => {
48
+ void refresh();
49
+ };
50
+ navigator.mediaDevices.addEventListener?.('devicechange', handler);
51
+ return () => {
52
+ navigator.mediaDevices.removeEventListener?.('devicechange', handler);
53
+ };
54
+ }, []);
55
+
56
+ return { devices, refresh };
57
+ }