feat: add multilingual dictation and simplify chat STT picker

This commit is contained in:
2026-10-26 00:58:25 +03:00
parent 1c6ae6b2e2
commit bdd7d5e866
10 changed files with 140 additions and 40 deletions
+2 -2
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2Icon, MicIcon } from "lucide-react";
import { useSettingsDialog } from "@/components/settings/settings-dialog";
import { dictationUrl, type Language } from "@/src/core";
import { dictationUrl, type SttLanguage } from "@/src/core";
import { LanguageSelect, useI18n } from "@/components/i18n";
import { Hint } from "@/components/ui/hint";
@@ -282,7 +282,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
const { sttLanguage, setDictating } = useI18n();
const selectedLanguage = useRef(sttLanguage);
selectedLanguage.current = sttLanguage;
const recordingLanguage = useRef<Language>(sttLanguage);
const recordingLanguage = useRef<SttLanguage>(sttLanguage);
const [phase, setPhase] = useState<DictationPhase>("idle");
const [error, setError] = useState<string | null>(null);
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
+14 -10
View File
@@ -1,19 +1,20 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { resolveLanguage, translator, type GeneralSettings, type Language, type Translator } from "@/src/core";
import { resolveLanguage, resolveSttLanguage, translator, type GeneralSettings, type Language, type SttLanguage, type Translator } from "@/src/core";
import { notifySettingsSaved, useSettingsSaved } from "./settings-signal";
import { MenuShell } from "./menu-shell";
type LanguageField = "language" | "sttLanguage";
type I18nValue = {
language: Language;
sttLanguage: Language;
sttLanguage: SttLanguage;
general: GeneralSettings;
dictating: boolean;
setDictating: (active: boolean) => void;
t: Translator;
saveGeneral: (patch: Partial<GeneralSettings>) => Promise<void>;
saveLanguage: (field: "language" | "sttLanguage", value: Language | undefined) => Promise<void>;
saveLanguage: <Field extends LanguageField>(field: Field, value: GeneralSettings[Field]) => Promise<void>;
};
const I18nContext = createContext<I18nValue>({ language: "en", sttLanguage: "en", general: {}, dictating: false, setDictating: () => {}, t: translator("en"), saveGeneral: async () => {}, saveLanguage: async () => {} });
export function useI18n(): I18nValue { return useContext(I18nContext); }
@@ -28,7 +29,7 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: {
const latest = useRef(general);
latest.current = general;
const language = resolveLanguage(general.language, detected);
const sttLanguage = resolveLanguage(general.sttLanguage, detected);
const sttLanguage = resolveSttLanguage(general.sttLanguage, detected);
const t = useMemo(() => translator(language), [language]);
useEffect(() => {
const detect = () => setDetected(resolveLanguage(undefined, navigator.languages));
@@ -70,7 +71,7 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: {
pending.current = write;
return write;
}, []);
const saveLanguage = useCallback((field: "language" | "sttLanguage", value: Language | undefined) => saveGeneral({ [field]: value }), [saveGeneral]);
const saveLanguage = useCallback(<Field extends LanguageField,>(field: Field, value: GeneralSettings[Field]) => saveGeneral({ [field]: value }), [saveGeneral]);
const value = useMemo(() => ({ language, sttLanguage, general, dictating, setDictating, t, saveGeneral, saveLanguage }), [language, sttLanguage, general, dictating, t, saveGeneral, saveLanguage]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
@@ -84,13 +85,16 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
const menuId = useId();
const locked = disabled || saving || (field === "sttLanguage" && dictating);
const menuOpen = open && !locked;
const selected = general[field] ?? "auto";
const compactStt = compact && field === "sttLanguage";
const saved = general[field] ?? "auto";
const selected = compactStt && saved === "auto" ? sttLanguage : saved;
const label = t(field === "language" ? "Interface language" : "Speech recognition language");
const testid = `language-select-${field}${compact ? "-compact" : ""}`;
const options = [
{ value: "auto", label: compact ? `${t("Auto")} · ${sttLanguage === "ru" ? "RU" : "EN"}` : t("System default") },
...(!compactStt ? [{ value: "auto", label: t("System default") } as const] : []),
{ value: "en", label: "English" },
{ value: "ru", label: "Русский" },
...(field === "sttLanguage" ? [{ value: "multi", label: t("Multilingual") } as const] : []),
] as const;
useEffect(() => { if (locked) setOpen(false); }, [locked]);
useEffect(() => {
@@ -114,11 +118,11 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
document.removeEventListener("keydown", escape, true);
};
}, [menuOpen, menuId]);
const pick = (value: "auto" | Language) => {
const pick = (value: "auto" | SttLanguage) => {
if (locked) return;
setOpen(false);
trigger.current?.focus();
if (value === selected) return;
if (value === saved) return;
setSaving(true); setError(null);
void saveLanguage(field, value === "auto" ? undefined : value).catch((e: unknown) => setError(e instanceof Error ? e.message : "Could not save language settings")).finally(() => setSaving(false));
};
@@ -130,7 +134,7 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault(); event.stopPropagation(); setOpen(true);
}}>
<span className="min-w-0 flex-1 truncate text-left">{options.find((option) => option.value === selected)?.label}</span>
<span className="min-w-0 flex-1 truncate text-left">{compact && selected === "multi" ? t("Multi") : options.find((option) => option.value === selected)?.label}</span>
<span aria-hidden className="text-muted-foreground">▾</span>
</button>
{menuOpen && <MenuShell anchorRef={trigger} testid={`${testid}-menu`} preferredSide={compact ? "up" : "down"} menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl">