feat: add Russian localization and independent STT languages
CI / Typecheck, tests, lint (push) Canceled after 0s
CI / Standalone (mac-arm64) (push) Canceled after 0s
CI / Standalone (linux-x64) (push) Canceled after 0s
CI / Standalone (windows-x64) (push) Canceled after 0s

This commit is contained in:
2026-10-25 17:18:44 +03:00
parent b277ee3e0f
commit a50ca4b867
93 changed files with 3261 additions and 904 deletions
+43 -41
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
/**
* The Agent section (settings-app wave): the THREE MODEL SLOTS, the tool
* toggles + the search-engine preference, and the prompt override
@@ -250,6 +252,7 @@ function MenuTrigger({
/** The no-key search state grays the whole row; the trigger follows. */
disabled?: boolean;
}) {
const { t } = useI18n();
return (
<button
type="button"
@@ -263,7 +266,7 @@ function MenuTrigger({
disabled ? "cursor-not-allowed" : "hover:border-muted-foreground/50"
} ${widthClass}`}
>
<span className="min-w-0 flex-1 truncate">{value || emptyLabel}</span>
<span className="min-w-0 flex-1 truncate">{t(value || emptyLabel)}</span>
<span aria-hidden className="text-muted-foreground">
▾
</span>
@@ -303,6 +306,7 @@ export function AgentSection({
/** Writes the whole compaction section (replace-PUT discipline). */
onCompactionChange: (next: CompactionSettings) => void;
}) {
const { t } = useI18n();
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
/** The three tiers' resolved pins — GET /api/models `slots`. The rows'
* Default hints and thinking lists resolve from it. */
@@ -492,7 +496,7 @@ export function AgentSection({
>
<span>{label}</span>
<span className="text-muted-foreground">
{live === null ? (active && <span aria-hidden>✓</span>) : live ? <span aria-hidden>✓ key</span> : <span aria-hidden>· no key</span>}
{live === null ? (active && <span aria-hidden>✓</span>) : live ? <span aria-hidden>{t("✓ key")}</span> : <span aria-hidden>{t("· no key")}</span>}
</span>
</button>
);
@@ -527,7 +531,7 @@ export function AgentSection({
<Toggle
checked={info.enabled}
disabled={!fileExists}
label={`prompt ${info.path}`}
label={t("prompt {v0}", { v0: info.path })}
onChange={(on) => onSectionChange(composePromptSwitch(agent, info.path, on))}
/>
</span>
@@ -540,26 +544,26 @@ export function AgentSection({
}}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
>
{open ? "Hide text" : "Show text"}
{open ? t("Hide text") : t("Show text")}
</button>
{open && (
<div className="mt-1 flex flex-col gap-2">
<div>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">Source</p>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{t("Source")}</p>
<textarea
readOnly
value={info.sourceText}
aria-label="The original instructions"
aria-label={t("The original instructions")}
className="h-24 w-full resize-y rounded-sm border border-border bg-muted p-2 font-ui text-xs text-muted-foreground"
/>
</div>
<div>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">Override</p>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{t("Override")}</p>
<textarea
value={segmentDraft[info.path] ?? info.overrideText ?? ""}
onChange={(e) => setSegmentDraft({ ...segmentDraft, [info.path]: e.target.value })}
aria-label="Your version of the instructions"
placeholder="Add your own instructions…"
aria-label={t("Your version of the instructions")}
placeholder={t("Add your own instructions…")}
className="h-24 w-full resize-y rounded-sm border border-border bg-background p-2 font-ui text-xs text-foreground outline-none"
/>
</div>
@@ -574,16 +578,14 @@ export function AgentSection({
: "cursor-not-allowed text-xs text-muted-foreground/40 underline-offset-2"
}
>
Save
</button>
{t("Save")}</button>
{fileExists && (
<button
type="button"
onClick={() => void deleteSegment(info.path)}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
>
Delete
</button>
{t("Delete")}</button>
)}
{/* The quiet inline confirmation — next to the
action, not the old path-echoing line at the
@@ -593,7 +595,7 @@ export function AgentSection({
status reads unambiguously. */}
{segmentStatus && (
<p className="text-xs text-muted-foreground/70" data-testid="segment-status">
{segmentStatus}
{t(segmentStatus)}
</p>
)}
</div>
@@ -614,7 +616,7 @@ export function AgentSection({
return (
<>
<SettingsGroup label="Models" testid="group-models">
<SettingsGroup label={t("Models")} testid="group-models">
{MODEL_SLOTS.map((slot) => {
const override = models?.[slot];
const modelId = override?.model?.id ?? "";
@@ -653,7 +655,7 @@ export function AgentSection({
<MenuTrigger
value={modelId ? prettyModel(modelId) : ""}
emptyLabel="—"
ariaLabel={`${SLOT_META[slot].label} model`}
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" })}
@@ -665,7 +667,7 @@ export function AgentSection({
// what the engine build runs). Display-only until a pick writes.
value={thinking || (chosen ? defaultOrderLevel(levels) : "")}
emptyLabel="—"
ariaLabel={`${SLOT_META[slot].label} thinking`}
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" })}
@@ -692,8 +694,8 @@ export function AgentSection({
}}
footer={
catalog && catalog.length === 0
? "No models — add a provider key in Keys"
: "Runs every agent of this tier"
? t("No models — add a provider key in Keys")
: t("Runs every agent of this tier")
}
/>
</MenuShell>
@@ -723,7 +725,7 @@ export function AgentSection({
})}
</SettingsGroup>
<SettingsGroup label="Tools" testid="group-tools">
<SettingsGroup label={t("Tools")} testid="group-tools">
{/* The prober's graph tools (wave 7, owner ruling 2026-09-23):
FIRST row of the group — its subject is the map, its
mechanism is agent capability, so it lives in Tools but
@@ -735,15 +737,15 @@ export function AgentSection({
const graphOn = graph?.enabled ?? true;
return (
<SettingsRow
label="Graph tools"
hint={graphOn ? "Lets the tutor consult your knowledge map between sessions" : "Graph is disabled"}
label={t("Graph tools")}
hint={graphOn ? t("Lets the tutor consult your knowledge map between sessions") : t("Graph is disabled")}
testid="graph-tools-row"
dimmed={!graphOn}
>
<Toggle
checked={agent?.graphTools?.enabled ?? true}
disabled={!graphOn}
label="graph tools"
label={t("graph tools")}
onChange={(on) => onSectionChange(composeGraphTools(agent, on))}
/>
</SettingsRow>
@@ -757,13 +759,13 @@ export function AgentSection({
const free = agent?.tools?.explain === true;
return (
<SettingsRow
label="Quizzing"
hint="How both tutors check your understanding"
label={t("Quizzing")}
hint={t("How both tutors check your understanding")}
testid="quiz-mechanics-row"
>
<div
role="radiogroup"
aria-label="Quizzing mechanics"
aria-label={t("Quizzing mechanics")}
className="flex rounded-lg border border-border/60 bg-muted/30 p-0.5"
>
{(["choice", "free"] as const).map((m) => {
@@ -781,7 +783,7 @@ export function AgentSection({
active ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
{m === "choice" ? "Multiple choice" : "Free recall"}
{m === "choice" ? t("Multiple choice") : t("Free recall")}
</button>
);
})}
@@ -792,12 +794,12 @@ export function AgentSection({
{TOGGLE_TOOLS.map((id) => (
<SettingsRow
key={id}
label={`Tool · ${id}`}
label={t("Tool · {v0}", { v0: id })}
testid={`tool-row-${id}`}
>
<Toggle
checked={agent?.tools?.[id] ?? true}
label={`tool ${id}`}
label={t("tool {v0}", { v0: id })}
onChange={(on) => onSectionChange(composeTool(agent, id, on))}
/>
</SettingsRow>
@@ -808,7 +810,7 @@ export function AgentSection({
const open = openMenu?.target === "search";
return (
<SettingsRow
label="Search engine"
label={t("Search engine")}
hint={search?.reason ?? undefined}
testid="search-engine-row"
dimmed={!anyKey}
@@ -816,8 +818,8 @@ export function AgentSection({
<span ref={open ? menuAnchorRef : undefined} className="relative flex items-center gap-2">
<MenuTrigger
value={engine === "tavily" ? "Tavily" : engine === "brave" ? "Brave" : ""}
emptyLabel="Automatic"
ariaLabel="Search engine"
emptyLabel={t("Automatic")}
ariaLabel={t("Search engine")}
testid="search-engine-trigger"
open={open}
onClick={() => setOpenMenu(open ? null : { target: "search", kind: "engine" })}
@@ -831,7 +833,7 @@ export function AgentSection({
testid="search-engine-menu"
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
{engineOption("auto", "Automatic", engine === null, null, () => {
{engineOption("auto", t("Automatic"), engine === null, null, () => {
onSectionChange(composeSearchEngine(agent, null));
setOpenMenu(null);
})}
@@ -849,17 +851,17 @@ export function AgentSection({
})()}
</SettingsGroup>
<SettingsGroup label="Context" testid="group-context">
<SettingsGroup label={t("Context")} testid="group-context">
<SettingsRow
label="Compaction threshold"
hint={`${resolvedCompaction.thresholdTokens} tok`}
label={t("Compaction threshold")}
hint={t("{v0} tok", { v0: resolvedCompaction.thresholdTokens })}
testid="compaction-row-threshold"
>
<CommitInput
type="number"
value={String(compaction?.thresholdTokens ?? "")}
placeholder={String(resolvedCompaction.thresholdTokens)}
ariaLabel="compaction threshold"
ariaLabel={t("compaction threshold")}
testid="input-compaction-threshold"
onCommit={(v) => {
// Empty = factory default (the budget row's discipline):
@@ -882,9 +884,9 @@ export function AgentSection({
</SettingsRow>
</SettingsGroup>
<SettingsGroup label="System prompts" testid="group-prompts">
<SettingsGroup label={t("System prompts")} testid="group-prompts">
{prompts === null ? (
<p className="text-xs text-muted-foreground/70">Loading prompt segments…</p>
<p className="text-xs text-muted-foreground/70">{t("Loading prompt segments…")}</p>
) : (
<div className="flex flex-col gap-4">
{/* The owner's ruled order (2026-09-26): Teacher first,
@@ -894,10 +896,10 @@ export function AgentSection({
{sectionedSegments(prompts).map((section) => (
<div key={section.id} data-testid={`prompt-section-${section.id}`}>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">
{section.label}
{t(section.label)}
</p>
{section.hint && (
<p className="mb-1 text-xs text-muted-foreground/60">{section.hint}</p>
<p className="mb-1 text-xs text-muted-foreground/60">{t(section.hint)}</p>
)}
<div className="flex flex-col gap-2">
{section.segments.map((info) => segmentRow(info))}