@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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/package.json +95 -0
- package/src/composer/composer.tsx +100 -0
- package/src/composer/context.ts +33 -0
- package/src/composer/controls/button.tsx +104 -0
- package/src/composer/controls/language-menu.tsx +123 -0
- package/src/composer/controls/send.tsx +83 -0
- package/src/composer/controls/think.tsx +50 -0
- package/src/composer/controls/tools.tsx +130 -0
- package/src/composer/controls/tooltip.tsx +10 -0
- package/src/composer/controls/voice.tsx +266 -0
- package/src/composer/field/field.tsx +140 -0
- package/src/composer/field/input.tsx +214 -0
- package/src/composer/field/keys.ts +44 -0
- package/src/composer/field/use-autosize.ts +134 -0
- package/src/composer/focus-field.ts +27 -0
- package/src/composer/index.ts +86 -0
- package/src/composer/stories/field.tsx +112 -0
- package/src/composer/stories/harness.tsx +127 -0
- package/src/composer/tools/menu.tsx +129 -0
- package/src/composer/tools/rows.tsx +180 -0
- package/src/composer/tools/use-menu-nav.ts +66 -0
- package/src/composer/tools/use-recent-tools.ts +30 -0
- package/src/composer/tray/banner.tsx +157 -0
- package/src/composer/tray/command-progress.tsx +116 -0
- package/src/composer/tray/picked-tools.tsx +104 -0
- package/src/composer/tray/queue.tsx +202 -0
- package/src/composer/tray/use-replace-text.ts +58 -0
- package/src/composer/use-draft-persistence.ts +79 -0
- package/src/content/index.ts +16 -0
- package/src/content/media-content.tsx +38 -0
- package/src/content/response.tsx +221 -0
- package/src/core/adapter.ts +29 -0
- package/src/core/capabilities.ts +286 -0
- package/src/core/chunks.ts +66 -0
- package/src/core/content/incomplete-math.ts +118 -0
- package/src/core/content/index.ts +9 -0
- package/src/core/draft-storage.ts +62 -0
- package/src/core/events.ts +88 -0
- package/src/core/focus/index.ts +10 -0
- package/src/core/focus/roving.ts +56 -0
- package/src/core/group-parts.ts +80 -0
- package/src/core/index.ts +34 -0
- package/src/core/links/index.ts +33 -0
- package/src/core/links/prose.ts +191 -0
- package/src/core/links/scheme.ts +98 -0
- package/src/core/links/tool-id.ts +26 -0
- package/src/core/media/allowed-src.ts +138 -0
- package/src/core/media/block.ts +254 -0
- package/src/core/media/index.ts +22 -0
- package/src/core/media/kinds.ts +85 -0
- package/src/core/outbound.ts +86 -0
- package/src/core/parts.ts +169 -0
- package/src/core/queue/index.ts +15 -0
- package/src/core/queue/queue.ts +100 -0
- package/src/core/recall/boundary.ts +77 -0
- package/src/core/recall/from-transcript.ts +47 -0
- package/src/core/recall/index.ts +23 -0
- package/src/core/recall/ladder.ts +171 -0
- package/src/core/reload-decision.ts +36 -0
- package/src/core/scroll/index.ts +17 -0
- package/src/core/scroll/missed.ts +106 -0
- package/src/core/scroll/sent.ts +54 -0
- package/src/core/text-replace.ts +20 -0
- package/src/core/tool-name.ts +31 -0
- package/src/core/tool-picks-set.ts +48 -0
- package/src/core/tool-picks.ts +142 -0
- package/src/core/tool-prompt.ts +47 -0
- package/src/core/tools/capability.ts +34 -0
- package/src/core/tools/catalogue.ts +109 -0
- package/src/core/tools/index.ts +17 -0
- package/src/core/tools/layout.ts +44 -0
- package/src/core/tools/nav.ts +32 -0
- package/src/core/tools/rank.ts +60 -0
- package/src/core/tools/recent.ts +39 -0
- package/src/core/transcript.ts +301 -0
- package/src/core/voice/earcons.ts +58 -0
- package/src/core/voice/index.ts +26 -0
- package/src/core/voice/language-search.ts +62 -0
- package/src/core/voice/languages.ts +267 -0
- package/src/core/voice/resolve-language.ts +93 -0
- package/src/core/voice/session.ts +196 -0
- package/src/core/voice/web-speech.ts +170 -0
- package/src/i18n/index.ts +87 -0
- package/src/i18n/locales/ar.ts +97 -0
- package/src/i18n/locales/da.ts +90 -0
- package/src/i18n/locales/de.ts +90 -0
- package/src/i18n/locales/en.ts +90 -0
- package/src/i18n/locales/es.ts +90 -0
- package/src/i18n/locales/fr.ts +90 -0
- package/src/i18n/locales/it.ts +90 -0
- package/src/i18n/locales/ja.ts +83 -0
- package/src/i18n/locales/ko.ts +83 -0
- package/src/i18n/locales/nl.ts +90 -0
- package/src/i18n/locales/no.ts +90 -0
- package/src/i18n/locales/pl.ts +96 -0
- package/src/i18n/locales/ptBR.ts +90 -0
- package/src/i18n/locales/ru.ts +96 -0
- package/src/i18n/locales/sv.ts +90 -0
- package/src/i18n/locales/tr.ts +89 -0
- package/src/i18n/locales/zh.ts +83 -0
- package/src/i18n/plural.ts +62 -0
- package/src/i18n/types.ts +257 -0
- package/src/index.ts +18 -0
- package/src/primitives/index.ts +43 -0
- package/src/primitives/message.tsx +85 -0
- package/src/primitives/part-registry.tsx +44 -0
- package/src/primitives/stories-harness.tsx +118 -0
- package/src/primitives/suggestions.tsx +112 -0
- package/src/primitives/tool-call.tsx +145 -0
- package/src/primitives/tooltip.tsx +48 -0
- package/src/primitives/transcript.tsx +285 -0
- package/src/primitives/use-dismiss.ts +100 -0
- package/src/primitives/use-missed-count.ts +30 -0
- package/src/primitives/use-overflows.ts +69 -0
- package/src/primitives/use-return-on-send.ts +29 -0
- package/src/primitives/use-roving-focus.ts +69 -0
- package/src/primitives/use-select-all-transcript.ts +111 -0
- package/src/primitives/use-stick-to-bottom.ts +83 -0
- package/src/primitives/use-turn-activity.ts +25 -0
- package/src/store/context.ts +28 -0
- package/src/store/hooks.ts +215 -0
- package/src/store/index.ts +31 -0
- package/src/store/provider.tsx +97 -0
- package/src/store/store.ts +394 -0
- package/src/store/subscribers.ts +55 -0
- package/src/store/surfaces.ts +174 -0
- package/src/store/use-draft.ts +168 -0
- package/src/store/use-queue.ts +61 -0
- package/src/store/use-recall.ts +264 -0
- package/src/store/use-reload-on-revision.ts +67 -0
- package/src/store/use-speech-language.ts +84 -0
- package/src/store/use-voice.ts +116 -0
- package/src/styles/block.css +128 -0
- package/src/styles/composer.css +449 -0
- package/src/styles/index.css +47 -0
- package/src/styles/markdown.css +368 -0
- package/src/styles/surface.css +102 -0
- package/src/styles/tool.css +128 -0
- package/src/styles/turn-mark.css +97 -0
- package/src/testing/index.ts +15 -0
- package/src/testing/mock-adapter.ts +69 -0
- package/src/testing/mock-tools.ts +101 -0
- package/src/testing/mock-voice.ts +81 -0
- package/src/ui/chat-composer.tsx +286 -0
- package/src/ui/chat-reply-chip.tsx +42 -0
- package/src/ui/chat-suggestions.tsx +87 -0
- package/src/ui/chat-transcript.tsx +157 -0
- package/src/ui/chat.tsx +197 -0
- package/src/ui/collapsible-body.tsx +180 -0
- package/src/ui/collapsible-row.tsx +81 -0
- package/src/ui/day-divider.tsx +49 -0
- package/src/ui/index.ts +54 -0
- package/src/ui/jump-to-latest.tsx +60 -0
- package/src/ui/link-chip.tsx +73 -0
- package/src/ui/message-actions.tsx +163 -0
- package/src/ui/message-quote.tsx +47 -0
- package/src/ui/message.tsx +295 -0
- package/src/ui/pending.tsx +85 -0
- package/src/ui/renderers/data.tsx +63 -0
- package/src/ui/renderers/reasoning.tsx +73 -0
- package/src/ui/renderers/tool.tsx +176 -0
- package/src/ui/turn-mark.tsx +31 -0
- package/src/utils/day-heading.ts +46 -0
- package/src/utils/error-message.ts +10 -0
- package/src/utils/index.ts +12 -0
- package/src/utils/markdown-text.ts +175 -0
- package/src/widgets/index.ts +14 -0
- 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
|
+
}
|