feat: add preferred STT languages and fix model settings layout
This commit is contained in:
@@ -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 SttLanguage } from "@/src/core";
|
||||
import { dictationUrl } from "@/src/core";
|
||||
import { LanguageSelect, useI18n } from "@/components/i18n";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
|
||||
@@ -279,10 +279,10 @@ export type Dictation = {
|
||||
* question waits.
|
||||
*/
|
||||
export function useDictation({ append }: { append: (text: string) => void }): Dictation {
|
||||
const { sttLanguage, setDictating } = useI18n();
|
||||
const selectedLanguage = useRef(sttLanguage);
|
||||
selectedLanguage.current = sttLanguage;
|
||||
const recordingLanguage = useRef<SttLanguage>(sttLanguage);
|
||||
const { sttLanguage, general, setDictating } = useI18n();
|
||||
const selectedUrl = useRef("");
|
||||
selectedUrl.current = dictationUrl(sttLanguage, general.sttPreferredLanguages);
|
||||
const recordingUrl = useRef(selectedUrl.current);
|
||||
const [phase, setPhase] = useState<DictationPhase>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
|
||||
@@ -365,7 +365,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
// Minted JWTs authenticate via the "bearer" subprotocol — ["token",
|
||||
// jwt] is rejected at the handshake (verified live; "token" is for
|
||||
// raw API keys only, as in the owner's pi dictate extension).
|
||||
const ws = new WebSocket(dictationUrl(recordingLanguage.current), ["bearer", token]);
|
||||
const ws = new WebSocket(recordingUrl.current, ["bearer", token]);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
@@ -425,7 +425,8 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
recordingLanguage.current = selectedLanguage.current;
|
||||
// Pin the complete configuration once; reconnects must retain both language and hints.
|
||||
recordingUrl.current = selectedUrl.current;
|
||||
setDictating(true);
|
||||
dictationActiveFlag = true;
|
||||
setError(null);
|
||||
|
||||
+30
-1
@@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useR
|
||||
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";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
type LanguageField = "language" | "sttLanguage";
|
||||
type I18nValue = {
|
||||
@@ -57,7 +58,7 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: {
|
||||
if (!current.ok) throw new Error("Could not load language settings");
|
||||
const body = await current.json() as { settings?: { general?: GeneralSettings } };
|
||||
const next = { ...(body.settings?.general ?? latest.current) };
|
||||
for (const field of ["language", "sttLanguage", "autoUpdate"] as const) {
|
||||
for (const field of ["language", "sttLanguage", "sttPreferredLanguages", "autoUpdate"] as const) {
|
||||
if (!Object.hasOwn(patch, field)) continue;
|
||||
if (patch[field] === undefined) delete next[field];
|
||||
else Object.assign(next, { [field]: patch[field] });
|
||||
@@ -148,3 +149,31 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
|
||||
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
|
||||
</span>;
|
||||
}
|
||||
|
||||
/** Preferred languages guide multilingual recognition; they are saved independently of its mode. */
|
||||
export function SttPreferredLanguages() {
|
||||
const { general, sttLanguage, dictating, saveGeneral, t } = useI18n();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const preferred = general.sttPreferredLanguages ?? [];
|
||||
const locked = sttLanguage !== "multi" || dictating || saving;
|
||||
const toggle = (language: Language) => {
|
||||
if (locked) return;
|
||||
const next = (["en", "ru"] as const).filter((code) => code === language ? !preferred.includes(code) : preferred.includes(code));
|
||||
setSaving(true); setError(null);
|
||||
void saveGeneral({ sttPreferredLanguages: next })
|
||||
.catch(() => setError("Could not save preferred languages"))
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
return <span className="flex max-w-full flex-col items-start gap-1">
|
||||
<span role="group" aria-label={t("Preferred languages")} className="flex max-w-full flex-wrap gap-2">
|
||||
{(["en", "ru"] as const).map((language) => <button key={language} type="button" role="checkbox" aria-checked={preferred.includes(language)}
|
||||
data-testid={`stt-preferred-${language}`} disabled={locked} onClick={() => toggle(language)}
|
||||
className={`flex h-7 items-center gap-1.5 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${preferred.includes(language) ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50"}`}>
|
||||
<span aria-hidden className="flex size-3.5 shrink-0 items-center justify-center rounded-xs border border-border">{preferred.includes(language) && <CheckIcon className="size-3" />}</span>
|
||||
{language === "en" ? "English" : "Русский"}
|
||||
</button>)}
|
||||
</span>
|
||||
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
|
||||
</span>;
|
||||
}
|
||||
|
||||
@@ -666,49 +666,51 @@ export function AgentSection({
|
||||
label={SLOT_META[slot].label}
|
||||
hint={SLOT_META[slot].roster}
|
||||
testid={`slot-row-${slot}`}
|
||||
className="flex-wrap gap-y-2"
|
||||
className="flex-wrap items-start gap-y-2 [&>span:first-child]:flex-[1_1_12rem]"
|
||||
>
|
||||
{/* The anchor for the outside-click contract is the
|
||||
span holding BOTH triggers of the open menu. */}
|
||||
span holding the tier's controls. */}
|
||||
<span
|
||||
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
||||
className="relative ml-auto flex max-w-full flex-wrap items-center justify-end gap-2"
|
||||
className="relative flex w-68 max-w-full shrink-0 flex-col items-start gap-2"
|
||||
>
|
||||
<span data-testid={`slot-controls-${slot}`} className="grid w-full grid-cols-[minmax(0,1fr)_5rem_auto] items-center gap-2">
|
||||
<MenuTrigger
|
||||
value={modelId ? prettyModel(modelId) : ""}
|
||||
emptyLabel="—"
|
||||
ariaLabel={t("{v0} model", { v0: t(SLOT_META[slot].label) })}
|
||||
testid={`slot-model-${slot}`}
|
||||
open={thisMenuOpen === "model"}
|
||||
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })}
|
||||
widthClass="w-full min-w-0"
|
||||
disabled={savingModels}
|
||||
/>
|
||||
<MenuTrigger
|
||||
// Legacy display (chunk 2 item 4): no stored level + a known
|
||||
// model → the RESOLVED level (the ordering's first supported —
|
||||
// what the engine build runs). Display-only until a pick writes.
|
||||
value={thinking || (chosen ? defaultOrderLevel(levels) : "")}
|
||||
emptyLabel="—"
|
||||
ariaLabel={t("{v0} thinking", { v0: t(SLOT_META[slot].label) })}
|
||||
testid={`slot-thinking-${slot}`}
|
||||
open={thisMenuOpen === "thinking"}
|
||||
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { target: slot, kind: "thinking" })}
|
||||
widthClass="w-20"
|
||||
disabled={savingModels}
|
||||
/>
|
||||
<Hint label={t(provider === "chatgpt" ? "Fast mode uses more ChatGPT allowance." : "Fast mode is available only for ChatGPT models.")}>
|
||||
<span className="inline-flex">
|
||||
<button type="button" data-testid={`slot-fast-${slot}`} aria-label={t("{v0} fast mode", { v0: t(SLOT_META[slot].label) })}
|
||||
aria-pressed={provider === "chatgpt" && override?.fastMode === true} disabled={provider !== "chatgpt" || !chosen || savingModels}
|
||||
onClick={() => { setOpenMenu(null); void updateModels(composeSlotFast(models, slot, !override?.fastMode)); }}
|
||||
className={cn("flex h-7 items-center gap-1 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-40",
|
||||
provider === "chatgpt" && override?.fastMode ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50")}>
|
||||
<ZapIcon className="size-3.5" aria-hidden /><span>{t("Fast")}</span>
|
||||
</button>
|
||||
</span>
|
||||
</Hint>
|
||||
</span>
|
||||
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
|
||||
<MenuTrigger
|
||||
value={modelId ? prettyModel(modelId) : ""}
|
||||
emptyLabel="—"
|
||||
ariaLabel={t("{v0} model", { v0: t(SLOT_META[slot].label) })}
|
||||
testid={`slot-model-${slot}`}
|
||||
open={thisMenuOpen === "model"}
|
||||
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })}
|
||||
widthClass="min-w-20"
|
||||
disabled={savingModels}
|
||||
/>
|
||||
<MenuTrigger
|
||||
// Legacy display (chunk 2 item 4): no stored level + a known
|
||||
// model → the RESOLVED level (the ordering's first supported —
|
||||
// what the engine build runs). Display-only until a pick writes.
|
||||
value={thinking || (chosen ? defaultOrderLevel(levels) : "")}
|
||||
emptyLabel="—"
|
||||
ariaLabel={t("{v0} thinking", { v0: t(SLOT_META[slot].label) })}
|
||||
testid={`slot-thinking-${slot}`}
|
||||
open={thisMenuOpen === "thinking"}
|
||||
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { target: slot, kind: "thinking" })}
|
||||
widthClass="w-20"
|
||||
disabled={savingModels}
|
||||
/>
|
||||
<Hint label={t(provider === "chatgpt" ? "Fast mode uses more ChatGPT allowance." : "Fast mode is available only for ChatGPT models.")}>
|
||||
<span className="inline-flex">
|
||||
<button type="button" data-testid={`slot-fast-${slot}`} aria-label={t("{v0} fast mode", { v0: t(SLOT_META[slot].label) })}
|
||||
aria-pressed={provider === "chatgpt" && override?.fastMode === true} disabled={provider !== "chatgpt" || !chosen || savingModels}
|
||||
onClick={() => { setOpenMenu(null); void updateModels(composeSlotFast(models, slot, !override?.fastMode)); }}
|
||||
className={cn("flex h-7 items-center gap-1 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-40",
|
||||
provider === "chatgpt" && override?.fastMode ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50")}>
|
||||
<ZapIcon className="size-3.5" aria-hidden /><span>{t("Fast")}</span>
|
||||
</button>
|
||||
</span>
|
||||
</Hint>
|
||||
{thisMenuOpen === "model" && (
|
||||
<MenuShell
|
||||
preferredSide="down"
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
type GraphSettings,
|
||||
type UserSettings,
|
||||
} from "@/src/core";
|
||||
import { LanguageSelect, useI18n } from "@/components/i18n";
|
||||
import { LanguageSelect, SttPreferredLanguages, useI18n } from "@/components/i18n";
|
||||
import { useAppearance } from "./appearance.tsx";
|
||||
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
|
||||
import { AgentSection } from "./agent-section.tsx";
|
||||
@@ -368,6 +368,9 @@ export function SettingsDialogBody({
|
||||
>
|
||||
<SettingsRow label={t("Interface language")} testid="row-language"><LanguageSelect field="language" /></SettingsRow>
|
||||
<SettingsRow label={t("Speech recognition language")} testid="row-stt-language"><LanguageSelect field="sttLanguage" /></SettingsRow>
|
||||
<SettingsRow label={t("Preferred languages")} hint={t("Used with Multilingual. Favors selected languages; other languages may still be recognized.")} testid="row-stt-preferred-languages" className="flex-col items-start gap-2">
|
||||
<SttPreferredLanguages />
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t("Auto-update")}
|
||||
hint={AUTO_UPDATE_HINT}
|
||||
|
||||
Reference in New Issue
Block a user