@djangocfg/widget-chat 0.1.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/package.json +95 -0
  4. package/src/composer/composer.tsx +100 -0
  5. package/src/composer/context.ts +33 -0
  6. package/src/composer/controls/button.tsx +104 -0
  7. package/src/composer/controls/language-menu.tsx +123 -0
  8. package/src/composer/controls/send.tsx +83 -0
  9. package/src/composer/controls/think.tsx +50 -0
  10. package/src/composer/controls/tools.tsx +130 -0
  11. package/src/composer/controls/tooltip.tsx +10 -0
  12. package/src/composer/controls/voice.tsx +266 -0
  13. package/src/composer/field/field.tsx +140 -0
  14. package/src/composer/field/input.tsx +214 -0
  15. package/src/composer/field/keys.ts +44 -0
  16. package/src/composer/field/use-autosize.ts +134 -0
  17. package/src/composer/focus-field.ts +27 -0
  18. package/src/composer/index.ts +86 -0
  19. package/src/composer/stories/field.tsx +112 -0
  20. package/src/composer/stories/harness.tsx +127 -0
  21. package/src/composer/tools/menu.tsx +129 -0
  22. package/src/composer/tools/rows.tsx +180 -0
  23. package/src/composer/tools/use-menu-nav.ts +66 -0
  24. package/src/composer/tools/use-recent-tools.ts +30 -0
  25. package/src/composer/tray/banner.tsx +157 -0
  26. package/src/composer/tray/command-progress.tsx +116 -0
  27. package/src/composer/tray/picked-tools.tsx +104 -0
  28. package/src/composer/tray/queue.tsx +202 -0
  29. package/src/composer/tray/use-replace-text.ts +58 -0
  30. package/src/composer/use-draft-persistence.ts +79 -0
  31. package/src/content/index.ts +16 -0
  32. package/src/content/media-content.tsx +38 -0
  33. package/src/content/response.tsx +221 -0
  34. package/src/core/adapter.ts +29 -0
  35. package/src/core/capabilities.ts +286 -0
  36. package/src/core/chunks.ts +66 -0
  37. package/src/core/content/incomplete-math.ts +118 -0
  38. package/src/core/content/index.ts +9 -0
  39. package/src/core/draft-storage.ts +62 -0
  40. package/src/core/events.ts +88 -0
  41. package/src/core/focus/index.ts +10 -0
  42. package/src/core/focus/roving.ts +56 -0
  43. package/src/core/group-parts.ts +80 -0
  44. package/src/core/index.ts +34 -0
  45. package/src/core/links/index.ts +33 -0
  46. package/src/core/links/prose.ts +191 -0
  47. package/src/core/links/scheme.ts +98 -0
  48. package/src/core/links/tool-id.ts +26 -0
  49. package/src/core/media/allowed-src.ts +138 -0
  50. package/src/core/media/block.ts +254 -0
  51. package/src/core/media/index.ts +22 -0
  52. package/src/core/media/kinds.ts +85 -0
  53. package/src/core/outbound.ts +86 -0
  54. package/src/core/parts.ts +169 -0
  55. package/src/core/queue/index.ts +15 -0
  56. package/src/core/queue/queue.ts +100 -0
  57. package/src/core/recall/boundary.ts +77 -0
  58. package/src/core/recall/from-transcript.ts +47 -0
  59. package/src/core/recall/index.ts +23 -0
  60. package/src/core/recall/ladder.ts +171 -0
  61. package/src/core/reload-decision.ts +36 -0
  62. package/src/core/scroll/index.ts +17 -0
  63. package/src/core/scroll/missed.ts +106 -0
  64. package/src/core/scroll/sent.ts +54 -0
  65. package/src/core/text-replace.ts +20 -0
  66. package/src/core/tool-name.ts +31 -0
  67. package/src/core/tool-picks-set.ts +48 -0
  68. package/src/core/tool-picks.ts +142 -0
  69. package/src/core/tool-prompt.ts +47 -0
  70. package/src/core/tools/capability.ts +34 -0
  71. package/src/core/tools/catalogue.ts +109 -0
  72. package/src/core/tools/index.ts +17 -0
  73. package/src/core/tools/layout.ts +44 -0
  74. package/src/core/tools/nav.ts +32 -0
  75. package/src/core/tools/rank.ts +60 -0
  76. package/src/core/tools/recent.ts +39 -0
  77. package/src/core/transcript.ts +301 -0
  78. package/src/core/voice/earcons.ts +58 -0
  79. package/src/core/voice/index.ts +26 -0
  80. package/src/core/voice/language-search.ts +62 -0
  81. package/src/core/voice/languages.ts +267 -0
  82. package/src/core/voice/resolve-language.ts +93 -0
  83. package/src/core/voice/session.ts +196 -0
  84. package/src/core/voice/web-speech.ts +170 -0
  85. package/src/i18n/index.ts +87 -0
  86. package/src/i18n/locales/ar.ts +97 -0
  87. package/src/i18n/locales/da.ts +90 -0
  88. package/src/i18n/locales/de.ts +90 -0
  89. package/src/i18n/locales/en.ts +90 -0
  90. package/src/i18n/locales/es.ts +90 -0
  91. package/src/i18n/locales/fr.ts +90 -0
  92. package/src/i18n/locales/it.ts +90 -0
  93. package/src/i18n/locales/ja.ts +83 -0
  94. package/src/i18n/locales/ko.ts +83 -0
  95. package/src/i18n/locales/nl.ts +90 -0
  96. package/src/i18n/locales/no.ts +90 -0
  97. package/src/i18n/locales/pl.ts +96 -0
  98. package/src/i18n/locales/ptBR.ts +90 -0
  99. package/src/i18n/locales/ru.ts +96 -0
  100. package/src/i18n/locales/sv.ts +90 -0
  101. package/src/i18n/locales/tr.ts +89 -0
  102. package/src/i18n/locales/zh.ts +83 -0
  103. package/src/i18n/plural.ts +62 -0
  104. package/src/i18n/types.ts +257 -0
  105. package/src/index.ts +18 -0
  106. package/src/primitives/index.ts +43 -0
  107. package/src/primitives/message.tsx +85 -0
  108. package/src/primitives/part-registry.tsx +44 -0
  109. package/src/primitives/stories-harness.tsx +118 -0
  110. package/src/primitives/suggestions.tsx +112 -0
  111. package/src/primitives/tool-call.tsx +145 -0
  112. package/src/primitives/tooltip.tsx +48 -0
  113. package/src/primitives/transcript.tsx +285 -0
  114. package/src/primitives/use-dismiss.ts +100 -0
  115. package/src/primitives/use-missed-count.ts +30 -0
  116. package/src/primitives/use-overflows.ts +69 -0
  117. package/src/primitives/use-return-on-send.ts +29 -0
  118. package/src/primitives/use-roving-focus.ts +69 -0
  119. package/src/primitives/use-select-all-transcript.ts +111 -0
  120. package/src/primitives/use-stick-to-bottom.ts +83 -0
  121. package/src/primitives/use-turn-activity.ts +25 -0
  122. package/src/store/context.ts +28 -0
  123. package/src/store/hooks.ts +215 -0
  124. package/src/store/index.ts +31 -0
  125. package/src/store/provider.tsx +97 -0
  126. package/src/store/store.ts +394 -0
  127. package/src/store/subscribers.ts +55 -0
  128. package/src/store/surfaces.ts +174 -0
  129. package/src/store/use-draft.ts +168 -0
  130. package/src/store/use-queue.ts +61 -0
  131. package/src/store/use-recall.ts +264 -0
  132. package/src/store/use-reload-on-revision.ts +67 -0
  133. package/src/store/use-speech-language.ts +84 -0
  134. package/src/store/use-voice.ts +116 -0
  135. package/src/styles/block.css +128 -0
  136. package/src/styles/composer.css +449 -0
  137. package/src/styles/index.css +47 -0
  138. package/src/styles/markdown.css +368 -0
  139. package/src/styles/surface.css +102 -0
  140. package/src/styles/tool.css +128 -0
  141. package/src/styles/turn-mark.css +97 -0
  142. package/src/testing/index.ts +15 -0
  143. package/src/testing/mock-adapter.ts +69 -0
  144. package/src/testing/mock-tools.ts +101 -0
  145. package/src/testing/mock-voice.ts +81 -0
  146. package/src/ui/chat-composer.tsx +286 -0
  147. package/src/ui/chat-reply-chip.tsx +42 -0
  148. package/src/ui/chat-suggestions.tsx +87 -0
  149. package/src/ui/chat-transcript.tsx +157 -0
  150. package/src/ui/chat.tsx +197 -0
  151. package/src/ui/collapsible-body.tsx +180 -0
  152. package/src/ui/collapsible-row.tsx +81 -0
  153. package/src/ui/day-divider.tsx +49 -0
  154. package/src/ui/index.ts +54 -0
  155. package/src/ui/jump-to-latest.tsx +60 -0
  156. package/src/ui/link-chip.tsx +73 -0
  157. package/src/ui/message-actions.tsx +163 -0
  158. package/src/ui/message-quote.tsx +47 -0
  159. package/src/ui/message.tsx +295 -0
  160. package/src/ui/pending.tsx +85 -0
  161. package/src/ui/renderers/data.tsx +63 -0
  162. package/src/ui/renderers/reasoning.tsx +73 -0
  163. package/src/ui/renderers/tool.tsx +176 -0
  164. package/src/ui/turn-mark.tsx +31 -0
  165. package/src/utils/day-heading.ts +46 -0
  166. package/src/utils/error-message.ts +10 -0
  167. package/src/utils/index.ts +12 -0
  168. package/src/utils/markdown-text.ts +175 -0
  169. package/src/widgets/index.ts +14 -0
  170. package/src/widgets/media.tsx +239 -0
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The two dictation earcons, as a port.
3
+ *
4
+ * The session plays them, not the button: the button knows what was pressed,
5
+ * only the session knows what the engine did. A permission prompt, a silence
6
+ * timeout and an already-running engine all sit between the two.
7
+ *
8
+ * ORDER: the start tone plays before the engine is asked to listen, the stop
9
+ * tone after it is told to stop. A tone inside that window is transcribed as a
10
+ * word. Reversing either line is silent — dictation still works and the
11
+ * transcript merely gains a phantom syllable.
12
+ */
13
+
14
+ export type EarconKind = "start" | "stop";
15
+
16
+ /**
17
+ * How a host plays one tone.
18
+ *
19
+ * A function, not a URL pair: the package ships no audio. The host already owns
20
+ * preloading, the mute gate and the autoplay-rejection rule.
21
+ *
22
+ * Must not throw and must not block — it runs on the path that opens the
23
+ * microphone.
24
+ */
25
+ export type PlayEarcon = (kind: EarconKind) => void;
26
+
27
+ /**
28
+ * The dictation-tones capability.
29
+ *
30
+ * Separate from `VoiceCapability` because an absent voice capability means the
31
+ * DEFAULT engine — a host on that path has no object to hang a player on. Sound
32
+ * and engine are also independent choices: the browser's recogniser with our
33
+ * tones, or the reverse.
34
+ *
35
+ * Declared here rather than in `core/capabilities.ts` so the port, the safety
36
+ * wrapper and the seam that exposes them stay in one file.
37
+ */
38
+ export interface VoiceSoundsCapability {
39
+ readonly play: PlayEarcon;
40
+ }
41
+
42
+ /**
43
+ * Wrap a player so a throwing host costs a tone, not the sentence.
44
+ *
45
+ * `undefined` in ⇒ no-op out, so the session holds one shape and needs no null
46
+ * check at either call site.
47
+ */
48
+ export function safeEarcon(play: PlayEarcon | undefined): PlayEarcon {
49
+ if (play === undefined) return () => undefined;
50
+ return (kind) => {
51
+ try {
52
+ play(kind);
53
+ } catch {
54
+ // Silent: this would fire once per dictation and train the reader to
55
+ // ignore the screen during voice work.
56
+ }
57
+ };
58
+ }
@@ -0,0 +1,26 @@
1
+ // Dictation: a state machine, a language rule, and ONE default engine.
2
+ //
3
+ // The package ships Web Speech as a default so a consumer gets a working
4
+ // microphone without wiring an external toolkit — audio never leaves the
5
+ // engine there. `VoiceCapability` remains the seam, so a host with a server
6
+ // transcriber, Whisper or a native bridge supplies its own and the default
7
+ // steps aside. Neither available means NO button, never a disabled one.
8
+ //
9
+ // This inverts the absent-is-absent rule for this one capability, deliberately:
10
+ // absent `voice` falls back to the browser rather than to nothing.
11
+ //
12
+ // MediaRecorder is NOT a fallback. It only produces audio, and turning that
13
+ // into text needs an endpoint — audio leaving the device, which is exactly the
14
+ // decision the seam exists to leave to the host.
15
+
16
+ export { WEB_SPEECH_LANGUAGES } from "./languages";
17
+ export type { SpeechLanguage, SpeechLanguageDialect } from "./languages";
18
+ export { expandQueryLayouts, matchesLanguageQuery } from "./language-search";
19
+ export { resolveLanguage, toSpeechTag } from "./resolve-language";
20
+ export type { LanguageSignals } from "./resolve-language";
21
+ export { createWebSpeechCapability, isWebSpeechAvailable } from "./web-speech";
22
+ export type { WebSpeechOptions } from "./web-speech";
23
+ export { VoiceSession } from "./session";
24
+ export type { VoiceSnapshot, VoiceSessionOptions } from "./session";
25
+ export { safeEarcon } from "./earcons";
26
+ export type { EarconKind, PlayEarcon, VoiceSoundsCapability } from "./earcons";
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Layout-independent language search.
3
+ *
4
+ * Typing "he,bq" on an EN layout while thinking «русый», or "hecc" for
5
+ * «русс», is the single most common way a bilingual user searches a
6
+ * language list — the physical keys are right, the layout is wrong.
7
+ * Every query is therefore expanded through the QWERTY↔ЙЦУКЕН key maps
8
+ * and EVERY variant is matched, so «рус», "rus", "russian" and "hec"
9
+ * (=рус on EN keys) all find Русский.
10
+ *
11
+ * The maps cover the RU↔EN pair only — the pair our users actually mix.
12
+ * The mechanism is additive: more layout pairs can be appended to
13
+ * `LAYOUT_PAIRS` without touching the matcher.
14
+ */
15
+
16
+ /** QWERTY key → ЙЦУКЕН character on the same physical key. */
17
+ const EN_TO_RU: Record<string, string> = {
18
+ q: 'й', w: 'ц', e: 'у', r: 'к', t: 'е', y: 'н', u: 'г', i: 'ш', o: 'щ',
19
+ p: 'з', '[': 'х', ']': 'ъ', a: 'ф', s: 'ы', d: 'в', f: 'а', g: 'п',
20
+ h: 'р', j: 'о', k: 'л', l: 'д', ';': 'ж', "'": 'э', z: 'я', x: 'ч',
21
+ c: 'с', v: 'м', b: 'и', n: 'т', m: 'ь', ',': 'б', '.': 'ю',
22
+ };
23
+
24
+ /** ЙЦУКЕН character → QWERTY key on the same physical key. */
25
+ const RU_TO_EN: Record<string, string> = Object.fromEntries(
26
+ Object.entries(EN_TO_RU).map(([en, ru]) => [ru, en]),
27
+ );
28
+
29
+ const LAYOUT_PAIRS: Record<string, string>[] = [EN_TO_RU, RU_TO_EN];
30
+
31
+ function remap(query: string, table: Record<string, string>): string {
32
+ let out = '';
33
+ for (const ch of query) out += table[ch] ?? ch;
34
+ return out;
35
+ }
36
+
37
+ /**
38
+ * All layout interpretations of a raw query, lowercased and de-duplicated.
39
+ * Always includes the query itself.
40
+ */
41
+ export function expandQueryLayouts(query: string): string[] {
42
+ const q = query.trim().toLowerCase();
43
+ if (!q) return [];
44
+ const variants = new Set<string>([q]);
45
+ for (const table of LAYOUT_PAIRS) {
46
+ const mapped = remap(q, table);
47
+ if (mapped !== q) variants.add(mapped);
48
+ }
49
+ return [...variants];
50
+ }
51
+
52
+ /**
53
+ * True when ANY layout variant of `query` is a substring of `haystack`.
54
+ * `haystack` must already be lowercase (build it once per row —
55
+ * `SpeechLanguageRow.search` packs native name, English name, ISO, tag
56
+ * and region).
57
+ */
58
+ export function matchesLanguageQuery(haystack: string, query: string): boolean {
59
+ const variants = expandQueryLayouts(query);
60
+ if (variants.length === 0) return true;
61
+ return variants.some((v) => haystack.includes(v));
62
+ }
@@ -0,0 +1,267 @@
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
+ * A host whose engine is not the browser can narrow this list — a server
14
+ * transcriber may accept more or fewer tags than Web Speech does.
15
+ */
16
+
17
+ export interface SpeechLanguageDialect {
18
+ /** BCP-47 tag (e.g. `en-US`). */
19
+ code: string;
20
+ /** Region label in the language's native script (e.g. "United States"). */
21
+ region: string;
22
+ }
23
+
24
+ export interface SpeechLanguage {
25
+ /** Native-script name (e.g. "Русский", "中文"). */
26
+ name: string;
27
+ /**
28
+ * English name used as a secondary search key so users typing
29
+ * "russian" / "chinese" / "korean" land on the right row regardless
30
+ * of the native script. Always lowercase.
31
+ */
32
+ englishName: string;
33
+ /**
34
+ * Primary-subtag ISO-639 code (e.g. `en`, `ru`, `cmn`). The key a UI locale
35
+ * is matched against when resolving a default.
36
+ */
37
+ iso: string;
38
+ /** One or more region dialects. Length >= 1. */
39
+ dialects: SpeechLanguageDialect[];
40
+ }
41
+
42
+ export const WEB_SPEECH_LANGUAGES: SpeechLanguage[] = [
43
+ { name: 'Afrikaans', iso: 'af', englishName: 'afrikaans', dialects: [{ code: 'af-ZA', region: 'South Africa' }] },
44
+ { name: 'አማርኛ', iso: 'am', englishName: 'amharic', dialects: [{ code: 'am-ET', region: 'Ethiopia' }] },
45
+ { name: 'Azərbaycanca', iso: 'az', englishName: 'azerbaijani', dialects: [{ code: 'az-AZ', region: 'Azerbaijan' }] },
46
+ {
47
+ name: 'বাংলা', iso: 'bn', englishName: 'bengali',
48
+ dialects: [
49
+ { code: 'bn-BD', region: 'Bangladesh' },
50
+ { code: 'bn-IN', region: 'India' },
51
+ ],
52
+ },
53
+ { name: 'Bahasa Indonesia', iso: 'id', englishName: 'indonesian', dialects: [{ code: 'id-ID', region: 'Indonesia' }] },
54
+ { name: 'Bahasa Melayu', iso: 'ms', englishName: 'malay', dialects: [{ code: 'ms-MY', region: 'Malaysia' }] },
55
+ { name: 'Català', iso: 'ca', englishName: 'catalan', dialects: [{ code: 'ca-ES', region: 'Spain' }] },
56
+ { name: 'Čeština', iso: 'cs', englishName: 'czech', dialects: [{ code: 'cs-CZ', region: 'Czechia' }] },
57
+ { name: 'Dansk', iso: 'da', englishName: 'danish', dialects: [{ code: 'da-DK', region: 'Denmark' }] },
58
+ { name: 'Deutsch', iso: 'de', englishName: 'german', dialects: [{ code: 'de-DE', region: 'Germany' }] },
59
+ {
60
+ name: 'English', iso: 'en', englishName: 'english',
61
+ dialects: [
62
+ { code: 'en-US', region: 'United States' },
63
+ { code: 'en-GB', region: 'United Kingdom' },
64
+ { code: 'en-AU', region: 'Australia' },
65
+ { code: 'en-CA', region: 'Canada' },
66
+ { code: 'en-IN', region: 'India' },
67
+ { code: 'en-NZ', region: 'New Zealand' },
68
+ { code: 'en-PH', region: 'Philippines' },
69
+ { code: 'en-ZA', region: 'South Africa' },
70
+ { code: 'en-NG', region: 'Nigeria' },
71
+ { code: 'en-GH', region: 'Ghana' },
72
+ { code: 'en-KE', region: 'Kenya' },
73
+ { code: 'en-TZ', region: 'Tanzania' },
74
+ ],
75
+ },
76
+ {
77
+ name: 'Español', iso: 'es', englishName: 'spanish',
78
+ dialects: [
79
+ { code: 'es-ES', region: 'España' },
80
+ { code: 'es-MX', region: 'México' },
81
+ { code: 'es-US', region: 'Estados Unidos' },
82
+ { code: 'es-AR', region: 'Argentina' },
83
+ { code: 'es-CL', region: 'Chile' },
84
+ { code: 'es-CO', region: 'Colombia' },
85
+ { code: 'es-PE', region: 'Perú' },
86
+ { code: 'es-VE', region: 'Venezuela' },
87
+ { code: 'es-EC', region: 'Ecuador' },
88
+ { code: 'es-GT', region: 'Guatemala' },
89
+ { code: 'es-CR', region: 'Costa Rica' },
90
+ { code: 'es-PA', region: 'Panamá' },
91
+ { code: 'es-DO', region: 'Rep. Dominicana' },
92
+ { code: 'es-UY', region: 'Uruguay' },
93
+ { code: 'es-PY', region: 'Paraguay' },
94
+ { code: 'es-BO', region: 'Bolivia' },
95
+ { code: 'es-SV', region: 'El Salvador' },
96
+ { code: 'es-HN', region: 'Honduras' },
97
+ { code: 'es-NI', region: 'Nicaragua' },
98
+ { code: 'es-PR', region: 'Puerto Rico' },
99
+ ],
100
+ },
101
+ { name: 'Euskara', iso: 'eu', englishName: 'basque', dialects: [{ code: 'eu-ES', region: 'Spain' }] },
102
+ { name: 'Filipino', iso: 'fil', englishName: 'filipino tagalog', dialects: [{ code: 'fil-PH', region: 'Philippines' }] },
103
+ { name: 'Français', iso: 'fr', englishName: 'french', dialects: [{ code: 'fr-FR', region: 'France' }] },
104
+ { name: 'Basa Jawa', iso: 'jv', englishName: 'javanese', dialects: [{ code: 'jv-ID', region: 'Indonesia' }] },
105
+ { name: 'Galego', iso: 'gl', englishName: 'galician', dialects: [{ code: 'gl-ES', region: 'Spain' }] },
106
+ { name: 'ગુજરાતી', iso: 'gu', englishName: 'gujarati', dialects: [{ code: 'gu-IN', region: 'India' }] },
107
+ { name: 'Hrvatski', iso: 'hr', englishName: 'croatian', dialects: [{ code: 'hr-HR', region: 'Croatia' }] },
108
+ { name: 'IsiZulu', iso: 'zu', englishName: 'zulu', dialects: [{ code: 'zu-ZA', region: 'South Africa' }] },
109
+ { name: 'Íslenska', iso: 'is', englishName: 'icelandic', dialects: [{ code: 'is-IS', region: 'Iceland' }] },
110
+ {
111
+ name: 'Italiano', iso: 'it', englishName: 'italian',
112
+ dialects: [
113
+ { code: 'it-IT', region: 'Italia' },
114
+ { code: 'it-CH', region: 'Svizzera' },
115
+ ],
116
+ },
117
+ { name: 'ಕನ್ನಡ', iso: 'kn', englishName: 'kannada', dialects: [{ code: 'kn-IN', region: 'India' }] },
118
+ { name: 'ភាសាខ្មែរ', iso: 'km', englishName: 'khmer cambodian', dialects: [{ code: 'km-KH', region: 'Cambodia' }] },
119
+ { name: 'Latviešu', iso: 'lv', englishName: 'latvian', dialects: [{ code: 'lv-LV', region: 'Latvia' }] },
120
+ { name: 'Lietuvių', iso: 'lt', englishName: 'lithuanian', dialects: [{ code: 'lt-LT', region: 'Lithuania' }] },
121
+ { name: 'മലയാളം', iso: 'ml', englishName: 'malayalam', dialects: [{ code: 'ml-IN', region: 'India' }] },
122
+ { name: 'मराठी', iso: 'mr', englishName: 'marathi', dialects: [{ code: 'mr-IN', region: 'India' }] },
123
+ { name: 'Magyar', iso: 'hu', englishName: 'hungarian', dialects: [{ code: 'hu-HU', region: 'Hungary' }] },
124
+ { name: 'ລາວ', iso: 'lo', englishName: 'lao laotian', dialects: [{ code: 'lo-LA', region: 'Laos' }] },
125
+ { name: 'Nederlands', iso: 'nl', englishName: 'dutch', dialects: [{ code: 'nl-NL', region: 'Netherlands' }] },
126
+ { name: 'नेपाली भाषा', iso: 'ne', englishName: 'nepali', dialects: [{ code: 'ne-NP', region: 'Nepal' }] },
127
+ { name: 'Norsk bokmål', iso: 'nb', englishName: 'norwegian bokmal', dialects: [{ code: 'nb-NO', region: 'Norway' }] },
128
+ { name: 'Polski', iso: 'pl', englishName: 'polish', dialects: [{ code: 'pl-PL', region: 'Poland' }] },
129
+ {
130
+ name: 'Português', iso: 'pt', englishName: 'portuguese',
131
+ dialects: [
132
+ { code: 'pt-BR', region: 'Brasil' },
133
+ { code: 'pt-PT', region: 'Portugal' },
134
+ ],
135
+ },
136
+ { name: 'Română', iso: 'ro', englishName: 'romanian', dialects: [{ code: 'ro-RO', region: 'Romania' }] },
137
+ { name: 'සිංහල', iso: 'si', englishName: 'sinhala sinhalese', dialects: [{ code: 'si-LK', region: 'Sri Lanka' }] },
138
+ { name: 'Slovenščina', iso: 'sl', englishName: 'slovenian', dialects: [{ code: 'sl-SI', region: 'Slovenia' }] },
139
+ { name: 'Basa Sunda', iso: 'su', englishName: 'sundanese', dialects: [{ code: 'su-ID', region: 'Indonesia' }] },
140
+ { name: 'Slovenčina', iso: 'sk', englishName: 'slovak', dialects: [{ code: 'sk-SK', region: 'Slovakia' }] },
141
+ { name: 'Suomi', iso: 'fi', englishName: 'finnish', dialects: [{ code: 'fi-FI', region: 'Finland' }] },
142
+ { name: 'Svenska', iso: 'sv', englishName: 'swedish', dialects: [{ code: 'sv-SE', region: 'Sweden' }] },
143
+ {
144
+ name: 'Kiswahili', iso: 'sw', englishName: 'swahili',
145
+ dialects: [
146
+ { code: 'sw-TZ', region: 'Tanzania' },
147
+ { code: 'sw-KE', region: 'Kenya' },
148
+ ],
149
+ },
150
+ { name: 'ქართული', iso: 'ka', englishName: 'georgian', dialects: [{ code: 'ka-GE', region: 'Georgia' }] },
151
+ { name: 'Հայերեն', iso: 'hy', englishName: 'armenian', dialects: [{ code: 'hy-AM', region: 'Armenia' }] },
152
+ {
153
+ name: 'தமிழ்', iso: 'ta', englishName: 'tamil',
154
+ dialects: [
155
+ { code: 'ta-IN', region: 'இந்தியா' },
156
+ { code: 'ta-SG', region: 'சிங்கப்பூர்' },
157
+ { code: 'ta-LK', region: 'இலங்கை' },
158
+ { code: 'ta-MY', region: 'மலேசியா' },
159
+ ],
160
+ },
161
+ { name: 'తెలుగు', iso: 'te', englishName: 'telugu', dialects: [{ code: 'te-IN', region: 'India' }] },
162
+ { name: 'Tiếng Việt', iso: 'vi', englishName: 'vietnamese', dialects: [{ code: 'vi-VN', region: 'Vietnam' }] },
163
+ { name: 'Türkçe', iso: 'tr', englishName: 'turkish', dialects: [{ code: 'tr-TR', region: 'Türkiye' }] },
164
+ {
165
+ name: 'اُردُو', iso: 'ur', englishName: 'urdu',
166
+ dialects: [
167
+ { code: 'ur-PK', region: 'پاکستان' },
168
+ { code: 'ur-IN', region: 'بھارت' },
169
+ ],
170
+ },
171
+ { name: 'Ελληνικά', iso: 'el', englishName: 'greek', dialects: [{ code: 'el-GR', region: 'Greece' }] },
172
+ { name: 'български', iso: 'bg', englishName: 'bulgarian', dialects: [{ code: 'bg-BG', region: 'Bulgaria' }] },
173
+ { name: 'Русский', iso: 'ru', englishName: 'russian', dialects: [{ code: 'ru-RU', region: 'Russia' }] },
174
+ { name: 'Српски', iso: 'sr', englishName: 'serbian', dialects: [{ code: 'sr-RS', region: 'Serbia' }] },
175
+ { name: 'Українська', iso: 'uk', englishName: 'ukrainian', dialects: [{ code: 'uk-UA', region: 'Ukraine' }] },
176
+ { name: '한국어', iso: 'ko', englishName: 'korean', dialects: [{ code: 'ko-KR', region: 'Korea' }] },
177
+ {
178
+ name: '中文', iso: 'cmn', englishName: 'chinese mandarin cantonese',
179
+ dialects: [
180
+ { code: 'cmn-Hans-CN', region: '普通话 (中国大陆)' },
181
+ { code: 'cmn-Hans-HK', region: '普通话 (香港)' },
182
+ { code: 'cmn-Hant-TW', region: '中文 (台灣)' },
183
+ { code: 'yue-Hant-HK', region: '粵語 (香港)' },
184
+ ],
185
+ },
186
+ { name: '日本語', iso: 'ja', englishName: 'japanese', dialects: [{ code: 'ja-JP', region: 'Japan' }] },
187
+ { name: 'हिन्दी', iso: 'hi', englishName: 'hindi', dialects: [{ code: 'hi-IN', region: 'India' }] },
188
+ { name: 'ภาษาไทย', iso: 'th', englishName: 'thai', dialects: [{ code: 'th-TH', region: 'Thailand' }] },
189
+ ];
190
+
191
+ /** Flat list of every supported BCP-47 tag, useful for validation. */
192
+ export const WEB_SPEECH_TAGS: string[] = WEB_SPEECH_LANGUAGES.flatMap((l) =>
193
+ l.dialects.map((d) => d.code),
194
+ );
195
+
196
+ /**
197
+ * Find the human-readable language entry that owns a given BCP-47 tag.
198
+ * Returns `null` for unknown / custom tags (custom engines may use
199
+ * codes outside this catalogue).
200
+ */
201
+ export function findSpeechLanguage(tag: string | null | undefined): {
202
+ language: SpeechLanguage;
203
+ dialect: SpeechLanguageDialect;
204
+ } | null {
205
+ if (!tag) return null;
206
+ const lower = tag.toLowerCase();
207
+ for (const language of WEB_SPEECH_LANGUAGES) {
208
+ for (const dialect of language.dialects) {
209
+ if (dialect.code.toLowerCase() === lower) return { language, dialect };
210
+ }
211
+ }
212
+ return null;
213
+ }
214
+
215
+ /**
216
+ * Extract the ISO-3166 country code (2 uppercase letters) from a
217
+ * BCP-47 tag, or `null` if the tag has no region subtag. Used by the
218
+ * language flag button to find the right country flag asset.
219
+ */
220
+ export function countryFromTag(tag: string | null | undefined): string | null {
221
+ if (!tag) return null;
222
+ const parts = tag.split('-');
223
+ for (let i = parts.length - 1; i >= 0; i -= 1) {
224
+ const p = parts[i];
225
+ if (p && p.length === 2 && /^[A-Za-z]{2}$/.test(p)) return p.toUpperCase();
226
+ }
227
+ return null;
228
+ }
229
+
230
+ export interface SpeechLanguageRow {
231
+ /** BCP-47 tag (e.g. `ru-RU`). */
232
+ tag: string;
233
+ /** Display label: native name, plus region when the language has >1 dialect. */
234
+ label: string;
235
+ /** ISO-3166 country for the flag asset, or null for region-less tags. */
236
+ country: string | null;
237
+ /**
238
+ * Lowercased search haystack: native name + English name + ISO + tag
239
+ * + region. Match with `matchesLanguageQuery` (layout-independent).
240
+ */
241
+ search: string;
242
+ }
243
+
244
+ /**
245
+ * Flatten the catalogue into ready-to-render menu rows — the single
246
+ * builder behind every language list surface (`VoiceControlMenu`
247
+ * submenu, the in-recording `LanguageChip` dropdown). `allowedTags`
248
+ * narrows the list for backend engines that support a subset.
249
+ */
250
+ export function buildLanguageRows(allowedTags?: string[]): SpeechLanguageRow[] {
251
+ const allow = allowedTags ? new Set(allowedTags) : null;
252
+ const rows: SpeechLanguageRow[] = [];
253
+ for (const lang of WEB_SPEECH_LANGUAGES) {
254
+ for (const d of lang.dialects) {
255
+ if (allow && !allow.has(d.code)) continue;
256
+ rows.push({
257
+ tag: d.code,
258
+ label:
259
+ lang.dialects.length === 1 ? lang.name : `${lang.name} — ${d.region}`,
260
+ country: countryFromTag(d.code),
261
+ search:
262
+ `${lang.name} ${lang.englishName} ${lang.iso} ${d.code} ${d.region}`.toLowerCase(),
263
+ });
264
+ }
265
+ }
266
+ return rows;
267
+ }
@@ -0,0 +1,93 @@
1
+ import { WEB_SPEECH_LANGUAGES } from "./languages";
2
+
3
+ /**
4
+ * The signals a default language is resolved from.
5
+ *
6
+ * Passed in rather than read from `navigator`/`localStorage` here: this is the
7
+ * one rule in dictation worth testing exhaustively, and a function that reaches
8
+ * for globals can only be tested by faking them.
9
+ */
10
+ export interface LanguageSignals {
11
+ /** What the user picked, if they ever did. Wins outright. */
12
+ readonly picked?: string | undefined;
13
+ /** The host UI's locale — an ISO subtag such as `ru`. A TIE-BREAKER only. */
14
+ readonly uiLocale?: string | undefined;
15
+ /** `navigator.languages`, in the browser's own order. */
16
+ readonly browserLanguages?: readonly string[] | undefined;
17
+ /** `navigator.language`. */
18
+ readonly browserLanguage?: string | undefined;
19
+ }
20
+
21
+ const FALLBACK = "en-US";
22
+
23
+ /** ISO primary subtag → the catalogue's default dialect (`uk` → `uk-UA`). */
24
+ const ISO_TO_TAG: ReadonlyMap<string, string> = new Map(
25
+ WEB_SPEECH_LANGUAGES.flatMap((language) => {
26
+ const first = language.dialects[0];
27
+ return first === undefined ? [] : [[language.iso.toLowerCase(), first.code] as const];
28
+ }),
29
+ );
30
+
31
+ /** Every tag the catalogue knows, lowercased, for validating a candidate. */
32
+ const KNOWN_TAGS: ReadonlySet<string> = new Set(
33
+ WEB_SPEECH_LANGUAGES.flatMap((language) =>
34
+ language.dialects.map((dialect) => dialect.code.toLowerCase()),
35
+ ),
36
+ );
37
+
38
+ function isoOf(tag: string): string {
39
+ return tag.toLowerCase().split("-")[0] ?? "";
40
+ }
41
+
42
+ /**
43
+ * Normalise anything language-shaped to a tag the Web Speech API accepts.
44
+ *
45
+ * A bare ISO subtag becomes the catalogue's default dialect for it; a full tag
46
+ * is kept when the catalogue knows it, and otherwise falls back to that
47
+ * language's default dialect. The last step is what rescues the measured case:
48
+ * `en-ID` is a real browser value and not a speech tag, so it resolves to
49
+ * `en-US` rather than being handed to an engine that will reject it.
50
+ */
51
+ export function toSpeechTag(code: string | null | undefined): string | undefined {
52
+ if (code === null || code === undefined) return undefined;
53
+ const trimmed = code.trim();
54
+ if (trimmed === "") return undefined;
55
+ if (KNOWN_TAGS.has(trimmed.toLowerCase())) return trimmed;
56
+ return ISO_TO_TAG.get(isoOf(trimmed));
57
+ }
58
+
59
+ /**
60
+ * Which language dictation should listen for.
61
+ *
62
+ * The order is load-bearing, and rung 2 is the one that is easy to get wrong in
63
+ * both directions:
64
+ *
65
+ * 1. the user's explicit pick
66
+ * 2. the UI locale, IF the browser also lists it as spoken
67
+ * 3. `navigator.language`
68
+ * 4. `en-US`
69
+ *
70
+ * Rung 2 exists because of a measured failure: `navigator.language` was `en-ID`
71
+ * while `navigator.languages` contained `ru` and the app was set to Russian.
72
+ * Reading position one alone threw away two independent signals and left
73
+ * dictation deaf to the language the user was actually speaking.
74
+ *
75
+ * It is deliberately NOT "use the UI locale". Pinning speech to the app locale
76
+ * is a known regression — an English UI then cannot dictate Russian. The UI
77
+ * locale is only allowed to break a tie the BROWSER has already declared
78
+ * plausible, so an English app on a browser listing no Russian still gets
79
+ * English.
80
+ */
81
+ export function resolveLanguage(signals: LanguageSignals): string {
82
+ const picked = toSpeechTag(signals.picked);
83
+ if (picked !== undefined) return picked;
84
+
85
+ const spoken = (signals.browserLanguages ?? []).map(isoOf);
86
+ const uiIso = isoOf(signals.uiLocale ?? "");
87
+ if (uiIso !== "" && spoken.includes(uiIso)) {
88
+ const tag = ISO_TO_TAG.get(uiIso);
89
+ if (tag !== undefined) return tag;
90
+ }
91
+
92
+ return toSpeechTag(signals.browserLanguage) ?? FALLBACK;
93
+ }