+ {t("Next problem")}
);
default:
return null;
@@ -1432,7 +1432,7 @@ export function Chat({
{/* The rail GROUPS (owner round 4, 2026-10-05): the YOU heading
sits INSIDE the rail, same mono-xs register and same 1ch
rail as the thinking rows — one quote geometry. */}
-
{/* The node name(s) below the title in JetBrains Mono (owner,
2026-09-24): the name is the payload — mono suits it, and
@@ -74,14 +77,14 @@ export function DeleteNodeBody({
) : (
{nodes.slice(0, 8).map((n) => n.name).join(", ")}
- {nodes.length > 8 ? `, and ${nodes.length - 8} more` : ""}
+ {nodes.length > 8 ? t(", and {v0} more", { v0: nodes.length - 8 }) : ""}
)}
{nodes.length === 1
- ? "This permanently deletes the node — this cannot be undone."
- : `This permanently deletes all ${nodes.length} nodes — this cannot be undone.`}
+ ? t("This permanently deletes the node — this cannot be undone.")
+ : t("This permanently deletes all {v0} nodes — this cannot be undone.", { v0: nodes.length })}
{/* The session name below the title in JetBrains Mono (owner,
2026-09-24): no code-block chrome — the font already says
code. Owner, 2026-10-13 polish: normal text-sm size (was
@@ -176,9 +180,7 @@ export function DeleteSessionBody({
- This permanently removes the session — its messages, background jobs, and extraction records. Everything
- it added to the graph stays unless you check below. This cannot be undone.
-
+ {t("This permanently removes the session — its messages, background jobs, and extraction records. Everything it added to the graph stays unless you check below. This cannot be undone.")}
- {TRACES_COPY.title}
- {TRACES_COPY.body}
+ {t(TRACES_COPY.title)}
+ {t(TRACES_COPY.body)}
{failure && (
@@ -202,7 +204,7 @@ export function DeleteSessionBody({
data-testid="delete-failure"
className="mt-3 rounded-sm border border-border/60 px-3 py-2 text-xs"
>
- {failure.error}
+ {t(failure.error)}
- Try again
-
+ {t("Try again")}
)}
{/* The node dialog's exact bottom row: quiet Cancel, destructive
@@ -225,8 +226,7 @@ export function DeleteSessionBody({
disabled={busy}
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
>
- Cancel
-
+ {t("Cancel")}
- {busy ? "Deleting…" : "Delete"}
+ {busy ? t("Deleting…") : t("Delete")}
diff --git a/components/dictation.tsx b/components/dictation.tsx
index ee34b77..3dfce2d 100644
--- a/components/dictation.tsx
+++ b/components/dictation.tsx
@@ -3,6 +3,8 @@
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 { LanguageSelect, useI18n } from "@/components/i18n";
import { Hint } from "@/components/ui/hint";
/**
@@ -86,15 +88,7 @@ import { Hint } from "@/components/ui/hint";
* endpointing=300, no interims. Silence streams too (the worklet never
* pauses), so the socket never idles — no KeepAlive needed.
*/
-const DG_URL =
- "wss://api.deepgram.com/v1/listen" +
- "?model=nova-3" +
- "&encoding=linear16" +
- "&sample_rate=16000" +
- "&channels=1" +
- "&smart_format=true" +
- "&punctuate=true" +
- "&endpointing=300";
+
/** Meter tuning — the owner's tested values, see the file header. */
const METER_FLOOR_DB = -50;
@@ -285,6 +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);
const [phase, setPhase] = useState("idle");
const [error, setError] = useState(null);
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
@@ -330,6 +328,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
const cleanup = useCallback(() => {
genRef.current++;
dictationActiveFlag = false;
+ setDictating(false);
flush(); // the deferred transcript appears when the run ends (owner round 2)
if (safetyRef.current) {
clearTimeout(safetyRef.current);
@@ -366,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(DG_URL, ["bearer", token]);
+ const ws = new WebSocket(dictationUrl(recordingLanguage.current), ["bearer", token]);
wsRef.current = ws;
ws.addEventListener("open", () => {
@@ -426,6 +425,8 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
);
const start = useCallback(async () => {
+ recordingLanguage.current = selectedLanguage.current;
+ setDictating(true);
dictationActiveFlag = true;
setError(null);
setPhase("connecting");
@@ -622,6 +623,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
* (full text in the tooltip), cleared on the next click.
*/
export function DictationBar({ dictation }: { dictation: Dictation }) {
+ const { t } = useI18n();
const { phase, error, levelRef, toggle, active, clearError } = dictation;
const { open: openSettings } = useSettingsDialog();
/** The keyless click (zero-keys wave, owner dictation trio): the errored
@@ -673,12 +675,13 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
if (!active) {
return (
+
@@ -692,16 +695,18 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
toggle();
}
}}
- aria-label={error ? `Dictation failed: ${error}` : "Start dictation"}
+ aria-label={error ? t("Dictation failed: {v0}", { v0: error }) : t("Start dictation")}
data-testid="dictation-toggle"
// Same blurring as the floating chrome (owner, fix wave round 3),
// but lower opacity — the mic stays quieter than the model pills.
className="flex h-7 w-full items-center justify-center gap-1.5 rounded-md border border-border/50 bg-card/50 text-xs text-muted-foreground/70 shadow-lg backdrop-blur transition-colors hover:border-border hover:text-muted-foreground"
>
- {error ?? "Dictate"}
+ {error ? t(error) : t("Dictate")}
+
+
);
}
@@ -710,14 +715,14 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
@@ -726,13 +731,13 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
) : (
)}
- Stop
-
+ {t("Stop")}
{/* Fixed width while recording — nothing ever appends text beside it
(no interims, owner round 1). Cells carry no minimum width; the
COUNT adapts to the measured row space (owner round 2). */}
+
)}
@@ -898,7 +902,7 @@ export function GraphChromeRow({
container (36px band), between the search and Import. */}
{!isEmpty && (
-
+
- Chains
-
+ {t("Chains")}
)}
@@ -929,7 +932,7 @@ export function GraphChromeRow({
// The one button whose action isn't obvious from its label:
// the missing fact is where imports go (owner, subtlety
// round 2026-09-25).
- label="Import notes into the graph"
+ label={t("Import notes into the graph")}
side="bottom"
>
- {importing ? "Importing…" : "Import notes…"}
+ {importing ? t("Importing…") : t("Import notes…")}
))}
@@ -1652,7 +1654,7 @@ export function GraphView({
{selected.node.statement}
{selected.sourceIds.length > 0 ? (
-
Sessions
+
{t("Sessions")}
{selected.sourceIds.map((sourceId) => {
// An imported-notes file: a STATIC entry — the path,
// plain text, never a link (no session to open).
@@ -1681,7 +1683,7 @@ export function GraphView({
})}
) : (
-
No sessions recorded.
+
{t("No sessions recorded.")}
)}
)}
diff --git a/components/home.tsx b/components/home.tsx
index 4b16d62..f784243 100644
--- a/components/home.tsx
+++ b/components/home.tsx
@@ -1,4 +1,6 @@
"use client";
+import { useI18n } from "@/components/i18n";
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
@@ -30,6 +32,7 @@ export function Home({
* changing the setting applies on the next visit home. */
rendering?: boolean;
}) {
+ const { t } = useI18n();
const router = useRouter();
const [topic, setTopic] = useState("");
/* The topic MIRROR ("nothing gets lost" round, 2026-10-01 — same
@@ -236,8 +239,7 @@ export function Home({
onClick={() => setSetupOpen(true)}
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
>
- Set up your models
-
+ {t("Set up your models")}
)
)}
/>
@@ -270,7 +272,7 @@ export function Home({
void start();
}
}}
- placeholder={mode === "practice" ? "What do you want to practice?" : "What do you want to learn?"}
+ placeholder={mode === "practice" ? t("What do you want to practice?") : t("What do you want to learn?")}
autoFocus
rows={1}
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
@@ -279,7 +281,7 @@ export function Home({
affordance (owner, subtlety round 2026-09-25). */}
@@ -309,7 +311,7 @@ export function Home({
}
>
{m === "practice" ? : null}
- {m === "teach" ? "Teach" : "Practice"}
+ {m === "teach" ? t("Teach") : t("Practice")}
))}
diff --git a/components/i18n.tsx b/components/i18n.tsx
new file mode 100644
index 0000000..86806c0
--- /dev/null
+++ b/components/i18n.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { resolveLanguage, translator, type GeneralSettings, type Language, type Translator } from "@/src/core";
+import { notifySettingsSaved, useSettingsSaved } from "./settings-signal";
+
+type I18nValue = {
+ language: Language;
+ sttLanguage: Language;
+ general: GeneralSettings;
+ dictating: boolean;
+ setDictating: (active: boolean) => void;
+ t: Translator;
+ saveGeneral: (patch: Partial) => Promise;
+ saveLanguage: (field: "language" | "sttLanguage", value: Language | undefined) => Promise;
+};
+const I18nContext = createContext({ language: "en", sttLanguage: "en", general: {}, dictating: false, setDictating: () => {}, t: translator("en"), saveGeneral: async () => {}, saveLanguage: async () => {} });
+export function useI18n(): I18nValue { return useContext(I18nContext); }
+
+/** Initial settings and locale come from the server, so hydration preserves the first frame. */
+export function I18nProvider({ children, initialGeneral = {}, system = "en" }: { children: ReactNode; initialGeneral?: GeneralSettings; system?: Language }) {
+ const [general, setGeneral] = useState(initialGeneral);
+ const [dictating, setDictating] = useState(false);
+ const [detected, setDetected] = useState(system);
+ const pending = useRef>(Promise.resolve());
+ const sequence = useRef(0);
+ const latest = useRef(general);
+ latest.current = general;
+ const language = resolveLanguage(general.language, detected);
+ const sttLanguage = resolveLanguage(general.sttLanguage, detected);
+ const t = useMemo(() => translator(language), [language]);
+ useEffect(() => {
+ const detect = () => setDetected(resolveLanguage(undefined, navigator.languages));
+ detect();
+ window.addEventListener("languagechange", detect);
+ return () => window.removeEventListener("languagechange", detect);
+ }, []);
+ useEffect(() => {
+ document.documentElement.lang = language;
+ document.title = "Learning Harness";
+ const description = document.querySelector('meta[name="description"]');
+ if (description) description.setAttribute("content", t("A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know."));
+ window.lh?.setLanguage?.(language);
+ }, [language, t]);
+ useSettingsSaved((section) => {
+ if (section !== "general") return;
+ const request = ++sequence.current;
+ void fetch("/api/settings").then((r) => { if (!r.ok) throw new Error("settings"); return r.json(); })
+ .then((body: { settings?: { general?: GeneralSettings } }) => { if (request === sequence.current) setGeneral(body.settings?.general ?? {}); }).catch(() => {});
+ });
+ // Serialize writes and re-read before replacing the section: simultaneous selectors cannot lose each other's changes.
+ const saveGeneral = useCallback((patch: Partial) => {
+ const write = pending.current.catch(() => {}).then(async () => {
+ const current = await fetch("/api/settings");
+ 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) {
+ if (!Object.hasOwn(patch, field)) continue;
+ if (patch[field] === undefined) delete next[field];
+ else Object.assign(next, { [field]: patch[field] });
+ }
+ const res = await fetch("/api/settings/general", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(next) });
+ if (!res.ok) throw new Error("Could not save language settings");
+ sequence.current++;
+ setGeneral(next);
+ notifySettingsSaved("general");
+ });
+ pending.current = write;
+ return write;
+ }, []);
+ const saveLanguage = useCallback((field: "language" | "sttLanguage", value: Language | undefined) => saveGeneral({ [field]: value }), [saveGeneral]);
+ const value = useMemo(() => ({ language, sttLanguage, general, dictating, setDictating, t, saveGeneral, saveLanguage }), [language, sttLanguage, general, dictating, t, saveGeneral, saveLanguage]);
+ return {children};
+}
+
+export function LanguageSelect({ field, disabled = false, compact = false }: { field: "language" | "sttLanguage"; disabled?: boolean; compact?: boolean }) {
+ const { general, t, saveLanguage, sttLanguage, dictating } = useI18n();
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const label = t(field === "language" ? "Interface language" : "Speech recognition language");
+ return
+ {
+ const value = event.currentTarget.value;
+ setSaving(true); setError(null);
+ void saveLanguage(field, value === "auto" ? undefined : value as Language).catch((e: unknown) => setError(e instanceof Error ? e.message : "Could not save language settings")).finally(() => setSaving(false));
+ }}>
+
+
+
+
+ {error && {t(error)}}
+ ;
+}
diff --git a/components/keyboard-shortcuts.tsx b/components/keyboard-shortcuts.tsx
index e0bd488..f2ff0d8 100644
--- a/components/keyboard-shortcuts.tsx
+++ b/components/keyboard-shortcuts.tsx
@@ -1,4 +1,6 @@
"use client";
+import { useI18n } from "@/components/i18n";
+
/**
* Keyboard shortcuts (shortcut wave A, 2026-09-17) — the single document-level
@@ -151,13 +153,14 @@ export function KeyboardShortcuts() {
* Until the settings milestone ships an editor, this is how shortcuts
* stay discoverable. */
function CheatSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
+ const { t } = useI18n();
const backdropDismiss = useBackdropDismiss(onClose);
if (!open) return null;
const scopes = ["global", "chat", "graph"] as const;
const scopeLabels: Record<(typeof scopes)[number], string> = {
- global: "Global",
- chat: "Chat",
- graph: "Graph view",
+ global: t("Global"),
+ chat: t("Chat"),
+ graph: t("Graph view"),
};
return (
void }) {
onClick={(e) => e.stopPropagation()}
>
- Keyboard shortcuts
- Esc to close
+ {t("Keyboard shortcuts")}
+ {t("Esc to close")}
diff --git a/components/model-picker.tsx b/components/model-picker.tsx
index 1ae092d..0c32892 100644
--- a/components/model-picker.tsx
+++ b/components/model-picker.tsx
@@ -1,4 +1,6 @@
"use client";
+import { useI18n } from "@/components/i18n";
+
import { useCallback, useEffect, useRef, useState } from "react";
import type { ChatSettings, ModelCatalogEntry, ModelCatalogResponse, ModelsSettings, UserSettings } from "@/src/core";
@@ -82,6 +84,7 @@ export function catalogLimits(catalog: Pick): Ma
* no-factory-fallback language: the UI never presents a default model).
*/
export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) {
+ const { t } = useI18n();
const { catalog, refresh } = useModelCatalog();
const [models, setModels] = useState();
const [session, setSession] = useState();
@@ -318,7 +321,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
// Trimmed (owner, subtlety round 2026-09-25): "Model selector"
// — the long sentence was way too much. ⌘K opens this same
// menu (registry, override-aware).
- label="Model selector"
+ label={t("Model selector")}
action="open-model-picker"
side="bottom"
>
@@ -341,7 +344,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
// "Thinking" + its real shortcut via the registry (owner interlude
// 2026-09-25): ⇧⇥ cycles the level, so "Thinking (⇧⇥)" —
// rebind-live like every Hint suffix.
- label="Thinking"
+ label={t("Thinking")}
action="cycle-thinking"
side="bottom"
>
@@ -364,7 +367,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
{/* Just the level — the button reads like the model button reads
the model name (owner, stats-control round 1). */}
- {effectiveLevel === undefined ? "—" : (THINKING_LABEL[effectiveLevel] ?? effectiveLevel)}
+ {effectiveLevel === undefined ? "—" : t(THINKING_LABEL[effectiveLevel] ?? effectiveLevel)}
▾
@@ -380,7 +383,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
preferredSide="up"
menuClass="flex w-[22rem] flex-col rounded-xl border bg-card p-2 text-xs shadow-xl"
>
- {error &&
{error}
}
+ {error &&
{t(error)}
}
@@ -408,10 +411,10 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
levels={catalog ? levels : []}
effectiveLevel={effectiveLevel}
onPick={(level) => apply(effectiveProvider ?? "", effectiveId ?? "", level as ThinkingLevel)}
- empty={catalog ? (effectiveId ? `${prettyModel(effectiveId)} has no thinking levels.` : "Pick a model first.") : "Loading…"}
- footer="Levels this model actually supports"
+ empty={catalog ? (effectiveId ? t("{v0} has no thinking levels.", { v0: prettyModel(effectiveId) }) : t("Pick a model first.")) : t("Loading…")}
+ footer={t("Levels this model actually supports")}
/>
- {error &&
{error}
}
+ {error &&
{t(error)}
}
)}
diff --git a/components/new-session.tsx b/components/new-session.tsx
index c2a0930..a01bb58 100644
--- a/components/new-session.tsx
+++ b/components/new-session.tsx
@@ -1,4 +1,6 @@
"use client";
+import { useI18n } from "@/components/i18n";
+
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
@@ -110,6 +112,7 @@ export function DraftShell({
* Passed as a node so the pick state stays in NewSession. */
overlay?: ReactNode;
}) {
+ const { t } = useI18n();
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
// draft text (via the parent's onChange — the truth lives there); the
// strip lives in the stats bar's middle slot.
@@ -172,11 +175,11 @@ export function DraftShell({
@@ -205,7 +208,7 @@ export function DraftShell({
// input (owner refinement round, 2026-09-24) — no lesson has
// started yet, so the composer must not read like one is in
// flight.
- placeholder={mode === "practice" ? "What do you want to practice?" : "What do you want to learn?"}
+ placeholder={mode === "practice" ? t("What do you want to practice?") : t("What do you want to learn?")}
/>
diff --git a/components/practice-cards.tsx b/components/practice-cards.tsx
index 08cd053..17899a3 100644
--- a/components/practice-cards.tsx
+++ b/components/practice-cards.tsx
@@ -1,4 +1,6 @@
"use client";
+import { useI18n } from "@/components/i18n";
+
import { MessageResponse } from "@/components/ai-elements/message";
import { cn } from "@/lib/utils";
@@ -56,6 +58,7 @@ export interface PracticeThinkingData {
}
export function ProblemCard({ part }: ArtifactPartProps) {
+ const { t } = useI18n();
// Always open, no chevron (owner round, 2026-10-05): the record IS the
// statement — same container + eyebrow discipline as the answered card
// and the dock (which mirrors this in Slice D). The commitment never
@@ -64,7 +67,7 @@ export function ProblemCard({ part }: ArtifactPartProps) {
const statement = typeof input.statement === "string" ? input.statement : "";
return (
-
Problem
+
{t("Problem")}
{repairMathForDisplay(statement)}
@@ -94,6 +97,7 @@ const OUTCOME_CHIP: Record = {
};
export function ConcludeCard({ part }: ArtifactPartProps) {
+ const { t } = useI18n();
// The conclusion redesigned (owner round, 2026-10-05): the SOLUTION is
// the hero — the modelSolution committed at pose time (part.reveal, the
// view-seam join), shown verbatim, never re-authored. The verdict sits
@@ -111,8 +115,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
if (outcome === "dismissed") {
return (
- Problem dismissed.
-
+ {t("Problem dismissed.")}
);
}
@@ -122,10 +125,9 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
eyebrow + the outcome in the quiz cards' EXACT chip register —
no more two same-weight headings stacked. */}
{qd.question}
@@ -644,7 +648,7 @@ function QuestionDockForm({
>
{sel.dontKnow && }
- I don't know
+ {t("I don't know")}
)}
@@ -710,7 +714,7 @@ function QuestionDockForm({
submit();
}
}}
- placeholder="Type your own answer…"
+ placeholder={t("Type your own answer…")}
className="lh-input w-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
onClick={(e) => e.stopPropagation()}
/>
@@ -742,8 +746,7 @@ function QuestionDockForm({
sel.dontKnow ? "text-muted-foreground/50" : "text-muted-foreground",
)}
>
- Confidence
-
+ {t("Confidence")}
{
// Snap to the nearest stop; the rendered VALUE is the stop's own
@@ -828,7 +831,7 @@ function QuestionDockForm({
className="rounded-[10px] border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50"
>
diff --git a/components/reading-thinking.tsx b/components/reading-thinking.tsx
index 5f9ce38..5b0287f 100644
--- a/components/reading-thinking.tsx
+++ b/components/reading-thinking.tsx
@@ -1,4 +1,6 @@
"use client";
+import { useI18n } from "@/components/i18n";
+
import { useCallback } from "react";
import { joinDictation, useDictationTarget } from "@/components/dictation";
@@ -37,6 +39,7 @@ export function ReadingThinkingField({
/** Primary dictation claim — true while the check is behind its reveal. */
claim: boolean;
}) {
+ const { t } = useI18n();
useDictationTarget(
useCallback(
(t: string) => {
@@ -52,8 +55,7 @@ export function ReadingThinkingField({
className="pointer-events-auto mb-2 rounded-2xl border bg-card/60 px-3 py-2 transition-colors focus-within:border-primary/50"
>
- Thinking while reading
-
+ {t("Thinking while reading")}
diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx
index bd7c810..9c58b23 100644
--- a/components/settings/agent-section.tsx
+++ b/components/settings/agent-section.tsx
@@ -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 (
- {value || emptyLabel}
+ {t(value || emptyLabel)}
▾
@@ -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(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({
>
{label}
- {live === null ? (active && ✓) : live ? ✓ key : · no key}
+ {live === null ? (active && ✓) : live ? {t("✓ key")} : {t("· no key")}}
);
@@ -527,7 +531,7 @@ export function AgentSection({
onSectionChange(composePromptSwitch(agent, info.path, on))}
/>
@@ -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")}
{open && (
-
Source
+
{t("Source")}
-
Override
+
{t("Override")}
@@ -574,16 +578,14 @@ export function AgentSection({
: "cursor-not-allowed text-xs text-muted-foreground/40 underline-offset-2"
}
>
- Save
-
+ {t("Save")}
{fileExists && (
void deleteSegment(info.path)}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
>
- Delete
-
+ {t("Delete")}
)}
{/* 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 && (
- {segmentStatus}
+ {t(segmentStatus)}
)}
@@ -614,7 +616,7 @@ export function AgentSection({
return (
<>
-
+
{MODEL_SLOTS.map((slot) => {
const override = models?.[slot];
const modelId = override?.model?.id ?? "";
@@ -653,7 +655,7 @@ export function AgentSection({
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")
}
/>
@@ -723,7 +725,7 @@ export function AgentSection({
})}
-
+
{/* 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 (
onSectionChange(composeGraphTools(agent, on))}
/>
@@ -757,13 +759,13 @@ export function AgentSection({
const free = agent?.tools?.explain === true;
return (
- Stored on this device (lh:keymap) — overrides only.
+ {t("Stored on this device (lh:keymap) — overrides only.")}
- Reset all to defaults
-
+ {t("Reset all to defaults")}
>
);
diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx
index 3269ca8..f0cac10 100644
--- a/components/settings/keys-section.tsx
+++ b/components/settings/keys-section.tsx
@@ -1,4 +1,7 @@
"use client";
+import { translator, type Translator } from "@/src/core";
+import { useI18n } from "@/components/i18n";
+
/**
* The Keys section (wave-5 polish, 2026-09-21 — owner design): a MINIMAL
* key-value table. Provider left, masked key right, no description hints
@@ -98,11 +101,11 @@ export function classifyKeys(
/** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an
* environment-owned value — the row itself is read-only then). Pure. */
-export function keyStatusLine(status: AuthKeyStatus): string {
- if (!status.present || !status.masked) return "not set";
+export function keyStatusLine(status: AuthKeyStatus, t: Translator = translator("en")): string {
+ if (!status.present || !status.masked) return t("not set");
const tail = status.masked.replace(/ \((\d+) chars\)$/, "");
const chars = status.masked.match(/\((\d+) chars\)$/)?.[1] ?? "?";
- return `${tail} · ${chars}${status.source === "env" ? " · env" : ""}`;
+ return `${tail} · ${chars}${status.source === "env" ? t(" · env") : ""}`;
}
/** One table row: label left, tiny docs link + status right. Writable
@@ -132,6 +135,7 @@ function KeyTableRow({
onCancel: () => void;
testid: string;
}) {
+ const { t } = useI18n();
const envOwned = status.source === "env";
const [value, setValue] = useState("");
const [error, setError] = useState(null);
@@ -163,8 +167,8 @@ function KeyTableRow({
onKeyDown={(e) => {
if (e.key === "Enter") void save();
}}
- placeholder="Paste key…"
- aria-label={`${label} key`}
+ placeholder={t("Paste key…")}
+ aria-label={t("{v0} key", { v0: label })}
data-testid={`${testid}-input`}
disabled={busy}
autoFocus
@@ -177,8 +181,7 @@ function KeyTableRow({
data-testid={`${testid}-save`}
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
>
- Save
-
+ {t("Save")}
- Cancel
-
+ {t("Cancel")}
{status.present && status.source === "file" && onDelete && (
- Delete
-
+ {t("Delete")}
)}
+ {t("Loading settings…")}
) : record === null ? (
- Settings could not be loaded — refresh to retry.
-
+ {t("Settings could not be loaded — refresh to retry.")}
) : (
{active === "general" && (
+
+
void write("general", { autoUpdate: on })
}
@@ -378,13 +384,13 @@ export function SettingsDialogBody({
)}
{active === "keys" && (
-
+
)}
{active === "agent" && (
diff --git a/components/settings/subscription-connections.tsx b/components/settings/subscription-connections.tsx
index 0120200..91f1454 100644
--- a/components/settings/subscription-connections.tsx
+++ b/components/settings/subscription-connections.tsx
@@ -1,4 +1,7 @@
"use client";
+import { translator, type Translator } from "@/src/core";
+import { useI18n } from "@/components/i18n";
+
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionStatus } from "@/src/core";
import { Button } from "@/components/ui/button";
@@ -9,13 +12,14 @@ import { Toggle } from "./controls";
export const COPILOT_POLICY_NOTICE = "Pi's native Copilot sign-in may enable additional models whose policy is not yet configured. Models explicitly disabled by policy are not enabled. Organization restrictions still apply.";
export const SUBSCRIPTION_BACKGROUND_HINT = "Allow LearnHarness helper agents and automatic follow-ups to use this connection.";
-export function subscriptionNotice(label: string): string { return `Your prompts, tool results, and relevant session context are sent to ${label} to run the work you select. Credentials are stored locally in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.`; }
-export function subscriptionDisconnectCopy(label: string): string { return `Disconnect ${label} from LearnHarness? This stops work using this connection and removes its saved credentials from this device. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.`; }
+export function subscriptionNotice(label: string, t: Translator = translator("en")): string { return t("Your prompts, tool results, and relevant session context are sent to {v0} to run the work you select. Credentials are stored locally in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.", { v0: label }); }
+export function subscriptionDisconnectCopy(label: string, t: Translator = translator("en")): string { return t("Disconnect {v0} from LearnHarness? This stops work using this connection and removes its saved credentials from this device. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.", { v0: label }); }
export function emptySubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus { return { providerId, available: false, connected: false, pending: false, allowBackgroundUse: true, connectionId: null, epoch: 0, authorityRevision: 0, revision: 0 }; }
const Context = createContext({ statuses: [] as NativeSubscriptionStatus[], busy: false, open: (_providerId: NativeSubscriptionProviderId) => {} });
const CONTROL_ERROR = "The connection could not be changed. Try again.";
export function NativeSubscriptionUIProvider({ children }: { children: React.ReactNode }) {
+ const { t } = useI18n();
const [statuses, setStatuses] = useState([]);
const [dialog, setDialog] = useState(null);
const [view, setView] = useState<"notice" | "connection" | "manage">("notice");
@@ -81,58 +85,60 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
const bridge = typeof window === "undefined" ? undefined : window.lh?.subscriptions;
return
{children}
- {dialog && descriptor && status &&
+ {dialog && descriptor && status &&
- Model catalog could not be loaded. Check the connection and retry.
-
+ {t("Model catalog could not be loaded. Check the connection and retry.")}
) : (
setKeyMenuOpen((v) => !v)}
@@ -456,8 +458,7 @@ export function SetupDialog({
) : (
- Pick the model for each tier. All three are required before chats can start.
-
+ {t("Pick the model for each tier. All three are required before chats can start.")}
{SLOT_ORDER.map((slot) => {
const entry = picked[slot];
const level = levels[slot];
@@ -467,29 +468,28 @@ export function SetupDialog({
return (
)}
diff --git a/components/sidebar.tsx b/components/sidebar.tsx
index d853f5c..7f65129 100644
--- a/components/sidebar.tsx
+++ b/components/sidebar.tsx
@@ -1,4 +1,7 @@
"use client";
+import { translator, type Language } from "@/src/core";
+import { useI18n } from "@/components/i18n";
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
@@ -66,6 +69,7 @@ declare global {
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
onZoom?: (cb: (factor: number) => void) => () => void;
getZoom?: () => Promise;
+ setLanguage?: (language: "en" | "ru") => void;
/** Auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes once a staged update exists. */
onUpdateStatus?: (cb: (status: { status: string; version: string | null }) => void) => () => void;
installUpdate?: () => void;
@@ -188,6 +192,7 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) {
* The icon fades with the slot and is clipped by it as it collapses —
* no popping in either direction. */
export function SidebarToggle() {
+ const { t } = useI18n();
const { open, toggle } = useSidebar();
return (
-
+ ) => void;
}) {
+ const { t, language } = useI18n();
return (
{chat.title}
- {sessionMeta(chat)}
+ {sessionMeta(chat, language)}
{/* The dumbbell rides the FAR RIGHT (owner round 6, 2026-10-05) —
was left of the name; right-aligned keeps the titles flush with
the teaching rows and reads cleaner. */}
{(chat.kind ?? "teach") === "practice" && (
-
+
)}
);
@@ -327,6 +336,7 @@ export function SessionRow({
* "replace", the header rename's rule), Enter/blur commits, Esc discards.
* The meta line stays so the row never changes height mid-edit. */
export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; onCommit: (next: string) => void; onCancel: () => void }) {
+ const { t, language } = useI18n();
const [draft, setDraft] = useState(chat.title);
const inputRef = useRef(null);
useEffect(() => {
@@ -362,11 +372,11 @@ export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; on
onCancel();
}
}}
- aria-label="Rename session"
+ aria-label={t("Rename session")}
data-testid="session-rename-input"
className="lh-input w-full min-w-0 truncate border-0 border-b border-border bg-transparent px-0 text-foreground outline-none"
/>
- {sessionMeta(chat)}
+ {sessionMeta(chat, language)}
);
@@ -379,6 +389,7 @@ export type SessionKindFilter = "teach" | "all" | "practice";
const KIND_FILTER_KEY = "lh:session-kind-filter";
function SessionsSidebar() {
+ const { t } = useI18n();
const { open, toggle } = useSidebar();
const [chats, setChats] = useState(null);
const [query, setQuery] = useState("");
@@ -656,23 +667,23 @@ function SessionsSidebar() {
// of the logo, carrying its real shortcut: ⌘G jumps to
// the graph view (keyboard-shortcuts routes it to "/",
// the same destination as this logo).
- label="Home"
+ label={t("Home")}
action="jump-graph"
side="right"
>
-
+
@@ -693,8 +704,7 @@ function SessionsSidebar() {
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
- Teaching
- create a new teaching session
+ {t("Teaching")}{t("create a new teaching session")}
- Practice
- create a new practice session
+ {t("Practice")}{t("create a new practice session")}
);
}
diff --git a/components/usage-format.ts b/components/usage-format.ts
index 3a400cd..f595740 100644
--- a/components/usage-format.ts
+++ b/components/usage-format.ts
@@ -1,10 +1,12 @@
+import { localeTag, type Language } from "@/src/core";
/**
* Money/token presentation helpers (wave-4 observability UI) — shared by the
* activity dock and the stats bar. Pure; exported for tests.
*/
/** USD for cent-scale money: >= $1 keeps two decimals, smaller keeps four. */
-export function formatUsd(c: number): string {
+export function formatUsd(c: number, language: Language = "en"): string {
+ if (language === "ru") return `${new Intl.NumberFormat(localeTag(language), { minimumFractionDigits: c >= 1 ? 2 : 4, maximumFractionDigits: c >= 1 ? 2 : 4 }).format(c)} $`;
return c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(4)}`;
}
@@ -14,7 +16,8 @@ export function planCostLabel(dollars: number | null, subscription: boolean | st
}
/** 38000 → "38k", 1234567 → "1.2M" — trimmed, never "38.0k". */
-export function formatTokens(n: number): string {
+export function formatTokens(n: number, language: Language = "en"): string {
+ if (language === "ru") return new Intl.NumberFormat("ru-RU", { notation: "compact", maximumFractionDigits: 1 }).format(n);
if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`;
if (n >= 1e3) return `${+(n / 1e3).toFixed(1)}k`;
return String(n);
diff --git a/docs/localization.md b/docs/localization.md
new file mode 100644
index 0000000..431bfa5
--- /dev/null
+++ b/docs/localization.md
@@ -0,0 +1,54 @@
+# Language support
+
+LearnHarness supports English and Russian. Settings → General contains independent
+interface and speech recognition language selectors. The microphone also has a
+speech language selector. Each offers System default, English, and Русский.
+
+System default uses the preferred browser language (the operating system's
+preferred language in the desktop app); Russian locales select Russian and other
+locales select English. Explicit choices persist in `settings.json` as
+`general.language` and `general.sttLanguage`. Removing a field restores automatic
+selection. Changing one preference preserves the other general settings.
+
+The interface changes immediately, including accessibility labels and the desktop
+menu. New AI teaching, questions, plans, practice, graph descriptions, research,
+figures, and demos use the interface language. Existing conversations and imported
+source material retain their authored text. A session retains the language of its
+latest foreground turn so automatic follow-ups can use the same language after a
+restart; an explicit app language preference takes precedence.
+
+Speech recognition uses Deepgram Nova-3 with an explicit `language=en` or
+`language=ru`. A recording captures the selected language when it starts and keeps
+that language through reconnections. The selector is disabled while recording;
+changes elsewhere take effect on the next recording. Audio capture, deferred
+transcript insertion, stopping, and cancellation retain their existing behavior.
+
+## Maintaining translations
+
+The pure localization functions live in `src/core/i18n.ts` and are exported through
+the core barrel. English and Russian catalogs are in `src/core/locales/en.json` and
+`ru.json`. English source phrases are message keys. Add every new app-owned message
+to both catalogs, use named interpolation parameters, and use plural entries with
+`Intl.PluralRules` for counts. Russian entries may use `one`, `few`, `many`, and
+`other` forms. Keep code, provider/model names, identifiers, and user-authored text
+out of translation lookups.
+
+React components use `useI18n()` from `components/i18n.tsx`. The root provider gets
+server-resolved initial settings and locale to preserve hydration. General-setting
+writes share a serialized read-modify-write path; other surfaces use the existing
+settings-saved signal. Server and agent code receive an explicit language rather
+than depending on a mutable global translation state.
+
+Some existing protocol errors arrive preformatted in English. Localization can
+recognize cataloged application templates at the display boundary, preserving the
+canonical error values used for classification and any third-party diagnostics.
+Raw technical instructions, code, and user overrides remain editable source data.
+
+The desktop shell has its own small catalog in `electron/language.cjs`, packaged
+with the shell. Its narrow `lh:language` channel accepts only `en` and `ru` from a
+trusted application window. Russian prose uses Noto Sans because Source Sans 3
+has no Cyrillic subset. Explicit font choices continue to take precedence.
+
+`tests/i18n.test.tsx` covers preference validation, automatic selection, independent
+STT requests, catalog parity and interpolation, Russian plural forms, SSR locale
+isolation, translated dialogs and exports, agent instructions, and native labels.
diff --git a/electron/language.cjs b/electron/language.cjs
new file mode 100644
index 0000000..ce732e6
--- /dev/null
+++ b/electron/language.cjs
@@ -0,0 +1,13 @@
+/** Native-shell catalog. Kept with the shell so packaged builds do not load server sources. */
+const ru = {
+ "Check for Updates…": "Проверить обновления…", "Edit": "Правка", "View": "Вид", "Window": "Окно",
+ "Actual Size": "Исходный масштаб", "Zoom In": "Увеличить масштаб", "Zoom Out": "Уменьшить масштаб",
+ "About LearnHarness": "О LearnHarness", "Hide LearnHarness": "Скрыть LearnHarness", "Hide Others": "Скрыть остальные", "Quit LearnHarness": "Выйти из LearnHarness",
+ "Undo": "Отменить", "Redo": "Повторить", "Cut": "Вырезать", "Copy": "Копировать", "Paste": "Вставить", "Select All": "Выбрать всё",
+ "Reload": "Перезагрузить", "Developer Tools": "Инструменты разработчика", "Toggle Full Screen": "Полноэкранный режим", "Minimize": "Свернуть", "Close": "Закрыть",
+ "You're up to date.": "Установлена последняя версия.", "Couldn't check for updates.": "Не удалось проверить обновления.",
+ "LearnHarness {version} is the latest version.": "LearnHarness {version} — последняя версия.", "OK": "ОК",
+};
+function resolveLanguage(preference, languages = []) { return preference === "ru" || preference !== "en" && /^ru(?:-|_|$)/i.test(languages[0] || "") ? "ru" : "en"; }
+function translate(language, text, params = {}) { return (language === "ru" ? ru[text] || text : text).replace(/\{(\w+)\}/g, (match, key) => Object.hasOwn(params, key) ? String(params[key]) : match); }
+module.exports = { resolveLanguage, translate };
diff --git a/electron/main.cjs b/electron/main.cjs
index 46db548..4ba7bb0 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -214,6 +214,7 @@ function startServer(port) {
PORT: String(port),
HOSTNAME: "127.0.0.1", // the server defaults to 0.0.0.0 — never
LH_DATA_DIR: DATA_DIR(),
+ LH_SYSTEM_LANGUAGE: app.getPreferredSystemLanguages()[0] || "en",
// The auth/keys store — the child reads AND writes this file itself
// (no env injection anymore: a spawn-time copy would shadow every
// settings-page key change until restart; env wins over file).
@@ -456,11 +457,11 @@ function checkForUpdates({ manual = false } = {}) {
// update-downloaded. Manual checks additionally answer directly.
if (!manual) return;
const version = result?.updateInfo?.version;
- if (!version) dialog.showMessageBox({ type: "info", message: "You're up to date.", detail: `LearnHarness ${app.getVersion()} is the latest version.` });
+ if (!version) dialog.showMessageBox({ type: "info", message: nt("You're up to date."), detail: nt("LearnHarness {version} is the latest version.", { version: app.getVersion() }), buttons: [nt("OK")] });
})
.catch((err) => {
log(`update check failed: ${err?.message ?? err}`);
- if (manual) dialog.showMessageBox({ type: "warning", message: "Couldn't check for updates.", detail: String(err?.message ?? err) });
+ if (manual) dialog.showMessageBox({ type: "warning", message: nt("Couldn't check for updates."), buttons: [nt("OK")], detail: String(err?.message ?? err) });
});
}
@@ -524,53 +525,63 @@ function applyZoom(win, factor) {
/** macOS needs an application menu with the Edit roles — without one,
* Cmd+C/V/A do nothing in the renderer. Everything else stays minimal. */
+const { resolveLanguage: resolveShellLanguage, translate: translateShell } = require("./language.cjs");
+let shellLanguage;
+function nativeLanguage() {
+ if (shellLanguage) return shellLanguage;
+ let preference;
+ try { preference = JSON.parse(fs.readFileSync(path.join(DATA_DIR(), "settings.json"), "utf8")).general?.language; } catch {}
+ return resolveShellLanguage(preference, app.getPreferredSystemLanguages());
+}
+function nt(text, params) { return translateShell(nativeLanguage(), text, params); }
+
function installMenu() {
const template = [
{
label: app.name,
submenu: [
- { role: "about" },
+ { role: "about", label: nt("About LearnHarness") },
// Manual check (Wave C): answers via native dialog — no renderer
// round-trip. The 24h automatic checks stay silent.
- { label: "Check for Updates…", click: () => checkForUpdates({ manual: true }) },
+ { label: nt("Check for Updates…"), click: () => checkForUpdates({ manual: true }) },
{ type: "separator" },
- { role: "hide" },
- { role: "hideOthers" },
+ { role: "hide", label: nt("Hide LearnHarness") },
+ { role: "hideOthers", label: nt("Hide Others") },
{ type: "separator" },
- { role: "quit" },
+ { role: "quit", label: nt("Quit LearnHarness") },
],
},
- { label: "Edit", submenu: [{ role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, { role: "selectAll" }] },
+ { label: nt("Edit"), submenu: [{ role: "undo", label: nt("Undo") }, { role: "redo", label: nt("Redo") }, { type: "separator" }, { role: "cut", label: nt("Cut") }, { role: "copy", label: nt("Copy") }, { role: "paste", label: nt("Paste") }, { role: "selectAll", label: nt("Select All") }] },
{
- label: "View",
+ label: nt("View"),
submenu: [
- { role: "reload" },
- { role: "toggleDevTools" },
+ { role: "reload", label: nt("Reload") },
+ { role: "toggleDevTools", label: nt("Developer Tools") },
{ type: "separator" },
// OWNED zoom (see applyZoom): the roles change zoomLevel
// silently; these broadcast the factor and re-seat the
// traffic lights. The renderer's --lh-zoom depends on
// hearing every change.
{
- label: "Actual Size",
+ label: nt("Actual Size"),
accelerator: "CommandOrControl+0",
click: () => applyZoom(BrowserWindow.getAllWindows()[0], 1),
},
{
- label: "Zoom In",
+ label: nt("Zoom In"),
accelerator: "CommandOrControl+Plus",
click: () => zoomStep(BrowserWindow.getAllWindows()[0], +1),
},
{
- label: "Zoom Out",
+ label: nt("Zoom Out"),
accelerator: "CommandOrControl+-",
click: () => zoomStep(BrowserWindow.getAllWindows()[0], -1),
},
{ type: "separator" },
- { role: "togglefullscreen" },
+ { role: "togglefullscreen", label: nt("Toggle Full Screen") },
],
},
- { role: "windowMenu" },
+ { label: nt("Window"), submenu: [{ role: "minimize", label: nt("Minimize") }, { role: "close", label: nt("Close") }] },
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}
@@ -647,6 +658,13 @@ async function main() {
});
installMenu();
+ ipcMain.on("lh:language", (event, language) => {
+ if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) return;
+ if (language !== "en" && language !== "ru") return;
+ if (shellLanguage === language) return;
+ shellLanguage = language;
+ installMenu();
+ });
// Auto-update (Wave C): wire the updater before the window so the
// banner channel is live from first paint; the first check and the 24h
diff --git a/electron/preload.cjs b/electron/preload.cjs
index 4912005..513b3b2 100644
--- a/electron/preload.cjs
+++ b/electron/preload.cjs
@@ -29,6 +29,7 @@ const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("lh", {
platform: process.platform,
+ setLanguage: (language) => ipcRenderer.send("lh:language", language),
chatgpt: {
status: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "status" }),
login: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "login", id }),
diff --git a/src/agent/subagents/demo-builder.ts b/src/agent/subagents/demo-builder.ts
index 1d203df..949cef4 100644
--- a/src/agent/subagents/demo-builder.ts
+++ b/src/agent/subagents/demo-builder.ts
@@ -1,3 +1,4 @@
+import { learningLanguageInstruction, type Language } from "../../core/i18n.ts";
import { Type } from "typebox";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { Usage } from "@earendil-works/pi-ai";
@@ -31,6 +32,7 @@ export interface DemoBuilderInput {
}
export interface DemoBuilderOptions {
+ language?: Language;
/** Test seam: scripted engine; production uses the demoBuilder role. */
engine?: PiEngine;
headless?: HeadlessApi;
@@ -207,7 +209,7 @@ export async function runDemoBuilder(input: DemoBuilderInput, opts: DemoBuilderO
const result = await runSubagent(Object.values(createDemoBuilderTools(state, headless, opts.onActivity)), {
role: "demoBuilder",
engine: opts.engine,
- systemPrompt: INSTRUCTIONS,
+ systemPrompt: `${INSTRUCTIONS}\n\n${learningLanguageInstruction(opts.language ?? "en")}`,
prompt,
maxTurns: opts.maxSteps ?? 24,
abortSignal: opts.abortSignal,
diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts
index aca547f..5d197fb 100644
--- a/src/agent/subagents/extraction.ts
+++ b/src/agent/subagents/extraction.ts
@@ -1,3 +1,4 @@
+import { learningLanguageInstruction, type Language } from "../../core/i18n.ts";
import * as crypto from "node:crypto";
import type { Usage } from "@earendil-works/pi-ai";
import type { AgentTool } from "@earendil-works/pi-agent-core";
@@ -471,6 +472,7 @@ function runExtractor(
batchIds: ReadonlySet,
signal: AbortSignal | undefined,
onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void,
+ language: Language = "en",
): Promise {
// Chains in (the wire), chains out (the store) — no translation seam
// since chains-primary. Validation runs at the submit seam (error-back)
@@ -486,7 +488,7 @@ function runExtractor(
parameters: sessionFindingsSchema,
fill: fillFindings,
validate: (value) => validateSessionAnchors(value, batchIds) ?? validateFindingsChains(value, existingIds),
- systemPrompt: EXTRACTOR_INSTRUCTIONS,
+ systemPrompt: `${EXTRACTOR_INSTRUCTIONS}\n\n${learningLanguageInstruction(language)}`,
prompt,
engine,
signal,
@@ -507,6 +509,7 @@ function runNotesExtractor(
existingIds: ReadonlySet,
signal: AbortSignal | undefined,
onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void,
+ language: Language = "en",
): Promise {
return runSubmitAgent({
role: "extractor",
@@ -517,7 +520,7 @@ function runNotesExtractor(
parameters: findingsSchema,
fill: fillNotesFindings,
validate: (value) => validateFindingsChains(value, existingIds),
- systemPrompt: NOTES_EXTRACTOR_INSTRUCTIONS,
+ systemPrompt: `${NOTES_EXTRACTOR_INSTRUCTIONS}\n\n${learningLanguageInstruction(language)}`,
prompt,
engine,
signal,
@@ -725,6 +728,7 @@ export interface ExtractionModels {
}
export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels {
+ languageForSource?: (sourceId: string) => Language;
/** The chunk budget — the transcript-token share of the extractor's
* context; batches fill to it (the trigger fires on the same number,
* read fresh per turn by the composition root). OPTIONAL with the
@@ -735,6 +739,7 @@ export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels {
}
export interface NotesImportInput {
+ language?: Language;
/** The source's file path — its identity (deterministic src id) AND its
* provenance label in the detail card. */
filePath: string;
@@ -940,6 +945,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
recordStep(jobKey, "extractor", callUsage(usage), model);
console.info(`[extraction] extractor step ${step}: ${finishReason}, tools: ${tools}`);
},
+ deps.languageForSource?.(sourceId) ?? chatStore.getChat(sourceId)?.language ?? "en",
);
console.info(`[extraction ${jobKey}] extractor done in ${Math.round(performance.now() - t0)}ms`);
return findings;
@@ -1262,7 +1268,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
async function notesExtract(
jobKey: string,
- input: { id: string; filePath: string; text: string; part?: { i: number; n: number } },
+ input: { id: string; filePath: string; text: string; language?: Language; part?: { i: number; n: number } },
): Promise {
// jobKey (per-chunk jobs, owner 2026-09-28): the registry key —
// "src#i" for chunk jobs — so the abort signal reaches the call.
@@ -1290,6 +1296,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
recordStep(jobKey, "extractor", callUsage(usage), model);
console.info(`[extraction] extractor step ${step}: ${finishReason}, tools: ${tools}`);
},
+ input.language ?? deps.languageForSource?.(input.id) ?? "en",
);
console.info(`[extraction ${jobKey}] notes extractor done in ${Math.round(performance.now() - t0)}ms`);
return findings;
@@ -1298,7 +1305,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
// The notes auditor is the SAME identity-linker as the chat one (one
// schema now that attribution is gone) — audit() above, verbatim.
- async function runNotesExtraction(input: { id: string; filePath: string; text: string; importedAt: string }): Promise {
+ async function runNotesExtraction(input: { id: string; filePath: string; text: string; importedAt: string; language?: Language }): Promise {
const { id } = input;
// importNotes owns the source exclusion, including metadata and cleanup.
const t0 = performance.now();
diff --git a/src/agent/subagents/pdf-reader.ts b/src/agent/subagents/pdf-reader.ts
index 462a3e3..140b5df 100644
--- a/src/agent/subagents/pdf-reader.ts
+++ b/src/agent/subagents/pdf-reader.ts
@@ -1,3 +1,4 @@
+import { learningLanguageInstruction, type Language } from "../../core/i18n.ts";
import { randomUUID } from "node:crypto";
import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai";
import { normalizeContext } from "@earendil-works/pi-ai";
@@ -62,6 +63,7 @@ const RENDER_BATCH_CAP = 6;
const EXTRACT_CAP = 24_000;
export interface PdfReaderOptions {
+ language?: Language;
engine?: PiEngine;
abortSignal?: AbortSignal;
onUsage?: (usage: Usage) => void;
@@ -206,7 +208,7 @@ export async function runPdfReaderAgent(
for (let iteration = 0; iteration < maxIterations; iteration++) {
const result = await streamOnce(
engine,
- INSTRUCTIONS,
+ `${INSTRUCTIONS}\n\n${learningLanguageInstruction(opts.language ?? "en")}`,
messages,
toolDeclarations,
sessionId,
diff --git a/src/agent/subagents/researcher.ts b/src/agent/subagents/researcher.ts
index c3a979f..e824ebe 100644
--- a/src/agent/subagents/researcher.ts
+++ b/src/agent/subagents/researcher.ts
@@ -1,3 +1,4 @@
+import { learningLanguageInstruction, type Language } from "../../core/i18n.ts";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { Usage } from "@earendil-works/pi-ai";
import { createWebTools } from "../tools/web.ts";
@@ -68,6 +69,7 @@ Numbered findings with inline source citations:
What couldn't be answered. Suggested next steps.`;
export interface ResearcherRunOptions {
+ language?: Language;
engine?: PiEngine;
/** The search seam (search port wave, 2026-09-20): resolves the LIVE
* provider per call, null = honestly unavailable (the web_search tool
@@ -98,7 +100,7 @@ export async function runResearcherAgent(prompt: string, opts: ResearcherRunOpti
role: "researcher",
prompt,
engine: opts.engine,
- systemPrompt: INSTRUCTIONS,
+ systemPrompt: `${INSTRUCTIONS}\n\n${learningLanguageInstruction(opts.language ?? "en")}`,
maxTurns: 12,
abortSignal: opts.abortSignal,
onUsage: opts.onUsage,
diff --git a/src/agent/subagents/runner.ts b/src/agent/subagents/runner.ts
index 41a3d40..3f8ca73 100644
--- a/src/agent/subagents/runner.ts
+++ b/src/agent/subagents/runner.ts
@@ -1,3 +1,4 @@
+import type { Language } from "../../core/i18n.ts";
import type { PiEngine } from "../models.ts";
import type { SearchProvider } from "../../core/search.ts";
import {
@@ -64,6 +65,7 @@ export const pdfModelOverride: { current?: PiEngine } = {};
type JobToolInput = ResearchToolInput | DemoToolInput | VisualToolInput | PdfToolInput;
export interface JobRunnerDeps {
+ languageForChat?: (chatId: string) => Language;
chatStore: ChatStore;
jobStore: JobStore;
/**
@@ -364,6 +366,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
jobStore.updateJob(jobId, { activity: "researching" });
const brief = await runResearcherAgent(prompt, {
engine: model,
+ language: deps.languageForChat?.(job.chatId) ?? chatStore.getChat(job.chatId)?.language ?? "en",
search: deps.searchProvider,
pdfDir,
abortSignal: loop.abortSignal,
@@ -417,6 +420,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
{ title, brief },
{
engine: model,
+ language: deps.languageForChat?.(job.chatId) ?? chatStore.getChat(job.chatId)?.language ?? "en",
abortSignal: loop.abortSignal,
chatId: loop.chatId,
onUsage: (usage) => loop.onUsage(piUsageToStep(usage)),
@@ -453,6 +457,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
{ brief },
{
engine: model,
+ language: deps.languageForChat?.(job.chatId) ?? chatStore.getChat(job.chatId)?.language ?? "en",
abortSignal: loop.abortSignal,
chatId: loop.chatId,
onUsage: (usage) => loop.onUsage(piUsageToStep(usage)),
@@ -492,6 +497,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
{ path, question },
{
engine: model,
+ language: deps.languageForChat?.(job.chatId) ?? chatStore.getChat(job.chatId)?.language ?? "en",
abortSignal: loop.abortSignal,
chatId: loop.chatId,
onUsage: (usage) => loop.onUsage(piUsageToStep(usage)),
diff --git a/src/agent/subagents/visual-maker.ts b/src/agent/subagents/visual-maker.ts
index 8daac95..20943e0 100644
--- a/src/agent/subagents/visual-maker.ts
+++ b/src/agent/subagents/visual-maker.ts
@@ -1,3 +1,4 @@
+import { learningLanguageInstruction, type Language } from "../../core/i18n.ts";
import { randomUUID } from "node:crypto";
import type { AssistantMessage, Message, Tool, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
import { normalizeContext } from "@earendil-works/pi-ai";
@@ -35,6 +36,7 @@ export interface VisualMakerInput {
}
export interface VisualMakerOptions {
+ language?: Language;
/** Test seam: scripted engine; production uses the visualMaker role. */
engine?: PiEngine;
headless?: HeadlessApi;
@@ -163,7 +165,7 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
// loop owns tool execution and the image-in-the-loop injection).
const result = await streamOnce(
engine,
- INSTRUCTIONS,
+ `${INSTRUCTIONS}\n\n${learningLanguageInstruction(opts.language ?? "en")}`,
messages,
toolDeclarations,
sessionId,
diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts
index 08caa42..b9aa2d7 100644
--- a/src/agent/teacher-agent.ts
+++ b/src/agent/teacher-agent.ts
@@ -1,3 +1,4 @@
+import { learningLanguageInstruction, type Language } from "../core/i18n.ts";
import { Agent } from "@earendil-works/pi-agent-core";
import type { AgentEvent, AgentMessage, AgentTool } from "@earendil-works/pi-agent-core";
import { invalidInputResult, questionResult } from "./tool-execute.ts";
@@ -97,6 +98,7 @@ export type StepUsageCollector = (usage: Usage) => void;
/** Per-request construction options (the handler wires all of these). */
export interface AgentBuildOptions {
+ language?: Language;
/** Model + stream function — tests inject a scripted faux engine. */
engine?: PiEngine;
callPurpose?: { background: boolean };
@@ -175,9 +177,10 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
// ("practice" is not a ModelRole) honest about that.
const engine = opts.engine ?? piEngineFor(kind as "prober" | "teacher");
const stopper = makeQuestionStopper();
- const systemPrompt = opts.promptSwitches
+ const basePrompt = opts.promptSwitches
? composeInstructions(kind, opts.promptSwitches)
: composedInstructions[kind]!;
+ const systemPrompt = `${basePrompt}\n\n${learningLanguageInstruction(opts.language ?? "en")}`;
// Debug recorder (owner, 2026-10-04): raw provider events when
// LH_PROVIDER_EVENTS=1 and the caller handed a chatId; undefined is
// the normal no-observer path (tests, env-off).
diff --git a/src/core/contracts/chat.ts b/src/core/contracts/chat.ts
index cf67a6b..0dd3452 100644
--- a/src/core/contracts/chat.ts
+++ b/src/core/contracts/chat.ts
@@ -28,6 +28,8 @@ import type { EmissionStats } from "./graph.ts";
export type ChatKind = "teach" | "practice";
export interface ChatRecord {
+ /** Effective language from the latest foreground turn, retained for automatic follow-ups. */
+ language?: "en" | "ru";
id: string;
title: string;
createdAt: string;
diff --git a/src/core/i18n.ts b/src/core/i18n.ts
new file mode 100644
index 0000000..b40a4d3
--- /dev/null
+++ b/src/core/i18n.ts
@@ -0,0 +1,69 @@
+/** Pure localization shared by browser, server, exports, and agent configuration. */
+import en from "./locales/en.json";
+import ru from "./locales/ru.json";
+export type Language = "en" | "ru";
+export type TranslationParams = Record;
+export type Translator = (message: string | null | undefined, params?: TranslationParams) => string;
+export function isLanguage(value: unknown): value is Language { return value === "en" || value === "ru"; }
+
+/** The preferred system language wins; unsupported preferences resolve to English. */
+export function systemLanguage(languages: string | readonly string[] | null | undefined): Language {
+ const first = typeof languages === "string"
+ ? languages.split(",").map((part, index) => {
+ const [tag = "", ...options] = part.trim().split(";");
+ const q = options.find((option) => option.trim().startsWith("q="));
+ return { tag, q: q ? Number(q.trim().slice(2)) : 1, index };
+ }).filter((item) => Number.isFinite(item.q) && item.q > 0).sort((a, b) => b.q - a.q || a.index - b.index)[0]?.tag
+ : languages?.[0];
+ return /^ru(?:-|_|$)/i.test(first ?? "") ? "ru" : "en";
+}
+export function resolveLanguage(preference: unknown, system?: string | readonly string[] | null): Language {
+ return isLanguage(preference) ? preference : systemLanguage(system);
+}
+export function localeTag(language: Language): string { return language === "ru" ? "ru-RU" : "en-US"; }
+
+type Entry = string | Partial>;
+const catalog: Record = ru;
+// Some protocol errors arrive as preformatted English. Match only cataloged
+// app-authored templates; arbitrary provider diagnostics remain untouched.
+const canonicalTemplates = Object.keys(catalog).filter((key) => /\{v\d+\}/.test(key) && key.replace(/\{\w+\}/g, "").replace(/[^a-zA-Z]/g, "").length >= 8).map((key) => {
+ const names: string[] = [];
+ const pattern = key.split(/(\{v\d+\})/g).map((part) => {
+ if (/^\{v\d+\}$/.test(part)) { names.push(part.slice(1, -1)); return "([\\s\\S]*?)"; }
+ return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ }).join("");
+ return { key, names, regex: new RegExp(`^${pattern}$`) };
+});
+export function translate(language: Language, message: string | null | undefined, params: TranslationParams = {}): string {
+ if (message == null) return "";
+ if (language === "ru" && message.startsWith("{")) {
+ try { const body = JSON.parse(message) as { error?: unknown }; if (typeof body.error === "string") return translate(language, body.error, params); } catch {}
+ }
+ const entry = language === "ru" ? catalog[message] : (en as Record)[message];
+ if (language === "ru" && entry === undefined && Object.keys(params).length === 0) {
+ for (const template of canonicalTemplates) {
+ const match = template.regex.exec(message);
+ if (match) return translate(language, template.key, Object.fromEntries(template.names.map((name, index) => [name, match[index + 1] ?? ""])));
+ }
+ }
+ const text = typeof entry === "string" ? entry : entry
+ ? entry[new Intl.PluralRules(localeTag(language)).select(Number(params.count ?? 0))] ?? entry.other ?? message
+ : message;
+ return text.replace(/\{(\w+)\}/g, (match, key: string) => Object.hasOwn(params, key) ? String(params[key] ?? "") : match);
+}
+export function translator(language: Language): Translator { return (message, params) => translate(language, message, params); }
+export function learningLanguageInstruction(language: Language): string {
+ return language === "ru"
+ ? "LANGUAGE: Use Russian for all learner-facing content: teaching, questions and answer choices, plans and titles, practice statements, hints, verdicts and solutions, research summaries, knowledge-graph propositions, and all text in generated diagrams, figures and interactive demos. Use natural Russian and address the learner as вы. Preserve code, mathematical notation, tool names, schema keys, identifiers, proper names and quoted source text. Follow an explicit learner request for a different language or a language-learning exercise when necessary."
+ : "LANGUAGE: Use English for all learner-facing content, including teaching, questions and choices, plans, titles, practice, research summaries, knowledge-graph propositions, and text in generated diagrams, figures and demos. Preserve code, notation, identifiers, proper names and quoted source text. Follow an explicit learner request for a different language or a language-learning exercise when necessary.";
+}
+/** Streaming configuration: the language is pinned once at recording start, including reconnects. */
+export function dictationUrl(language: Language): string {
+ const params = new URLSearchParams({ model: "nova-3", language, encoding: "linear16", sample_rate: "16000", channels: "1", smart_format: "true", punctuate: "true", endpointing: "300" });
+ return `wss://api.deepgram.com/v1/listen?${params}`;
+}
+
+/** Count labels with natural Russian inflections; English keeps its original singular/plural wording. */
+export function countLabel(language: Language, count: number, singular: string, plural: string): string {
+ return translate(language, `{count} ${language === "ru" ? plural : count === 1 ? singular : plural}`, { count });
+}
diff --git a/src/core/index.ts b/src/core/index.ts
index b4106d7..3afaa0a 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -79,3 +79,5 @@ export * from "./coverage.ts";
// ONE session→markdown formatter — the extractor's view and the user-facing
// export share it. Pure, browser-safe.
export * from "./session-markdown.ts";
+
+export * from "./i18n.ts";
diff --git a/src/core/locales/en.json b/src/core/locales/en.json
new file mode 100644
index 0000000..34ce5ba
--- /dev/null
+++ b/src/core/locales/en.json
@@ -0,0 +1,839 @@
+{
+ "Abort this turn?": "Abort this turn?",
+ "The partial answer stays in the transcript.": "The partial answer stays in the transcript.",
+ "Abort": "Abort",
+ "Dismiss this question?": "Dismiss this question?",
+ "Skipped — the teacher moves on without it.": "Skipped — the teacher moves on without it.",
+ "Dismiss": "Dismiss",
+ "Discard this problem?": "Discard this problem?",
+ "The problem is dropped — no solution is revealed.": "The problem is dropped — no solution is revealed.",
+ "Discard": "Discard",
+ " The working turn stops too.": " The working turn stops too.",
+ "Cancel": "Cancel",
+ "needs model setup": "needs model setup",
+ "needs a provider key": "needs a provider key",
+ "{v0}s": "{v0}s",
+ "{v0}m {v1}s": "{v0}m {v1}s",
+ "{v0}h {v1}m": "{v0}h {v1}m",
+ "Background agents": "Background agents",
+ "Total running cost (inferred)": "Total running cost (inferred)",
+ "Extraction — {v0}": "Extraction — {v0}",
+ "Researcher": "Researcher",
+ "Demo builder": "Demo builder",
+ "Figure maker": "Figure maker",
+ "PDF reader": "PDF reader",
+ "Sub-agent": "Sub-agent",
+ "Abort failed — try again": "Abort failed — try again",
+ "End this background process": "End this background process",
+ "End {v0} in {v1}": "End {v0} in {v1}",
+ "Cost": "Cost",
+ "Context": "Context",
+ "Model": "Model",
+ "Steps": "Steps",
+ "Open session →": "Open session →",
+ "No messages yet": "No messages yet",
+ "Start a conversation to see messages here": "Start a conversation to see messages here",
+ "Previous branch": "Previous branch",
+ "Next branch": "Next branch",
+ "Thinking...": "Thinking...",
+ "AGENT’S THINKING": "AGENT’S THINKING",
+ "Thinking": "Thinking",
+ "— skipped": "— skipped",
+ "YOUR THINKING": "YOUR THINKING",
+ "Probe": "Probe",
+ "Recall": "Recall",
+ "Prediction": "Prediction",
+ "Check": "Check",
+ "Explain": "Explain",
+ "Question": "Question",
+ "didn't know": "didn't know",
+ "Answered “I don't know”.": "Answered “I don't know”.",
+ "Hide why": "Hide why",
+ "Why": "Why",
+ "Why:": "Why:",
+ "The answer:": "The answer:",
+ "unknown error": "unknown error",
+ "Demo completed, but the result could not be displayed.": "Demo completed, but the result could not be displayed.",
+ "Interactive demo": "Interactive demo",
+ "interactive demo": "interactive demo",
+ "Invert demo brightness": "Invert demo brightness",
+ "Invert colors": "Invert colors",
+ "Download demo as HTML": "Download demo as HTML",
+ "Download as HTML": "Download as HTML",
+ "The demo \"{v0}\" threw an error: {v1}": "The demo \"{v0}\" threw an error: {v1}",
+ "This demo threw an error": "This demo threw an error",
+ "Send to teacher": "Send to teacher",
+ "Research agent": "Research agent",
+ "Demo agent": "Demo agent",
+ "Figure agent": "Figure agent",
+ "Set up models": "Set up models",
+ "Add a key": "Add a key",
+ "{v0} running": "{v0} running",
+ "Dispatched…": "Dispatched…",
+ "PDF reading completed, but the result could not be displayed.": "PDF reading completed, but the result could not be displayed.",
+ "PDF failed": "PDF failed",
+ "PDF brief": "PDF brief",
+ "Pages read:": "Pages read:",
+ "Pages consulted:": "Pages consulted:",
+ "Research completed, but the result could not be displayed.": "Research completed, but the result could not be displayed.",
+ "Research failed": "Research failed",
+ "Research brief": "Research brief",
+ "Sources": "Sources",
+ "{v0} — live": "{v0} — live",
+ "Input (streaming)": "Input (streaming)",
+ "Probe concluded — your assessment": "Probe concluded — your assessment",
+ "The prober retried:": "The prober retried:",
+ "The assessment was malformed — the prober retried with a corrected one.": "The assessment was malformed — the prober retried with a corrected one.",
+ "Writing the assessment…": "Writing the assessment…",
+ "A sub-agent finished, but its result could not be displayed.": "A sub-agent finished, but its result could not be displayed.",
+ "aborted by the learner": "aborted by the learner",
+ "reading complete": "reading complete",
+ "· read": "· read",
+ "view brief": "view brief",
+ "hide brief": "hide brief",
+ "Research": "Research",
+ "{v0}\n[{v1} image{v2} attached to the model]": "{v0}\n[{v1} image{v2} attached to the model]",
+ "Input": "Input",
+ "Error": "Error",
+ "Output": "Output",
+ "running…": "running…",
+ "Figure completed, but the result could not be displayed.": "Figure completed, but the result could not be displayed.",
+ "Figure": "Figure",
+ "Invert figure brightness": "Invert figure brightness",
+ "Download figure as SVG": "Download figure as SVG",
+ "Download as SVG": "Download as SVG",
+ "Open figure fullscreen": "Open figure fullscreen",
+ "Open fullscreen": "Open fullscreen",
+ "Working": "Working",
+ "{v0}ms": "{v0}ms",
+ "Search nodes…": "Search nodes…",
+ "Search nodes": "Search nodes",
+ "Close chains": "Close chains",
+ "Name": "Name",
+ "Props": "Props",
+ "Created": "Created",
+ "No matching chains.": "No matching chains.",
+ "Select everything born in {v0}": "Select everything born in {v0}",
+ "Expand cluster {v0}": "Expand cluster {v0}",
+ "Select chain {v0}": "Select chain {v0}",
+ "Expand chain {v0}": "Expand chain {v0}",
+ "Select node {v0}": "Select node {v0}",
+ "Clear": "Clear",
+ "Done picking — {v0} {v1} selected": "Done picking — {v0} {v1} selected",
+ "Done": "Done",
+ "Delete {v0} selected {v1}": "Delete {v0} selected {v1}",
+ "Delete": "Delete",
+ "rename failed: {v0}": "rename failed: {v0}",
+ "rename failed:": "rename failed:",
+ "Session title": "Session title",
+ "Rename this session": "Rename this session",
+ "Rename session": "Rename session",
+ "Open the full plan": "Open the full plan",
+ "Plan": "Plan",
+ "Practice": "Practice",
+ "New session": "New session",
+ "The teacher will open by probing what you already know.": "The teacher will open by probing what you already know.",
+ "Next problem": "Next problem",
+ "You": "You",
+ "Retrying…": "Retrying…",
+ "Compacting context": "Compacting context",
+ "COMPACTING CONTEXT…": "COMPACTING CONTEXT…",
+ "THINKING…": "THINKING…",
+ "Something went wrong:": "Something went wrong:",
+ "Set up your models": "Set up your models",
+ "Ask anything, or press Enter for the next one.": "Ask anything, or press Enter for the next one.",
+ "Context compacted": "Context compacted",
+ " · {v0}k tok": " · {v0}k tok",
+ "History compacted": "History compacted",
+ "{v0} {v1} selected": "{v0} {v1} selected",
+ "Pick nodes": "Pick nodes",
+ "Ask, answer, or steer the lesson…": "Ask, answer, or steer the lesson…",
+ "Stop": "Stop",
+ "Send": "Send",
+ "An extraction is running — try again when the activity dock drains.": "An extraction is running — try again when the activity dock drains.",
+ "Delete node": "Delete node",
+ "Delete node?": "Delete node?",
+ "Delete {v0} nodes?": "Delete {v0} nodes?",
+ ", and {v0} more": ", and {v0} more",
+ "This permanently deletes the node — this cannot be undone.": "This permanently deletes the node — this cannot be undone.",
+ "This permanently deletes all {v0} nodes — this cannot be undone.": "This permanently deletes all {v0} nodes — this cannot be undone.",
+ "Try again": "Try again",
+ "Deleting…": "Deleting…",
+ "Delete failed — network error. Try again.": "Delete failed — network error. Try again.",
+ "Also remove its graph traces": "Also remove its graph traces",
+ "This session’s topics, links, and events leave the graph too. Topics other sessions or imported notes also cover survive; topics only this session created are removed entirely.": "This session’s topics, links, and events leave the graph too. Topics other sessions or imported notes also cover survive; topics only this session created are removed entirely.",
+ "Delete session": "Delete session",
+ "Delete session?": "Delete session?",
+ "This permanently removes the session — its messages, background jobs, and extraction records. Everything it added to the graph stays unless you check below. This cannot be undone.": "This permanently removes the session — its messages, background jobs, and extraction records. Everything it added to the graph stays unless you check below. This cannot be undone.",
+ "Dictation needs a Deepgram key": "Dictation needs a Deepgram key",
+ "Deepgram reconnect failed": "Deepgram reconnect failed",
+ "Deepgram connection lost ({v0})": "Deepgram connection lost ({v0})",
+ "Microphone permission denied": "Microphone permission denied",
+ "mic unavailable": "mic unavailable",
+ "token unavailable": "token unavailable",
+ "Dictation failed: {v0}": "Dictation failed: {v0}",
+ "Start dictation": "Start dictation",
+ "Stop dictation (Esc)": "Stop dictation (Esc)",
+ "Stop dictation": "Stop dictation",
+ "extracting…": "extracting…",
+ "fully extracted": "fully extracted",
+ "tok uncovered": "tok uncovered",
+ "Running": "Running",
+ "Extract": "Extract",
+ "How much of this session isn't mapped yet": "How much of this session isn't mapped yet",
+ "Extraction coverage": "Extraction coverage",
+ "Uncovered": "Uncovered",
+ "Covered": "Covered",
+ "Messages": "Messages",
+ "Chunk threshold": "Chunk threshold",
+ "Last extraction": "Last extraction",
+ "Status": "Status",
+ "Graph context": "Graph context",
+ "{v0}k": "{v0}k",
+ "{v0}M": "{v0}M",
+ "{v0}m": "{v0}m",
+ "{v0}h": "{v0}h",
+ "{v0}d": "{v0}d",
+ "Graph costs — click for details": "Graph costs — click for details",
+ "Extraction": "Extraction",
+ "Audit": "Audit",
+ "Graph pipeline costs": "Graph pipeline costs",
+ "in /": "in /",
+ " +1 unpriced": " +1 unpriced",
+ "Graph display settings": "Graph display settings",
+ "Node size": "Node size",
+ "Edge width": "Edge width",
+ "Arrowhead size": "Arrowhead size",
+ "Label size": "Label size",
+ "Labels until zoom": "Labels until zoom",
+ "Regions": "Regions",
+ "Background colors": "Background colors",
+ "Dilation": "Dilation",
+ "Blur": "Blur",
+ "Edge alpha": "Edge alpha",
+ "Bitmap resolution": "Bitmap resolution",
+ "Cluster labels": "Cluster labels",
+ "Cluster label size": "Cluster label size",
+ "Physics": "Physics",
+ "Node repulsion": "Node repulsion",
+ "Edge attraction": "Edge attraction",
+ "Edge length": "Edge length",
+ "Central pull": "Central pull",
+ "Cluster pull": "Cluster pull",
+ "Friction": "Friction",
+ "Depth anchor": "Depth anchor",
+ "Off": "Off",
+ "Vertical (up)": "Vertical (up)",
+ "Radial (outward)": "Radial (outward)",
+ "Depth strength": "Depth strength",
+ "Depth spacing": "Depth spacing",
+ "Base ring radius": "Base ring radius",
+ "No matching nodes.": "No matching nodes.",
+ "Every reasoning chain": "Every reasoning chain",
+ "Chains": "Chains",
+ "Import notes into the graph": "Import notes into the graph",
+ "Importing…": "Importing…",
+ "Import notes…": "Import notes…",
+ "Knowledge graph": "Knowledge graph",
+ "Start a session or import notes to build your graph.": "Start a session or import notes to build your graph.",
+ "Sessions": "Sessions",
+ "No sessions recorded.": "No sessions recorded.",
+ "What do you want to practice?": "What do you want to practice?",
+ "What do you want to learn?": "What do you want to learn?",
+ "Start session": "Start session",
+ "Teach": "Teach",
+ "A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know.": "A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know.",
+ "Could not load language settings": "Could not load language settings",
+ "Could not save language settings": "Could not save language settings",
+ "Interface language": "Interface language",
+ "Speech recognition language": "Speech recognition language",
+ "Auto": "Auto",
+ "System default": "System default",
+ "Global": "Global",
+ "Chat": "Chat",
+ "Graph view": "Graph view",
+ "Keyboard shortcuts": "Keyboard shortcuts",
+ "Esc to close": "Esc to close",
+ "Copy diagram source": "Copy diagram source",
+ "Open full-screen": "Open full-screen",
+ "Expand diagram": "Expand diagram",
+ "Copy code": "Copy code",
+ "Copy table": "Copy table",
+ "Copy equation source": "Copy equation source",
+ "Diagram failed to render": "Diagram failed to render",
+ "Show diagram source": "Show diagram source",
+ "Retry": "Retry",
+ "teaching session": "teaching session",
+ "teaching sessions": "teaching sessions",
+ "practice session": "practice session",
+ "practice sessions": "practice sessions",
+ "imported note": "imported note",
+ "imported notes": "imported notes",
+ "Nothing here yet — import notes to begin building the graph.": "Nothing here yet — import notes to begin building the graph.",
+ "catalog request failed: {v0}": "catalog request failed: {v0}",
+ "Could not reach the server — try again.": "Could not reach the server — try again.",
+ "Model selector": "Model selector",
+ "No models — add a provider key in Keys": "No models — add a provider key in Keys",
+ "This session's model": "This session's model",
+ "Strong tier — the default for new sessions": "Strong tier — the default for new sessions",
+ "{v0} has no thinking levels.": "{v0} has no thinking levels.",
+ "Pick a model first.": "Pick a model first.",
+ "Loading…": "Loading…",
+ "Levels this model actually supports": "Levels this model actually supports",
+ "New practice session": "New practice session",
+ "Pick nodes from your knowledge graph, describe what you want to practice, or bring your own problem.": "Pick nodes from your knowledge graph, describe what you want to practice, or bring your own problem.",
+ "Invalid import response. Please retry the file.": "Invalid import response. Please retry the file.",
+ "Import request failed (HTTP {v0}){v1}. Please retry the file.": "Import request failed (HTTP {v0}){v1}. Please retry the file.",
+ "Probing": "Probing",
+ "Planning": "Planning",
+ "Teaching": "Teaching",
+ "Plan · {v0} node{v1}": "Plan · {v0} node{v1}",
+ "Plan diagram": "Plan diagram",
+ "The full plan": "The full plan",
+ "The plan": "The plan",
+ "live — statuses derive from your check outcomes": "live — statuses derive from your check outcomes",
+ "Close": "Close",
+ "Drag to pan · scroll to zoom": "Drag to pan · scroll to zoom",
+ "Zoom out": "Zoom out",
+ "Fit plan to screen": "Fit plan to screen",
+ "Zoom in": "Zoom in",
+ "No plan in this session yet.": "No plan in this session yet.",
+ "Problem": "Problem",
+ "Solved": "Solved",
+ "Solved with hints": "Solved with hints",
+ "Moved on": "Moved on",
+ "Surrendered": "Surrendered",
+ "Dismissed": "Dismissed",
+ "Problem dismissed.": "Problem dismissed.",
+ "Concluded": "Concluded",
+ "Model solution": "Model solution",
+ "Metadata": "Metadata",
+ "Mode": "Mode",
+ "Targets": "Targets",
+ "Outcome": "Outcome",
+ "Hint rung": "Hint rung",
+ "Miss class": "Miss class",
+ "Retest": "Retest",
+ "Work here, think out loud.": "Work here, think out loud.",
+ "compacting session…": "compacting session…",
+ "↵ submits · ⇧↵ newline": "↵ submits · ⇧↵ newline",
+ "Submit thinking": "Submit thinking",
+ "This question arrived malformed — the transcript keeps the retry.": "This question arrived malformed — the transcript keeps the retry.",
+ "Check your understanding": "Check your understanding",
+ "Open": "Open",
+ "Answer": "Answer",
+ "I don't know": "I don't know",
+ "Type your own answer…": "Type your own answer…",
+ "Confidence": "Confidence",
+ "Confidence — the probability your selected answer is correct": "Confidence — the probability your selected answer is correct",
+ "{v0} percent": "{v0} percent",
+ "The probability your selected answer is correct — 50% is a coin flip. Adjust with ←/→.": "The probability your selected answer is correct — 50% is a coin flip. Adjust with ←/→.",
+ "Your answer": "Your answer",
+ "Type your answer…": "Type your answer…",
+ "Why this answer? Whatever comes to mind.": "Why this answer? Whatever comes to mind.",
+ "Submit answer": "Submit answer",
+ "Thinking while reading": "Thinking while reading",
+ "Whatever comes to mind as you read.": "Whatever comes to mind as you read.",
+ "Aborted": "Aborted",
+ "The turn stream body is empty.": "The turn stream body is empty.",
+ "A turn is already running — try again in a moment.": "A turn is already running — try again in a moment.",
+ "The dismissal failed ({v0}).": "The dismissal failed ({v0}).",
+ "Session Find": "Session Find",
+ "Find in session": "Find in session",
+ "Find in session…": "Find in session…",
+ "Previous match": "Previous match",
+ "Previous match (Shift+Enter)": "Previous match (Shift+Enter)",
+ "Next match": "Next match",
+ "Next match (Enter)": "Next match (Enter)",
+ "Close Find": "Close Find",
+ "Close Find (Esc)": "Close Find (Esc)",
+ "Diagram focus": "Diagram focus",
+ "Diagram": "Diagram",
+ "from this lesson": "from this lesson",
+ "Fit diagram to view": "Fit diagram to view",
+ "Rendering diagram…": "Rendering diagram…",
+ "Strong": "Strong",
+ "The main model for your sessions": "The main model for your sessions",
+ "Cheap": "Cheap",
+ "The assistants that research, build demos, and grade your work": "The assistants that research, build demos, and grade your work",
+ "Vision": "Vision",
+ "The assistants that draw and read PDFs": "The assistants that draw and read PDFs",
+ "Your version is in use": "Your version is in use",
+ "Teacher": "Teacher",
+ "Prober": "Prober",
+ "Shared": "Shared",
+ "Used by both agents": "Used by both agents",
+ "Catalog unavailable": "Catalog unavailable",
+ "Save failed: {v0}": "Save failed: {v0}",
+ "Saved": "Saved",
+ "Delete failed: {v0}": "Delete failed: {v0}",
+ "Deleted": "Deleted",
+ "✓ key": "✓ key",
+ "· no key": "· no key",
+ "prompt {v0}": "prompt {v0}",
+ "Hide text": "Hide text",
+ "Show text": "Show text",
+ "Source": "Source",
+ "The original instructions": "The original instructions",
+ "Override": "Override",
+ "Your version of the instructions": "Your version of the instructions",
+ "Add your own instructions…": "Add your own instructions…",
+ "Save": "Save",
+ "Models": "Models",
+ "{v0} model": "{v0} model",
+ "{v0} thinking": "{v0} thinking",
+ "Runs every agent of this tier": "Runs every agent of this tier",
+ "Tools": "Tools",
+ "Graph tools": "Graph tools",
+ "Lets the tutor consult your knowledge map between sessions": "Lets the tutor consult your knowledge map between sessions",
+ "Graph is disabled": "Graph is disabled",
+ "graph tools": "graph tools",
+ "Quizzing": "Quizzing",
+ "How both tutors check your understanding": "How both tutors check your understanding",
+ "Quizzing mechanics": "Quizzing mechanics",
+ "Multiple choice": "Multiple choice",
+ "Free recall": "Free recall",
+ "Tool · {v0}": "Tool · {v0}",
+ "tool {v0}": "tool {v0}",
+ "Search engine": "Search engine",
+ "Automatic": "Automatic",
+ "Compaction threshold": "Compaction threshold",
+ "{v0} tok": "{v0} tok",
+ "compaction threshold": "compaction threshold",
+ "System prompts": "System prompts",
+ "Loading prompt segments…": "Loading prompt segments…",
+ "Coming soon": "Coming soon",
+ "Dark": "Dark",
+ "Background": "Background",
+ "Empty = {v0}": "Empty = {v0}",
+ "background hex": "background hex",
+ "Reading font": "Reading font",
+ "Choose from your installed fonts": "Choose from your installed fonts",
+ "Common families — installed fonts could not be read": "Common families — installed fonts could not be read",
+ "Common families — your installed list needs the desktop app": "Common families — your installed list needs the desktop app",
+ "Your installed fonts": "Your installed fonts",
+ "Reading font size": "Reading font size",
+ "prose font size": "prose font size",
+ "Session names in serif": "Session names in serif",
+ "session names serif": "session names serif",
+ "Heading weight": "Heading weight",
+ "heading weight": "heading weight",
+ "Bold color": "Bold color",
+ "Empty = same as text": "Empty = same as text",
+ "same as text": "same as text",
+ "bold color": "bold color",
+ "Italic color": "Italic color",
+ "italic color": "italic color",
+ "Off = the graph stops growing": "Off = the graph stops growing",
+ "extraction enabled": "extraction enabled",
+ "Graph rendering": "Graph rendering",
+ "Off = home shows the chains view": "Off = home shows the chains view",
+ "graph rendering enabled": "graph rendering enabled",
+ "Extraction chunk budget": "Extraction chunk budget",
+ "extraction chunk budget": "extraction chunk budget",
+ "LearnHarness stores your ChatGPT account details and credentials in an unencrypted local file on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness.": "LearnHarness stores your ChatGPT account details and credentials in an unencrypted local file on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness.",
+ "Background work counts toward your ChatGPT plan usage.": "Background work counts toward your ChatGPT plan usage.",
+ "Allow background use for all ChatGPT accounts": "Allow background use for all ChatGPT accounts",
+ "Account disconnected locally. Remote revocation could not be confirmed.": "Account disconnected locally. Remote revocation could not be confirmed.",
+ "Disconnect account?": "Disconnect account?",
+ "This removes the saved account and its credentials from this device.": "This removes the saved account and its credentials from this device.",
+ "The ChatGPT connection could not be changed. Try again.": "The ChatGPT connection could not be changed. Try again.",
+ "The desktop connection is unavailable. Retry when the app is ready.": "The desktop connection is unavailable. Retry when the app is ready.",
+ "Disconnect account": "Disconnect account",
+ "Saved ChatGPT account": "Saved ChatGPT account",
+ "Add another account or workspace": "Add another account or workspace",
+ "Manage…": "Manage…",
+ "OpenAI privacy": "OpenAI privacy",
+ "Manage usage": "Manage usage",
+ "ChatGPT sign-in is available only in the desktop app. API-key connections remain available here.": "ChatGPT sign-in is available only in the desktop app. API-key connections remain available here.",
+ "Waiting for browser sign-in…": "Waiting for browser sign-in…",
+ "Manage ChatGPT account": "Manage ChatGPT account",
+ "Add accounts": "Add accounts",
+ "Disconnect": "Disconnect",
+ "Sign in again": "Sign in again",
+ "Continue with ChatGPT": "Continue with ChatGPT",
+ "ChatGPT account": "ChatGPT account",
+ "Connect": "Connect",
+ "Enable background use": "Enable background use",
+ "Graph": "Graph",
+ "Fixed — part of this screen": "Fixed — part of this screen",
+ "Press keys…": "Press keys…",
+ "Rebind": "Rebind",
+ "Reset": "Reset",
+ "Stored on this device (lh:keymap) — overrides only.": "Stored on this device (lh:keymap) — overrides only.",
+ "Reset all to defaults": "Reset all to defaults",
+ "not set": "not set",
+ " · env": " · env",
+ "Paste key…": "Paste key…",
+ "{v0} key": "{v0} key",
+ "key ↗": "key ↗",
+ "+ Add provider key": "+ Add provider key",
+ "Loading keys…": "Loading keys…",
+ "Model API keys": "Model API keys",
+ "Subscriptions": "Subscriptions",
+ "Dictation": "Dictation",
+ "Search": "Search",
+ "Search models…": "Search models…",
+ "Loading models…": "Loading models…",
+ "No models match.": "No models match.",
+ "Subscription connection": "Subscription connection",
+ "USD per 1M tokens in/out": "USD per 1M tokens in/out",
+ "{v0} (subscription)": "{v0} (subscription)",
+ "Search providers…": "Search providers…",
+ "No providers match.": "No providers match.",
+ "Search fonts…": "Search fonts…",
+ "Loading fonts…": "Loading fonts…",
+ "Use “": "Use “",
+ "Providers": "Providers",
+ "Agent": "Agent",
+ "Appearance": "Appearance",
+ "Keyboard": "Keyboard",
+ "Stored on this device": "Stored on this device",
+ "General": "General",
+ "Automatic updates arrive in an upcoming release": "Automatic updates arrive in an upcoming release",
+ "status {v0}": "status {v0}",
+ "Settings": "Settings",
+ "Close settings": "Close settings",
+ "Settings sections": "Settings sections",
+ "Loading settings…": "Loading settings…",
+ "Settings could not be loaded — refresh to retry.": "Settings could not be loaded — refresh to retry.",
+ "Auto-update": "Auto-update",
+ "auto update": "auto update",
+ "Pi's native Copilot sign-in may enable additional models whose policy is not yet configured. Models explicitly disabled by policy are not enabled. Organization restrictions still apply.": "Pi's native Copilot sign-in may enable additional models whose policy is not yet configured. Models explicitly disabled by policy are not enabled. Organization restrictions still apply.",
+ "Allow LearnHarness helper agents and automatic follow-ups to use this connection.": "Allow LearnHarness helper agents and automatic follow-ups to use this connection.",
+ "Your prompts, tool results, and relevant session context are sent to {v0} to run the work you select. Credentials are stored locally in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.": "Your prompts, tool results, and relevant session context are sent to {v0} to run the work you select. Credentials are stored locally in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.",
+ "Disconnect {v0} from LearnHarness? This stops work using this connection and removes its saved credentials from this device. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.": "Disconnect {v0} from LearnHarness? This stops work using this connection and removes its saved credentials from this device. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.",
+ "The connection could not be changed. Try again.": "The connection could not be changed. Try again.",
+ "Sign-in did not complete. Try connecting again.": "Sign-in did not complete. Try connecting again.",
+ "{v0} connection": "{v0} connection",
+ "Allow background use": "Allow background use",
+ "Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.": "Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.",
+ "Device code:": "Device code:",
+ "The browser could not be opened. Try again.": "The browser could not be opened. Try again.",
+ "Open browser": "Open browser",
+ "Select…": "Select…",
+ "Continue": "Continue",
+ "Add a provider key": "Add a provider key",
+ "Prober, teacher": "Prober, teacher",
+ "Researcher, demo builder, extractor, auditor": "Researcher, demo builder, extractor, auditor",
+ "Visual maker + PDF reader": "Visual maker + PDF reader",
+ "Choose your AI provider and paste its API key. You’ll pick your models next.": "Choose your AI provider and paste its API key. You’ll pick your models next.",
+ "Provider": "Provider",
+ "Pick a provider…": "Pick a provider…",
+ "Saving…": "Saving…",
+ "Save key": "Save key",
+ "Save failed. Check your connection and try again.": "Save failed. Check your connection and try again.",
+ "Close setup": "Close setup",
+ "Model catalog could not be loaded. Check the connection and retry.": "Model catalog could not be loaded. Check the connection and retry.",
+ "Pick the model for each tier. All three are required before chats can start.": "Pick the model for each tier. All three are required before chats can start.",
+ "Pick a model…": "Pick a model…",
+ "No image-capable models. Add a provider key with access to a vision model.": "No image-capable models. Add a provider key with access to a vision model.",
+ "Save models": "Save models",
+ "Open sidebar": "Open sidebar",
+ "just now": "just now",
+ "{v0}m ago": "{v0}m ago",
+ "{v0}h ago": "{v0}h ago",
+ "{v0}d ago": "{v0}d ago",
+ "Compacting context · {v0}": "Compacting context · {v0}",
+ "Waiting for input · {v0}": "Waiting for input · {v0}",
+ "Problem waiting · {v0}": "Problem waiting · {v0}",
+ "Working…": "Working…",
+ "Practice session": "Practice session",
+ "Home": "Home",
+ "Close sidebar": "Close sidebar",
+ "create a new teaching session": "create a new teaching session",
+ "create a new practice session": "create a new practice session",
+ "Search sessions": "Search sessions",
+ "Search…": "Search…",
+ "All": "All",
+ "No sessions match.": "No sessions match.",
+ "No sessions yet.": "No sessions yet.",
+ "Rename": "Rename",
+ "Export as Markdown…": "Export as Markdown…",
+ "Delete…": "Delete…",
+ "Session stats": "Session stats",
+ "Context (last call)": "Context (last call)",
+ "Usage": "Usage",
+ "Total cost": "Total cost",
+ "Manage ChatGPT usage": "Manage ChatGPT usage",
+ "Unpriced turns": "Unpriced turns",
+ "Turns": "Turns",
+ "is ready": "is ready",
+ "— restart to finish installing.": "— restart to finish installing.",
+ "Restart": "Restart",
+ "Toggle dictation": "Toggle dictation",
+ "Open settings": "Open settings",
+ "Cycle thinking level": "Cycle thinking level",
+ "Toggle sessions sidebar": "Toggle sessions sidebar",
+ "Toggle all your thinking": "Toggle all your thinking",
+ "Find": "Find",
+ "Shortcut cheat sheet": "Shortcut cheat sheet",
+ "Question · focus thinking ↔ rows": "Question · focus thinking ↔ rows",
+ "Question · next row": "Question · next row",
+ "Question · previous row": "Question · previous row",
+ "Question · pick row": "Question · pick row",
+ "Question · submit": "Question · submit",
+ "Space": "Space",
+ "Speech-to-text key for dictation — needs ≥ Member permission in the Deepgram console to mint tokens.": "Speech-to-text key for dictation — needs ≥ Member permission in the Deepgram console to mint tokens.",
+ "Web-search engine for the researcher sub-agent (AI-native search API). Setting the key activates it — no engine toggle needed.": "Web-search engine for the researcher sub-agent (AI-native search API). Setting the key activates it — no engine toggle needed.",
+ "Web-search engine for the researcher sub-agent — the alternative to Tavily. Setting the key activates it.": "Web-search engine for the researcher sub-agent — the alternative to Tavily. Setting the key activates it.",
+ "{v0} must be at least {v1} characters with no whitespace": "{v0} must be at least {v1} characters with no whitespace",
+ "{v0} must start with \"tvly-\" (Tavily's key format)": "{v0} must start with \"tvly-\" (Tavily's key format)",
+ "unknown key \"{v0}\" (supported: {v1})": "unknown key \"{v0}\" (supported: {v1})",
+ "{v0} must be at least 12 characters with no whitespace": "{v0} must be at least 12 characters with no whitespace",
+ "Dictate": "Dictate",
+ "of": "of",
+ "correct": "correct",
+ "incorrect": "incorrect",
+ "failed": "failed",
+ "complete": "complete",
+ "sources": "sources",
+ "unlabeled": "unlabeled",
+ "notes": "notes",
+ "tokens": "tokens",
+ "unpriced": "unpriced",
+ "out": "out",
+ "steps": "steps",
+ "custom": "custom",
+ "needed": "needed",
+ "now": "now",
+ "starting": "starting",
+ "done": "done",
+ "queued": "queued",
+ "running": "running",
+ "extracting": "extracting",
+ "auditing": "auditing",
+ "merging": "merging",
+ "delivery failed": "delivery failed",
+ "waiting": "waiting",
+ "waiting for a slot": "waiting for a slot",
+ "building demo": "building demo",
+ "rendering figure": "rendering figure",
+ "reading PDF": "reading PDF",
+ "final verification": "final verification",
+ "off": "off",
+ "minimal": "minimal",
+ "low": "low",
+ "medium": "medium",
+ "high": "high",
+ "xhigh": "xhigh",
+ "max": "max",
+ "No thinking levels.": "No thinking levels.",
+ "Learner": "Learner",
+ "Quiz": "Quiz",
+ "Recall check": "Recall check",
+ "(no question text)": "(no question text)",
+ "The learner skipped this check.": "The learner skipped this check.",
+ "The learner did not know.": "The learner did not know.",
+ "The learner answered correctly.": "The learner answered correctly.",
+ "The learner answered incorrectly.": "The learner answered incorrectly.",
+ "The learner answered in their own words: {answer}": "The learner answered in their own words: {answer}",
+ "The learner answered in his own words: {answer}": "The learner answered in his own words: {answer}",
+ "Their thinking: {thinking}": "Their thinking: {thinking}",
+ "his thinking: {thinking}": "his thinking: {thinking}",
+ "(no statement)": "(no statement)",
+ "The learner skipped this problem.": "The learner skipped this problem.",
+ "The learner answered: {answer}": "The learner answered: {answer}",
+ "The learner chose: {answer}": "The learner chose: {answer}",
+ "The learner skipped this question.": "The learner skipped this question.",
+ "Q:": "Q:",
+ "Tool": "Tool",
+ "{tool} result delivered": "{tool} result delivered",
+ "No model configured — set up your models first": "No model configured — set up your models first",
+ "No {v0} key is set — add one in Settings → Keys": "No {v0} key is set — add one in Settings → Keys",
+ "Unknown chat": "Unknown chat",
+ "Invalid JSON body": "Invalid JSON body",
+ "Failed to fetch": "Failed to fetch",
+ "Network error": "Network error",
+ "Network error. Try again.": "Network error. Try again.",
+ "Failed to attach to the running turn.": "Failed to attach to the running turn.",
+ "The send failed ({v0}).": "The send failed ({v0}).",
+ "The steer failed ({v0}).": "The steer failed ({v0}).",
+ "Save failed ({v0})": "Save failed ({v0})",
+ "Delete failed ({v0})": "Delete failed ({v0})",
+ "settings rejected ({v0})": "settings rejected ({v0})",
+ "A turn is already running for this session": "A turn is already running for this session",
+ "This file is already being imported. Try again when it finishes.": "This file is already being imported. Try again when it finishes.",
+ "Notes import did not complete": "Notes import did not complete",
+ "Deepgram grant returned no token": "Deepgram grant returned no token",
+ "Deepgram unreachable": "Deepgram unreachable",
+ "Deepgram token grant failed ({v0}): {v1}": "Deepgram token grant failed ({v0}): {v1}",
+ "token route {v0}": "token route {v0}",
+ "No files": "No files",
+ "Empty title": "Empty title",
+ "Title must not be empty": "Title must not be empty",
+ "Copied": "Copied",
+ "Copy": "Copy",
+ "Download": "Download",
+ "No results": "No results",
+ "Select": "Select",
+ "Previous": "Previous",
+ "Next": "Next",
+ "Untitled session": "Untitled session",
+ "{count} messages unextracted": {
+ "one": "{count} message unextracted",
+ "other": "{count} messages unextracted"
+ },
+ "· read {count} pages": {
+ "one": "· read {count} page",
+ "other": "· read {count} pages"
+ },
+ "{count} sources": {
+ "one": "{count} source",
+ "other": "{count} sources"
+ },
+ "{count} propositions": "{count} propositions",
+ "{count} chains": "{count} chains",
+ "{count} teaching sessions": "{count} teaching sessions",
+ "{count} practice sessions": "{count} practice sessions",
+ "{count} imported notes": "{count} imported notes",
+ "Copy Code": "Copy Code",
+ "Download file": "Download file",
+ "Download diagram": "Download diagram",
+ "Download diagram as SVG": "Download diagram as SVG",
+ "Download diagram as PNG": "Download diagram as PNG",
+ "Download diagram as MMD": "Download diagram as MMD",
+ "View fullscreen": "View fullscreen",
+ "Exit fullscreen": "Exit fullscreen",
+ "Copy table as Markdown": "Copy table as Markdown",
+ "Copy table as CSV": "Copy table as CSV",
+ "Copy table as TSV": "Copy table as TSV",
+ "Download table": "Download table",
+ "Download table as CSV": "Download table as CSV",
+ "Download table as Markdown": "Download table as Markdown",
+ "Image not available": "Image not available",
+ "Download image": "Download image",
+ "Open external link?": "Open external link?",
+ "You're about to visit an external website.": "You're about to visit an external website.",
+ "Copy link": "Copy link",
+ "Open link": "Open link",
+ "Other": "Other",
+ "Light": "Light",
+ "delivered": "delivered",
+ "No such session": "No such session",
+ "A turn is running in this session — try again when it finishes": "A turn is running in this session — try again when it finishes",
+ "Background jobs are still running in this session — try again when they finish": "Background jobs are still running in this session — try again when they finish",
+ "The session's extraction is still winding down — try again in a moment": "The session's extraction is still winding down — try again in a moment",
+ "Not found": "Not found",
+ "title must be a non-empty string": "title must be a non-empty string",
+ "unknown key \"{v0}\"": "unknown key \"{v0}\"",
+ "body must be JSON": "body must be JSON",
+ "body must be {value: string}": "body must be {value: string}",
+ "model must be {provider, id}": "model must be {provider, id}",
+ "thinkingLevel must be a string": "thinkingLevel must be a string",
+ "autoUpdate must be a boolean": "autoUpdate must be a boolean",
+ "{v0} must be \"en\" or \"ru\"": "{v0} must be \"en\" or \"ru\"",
+ "{v0} must be an object": "{v0} must be an object",
+ "{v0}.model must be {provider, id}": "{v0}.model must be {provider, id}",
+ "{v0}.thinkingLevel must be a string": "{v0}.thinkingLevel must be a string",
+ "{v0}.{v1} must be a boolean": "{v0}.{v1} must be a boolean",
+ "models must be an object": "models must be an object",
+ "unknown role \"{v0}\" (supported: {v1})": "unknown role \"{v0}\" (supported: {v1})",
+ "unknown tool \"{v0}\" (supported: {v1})": "unknown tool \"{v0}\" (supported: {v1})",
+ "search must be an object": "search must be an object",
+ "search.engine must be \"tavily\" or \"brave\"": "search.engine must be \"tavily\" or \"brave\"",
+ "graphTools must be an object": "graphTools must be an object",
+ "graphTools.enabled must be a boolean": "graphTools.enabled must be a boolean",
+ "{v0} must be a #hex color or null": "{v0} must be a #hex color or null",
+ "mode must be \"dark\" or \"light\"": "mode must be \"dark\" or \"light\"",
+ "fontFamily must be a non-empty string or null": "fontFamily must be a non-empty string or null",
+ "proseFontSize must be an integer between 12 and 24": "proseFontSize must be an integer between 12 and 24",
+ "sessionNameSerif must be a boolean": "sessionNameSerif must be a boolean",
+ "mdRendering must be an object": "mdRendering must be an object",
+ "mdRendering.headingWeight must be an integer between 100 and 900": "mdRendering.headingWeight must be an integer between 100 and 900",
+ "enabled must be a boolean": "enabled must be a boolean",
+ "rendering must be a boolean": "rendering must be a boolean",
+ "extractionChunkTokens must be a number": "extractionChunkTokens must be a number",
+ "extractionChunkTokens must be at least {v0} (smaller batches waste the per-run graph overhead)": "extractionChunkTokens must be at least {v0} (smaller batches waste the per-run graph overhead)",
+ "unknown slot \"{v0}\" (supported: {v1})": "unknown slot \"{v0}\" (supported: {v1})",
+ "slot \"{v0}\" must set a model (thinkingLevel alone cannot run — absent slot = unset)": "slot \"{v0}\" must set a model (thinkingLevel alone cannot run — absent slot = unset)",
+ "thresholdTokens must be a number": "thresholdTokens must be a number",
+ "thresholdTokens must be at least {v0} (compaction keeps a ~20k-token recent tail — smaller budgets would churn it)": "thresholdTokens must be at least {v0} (compaction keeps a ~20k-token recent tail — smaller budgets would churn it)",
+ "The tool call failed.": "The tool call failed.",
+ "Aborted by learner": "Aborted by learner",
+ "starting research": "starting research",
+ "starting demo": "starting demo",
+ "starting figure": "starting figure",
+ "building visual": "building visual",
+ "render_svg: inspecting final render": "render_svg: inspecting final render",
+ "render_svg: inspecting render": "render_svg: inspecting render",
+ "render_svg: render error": "render_svg: render error",
+ "edit_svg: applied": "edit_svg: applied",
+ "web_search: \"{v0}\"": "web_search: \"{v0}\"",
+ "web_fetch: {v0}": "web_fetch: {v0}",
+ "Stopping…": "Stopping…",
+ "The demo could not be built.": "The demo could not be built.",
+ "The figure could not be drawn.": "The figure could not be drawn.",
+ "untagged": "untagged",
+ "yes": "yes",
+ "no": "no",
+ "none": "none",
+ " · select all that apply": " · select all that apply",
+ "recall": "recall",
+ "discrimination": "discrimination",
+ "composition": "composition",
+ "solved": "solved",
+ "solvedWithHints": "solvedWithHints",
+ "movedOn": "movedOn",
+ "surrendered": "surrendered",
+ "dismissed": "dismissed",
+ "cue": "cue",
+ "execution": "execution",
+ "careless": "careless",
+ "structural": "structural",
+ "pending": "pending",
+ "consent": "consent",
+ "proposition": "proposition",
+ "propositions": "propositions",
+ "node": "node",
+ "nodes": "nodes",
+ "chain": "chain",
+ "chains": "chains",
+ " · {v0} sources": " · {v0} sources",
+ "{v0}s elapsed": "{v0}s elapsed",
+ "{count} nodes": "{count} nodes",
+ "The saved connection could not be read or written safely. Reopen the app or reconnect after resolving the credential-file problem.": "The saved connection could not be read or written safely. Reopen the app or reconnect after resolving the credential-file problem.",
+ "The connection could not be refreshed. Retry or reconnect in Providers.": "The connection could not be refreshed. Retry or reconnect in Providers.",
+ "Sign-in cancelled.": "Sign-in cancelled.",
+ "This subscription connection is unavailable. Reconnect or explicitly choose another connection.": "This subscription connection is unavailable. Reconnect or explicitly choose another connection.",
+ "Background use is off for this connection. Automatic follow-up remains deferred in its queue.": "Background use is off for this connection. Automatic follow-up remains deferred in its queue.",
+ "The connection or model catalog changed. Select an available model and retry.": "The connection or model catalog changed. Select an available model and retry.",
+ "This sign-in action is no longer available.": "This sign-in action is no longer available.",
+ "ChatGPT sign-in cancelled.": "ChatGPT sign-in cancelled.",
+ "ChatGPT sign-in timed out. Try again.": "ChatGPT sign-in timed out. Try again.",
+ "ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests.": "ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests.",
+ "ChatGPT sign-in could not be verified. Select the intended saved account and try again.": "ChatGPT sign-in could not be verified. Select the intended saved account and try again.",
+ "ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.": "ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.",
+ "This ChatGPT connection is disconnected. Reconnect in Providers.": "This ChatGPT connection is disconnected. Reconnect in Providers.",
+ "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers, or explicitly select an API-key model for that workload.": "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers, or explicitly select an API-key model for that workload.",
+ "The ChatGPT account model catalog is unavailable. Retry the connection; no API-key fallback was used.": "The ChatGPT account model catalog is unavailable. Retry the connection; no API-key fallback was used.",
+ "The ChatGPT session expired. Reconnect in Providers.": "The ChatGPT session expired. Reconnect in Providers.",
+ "ChatGPT could not be reached. Check your connection and try again.": "ChatGPT could not be reached. Check your connection and try again.",
+ "A ChatGPT sign-in is already pending.": "A ChatGPT sign-in is already pending.",
+ "ChatGPT sign-in is unavailable in browser-only mode.": "ChatGPT sign-in is unavailable in browser-only mode.",
+ "ChatGPT sign-in failed. Try again; no API-key fallback was used.": "ChatGPT sign-in failed. Try again; no API-key fallback was used.",
+ "The selected model is unavailable.": "The selected model is unavailable.",
+ "ChatGPT plan usage limit reached. Manage usage at https://chatgpt.com/settings/usage. No API-key fallback was used.": "ChatGPT plan usage limit reached. Manage usage at https://chatgpt.com/settings/usage. No API-key fallback was used.",
+ "ChatGPT plan permission is missing. Reconnect in Providers and authorize plan use. No API-key fallback was used.": "ChatGPT plan permission is missing. Reconnect in Providers and authorize plan use. No API-key fallback was used.",
+ "Your ChatGPT session expired. Reconnect in Providers. No API-key fallback was used.": "Your ChatGPT session expired. Reconnect in Providers. No API-key fallback was used.",
+ "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers to run this work. No API-key fallback was used.": "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers to run this work. No API-key fallback was used.",
+ "This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility.": "This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility.",
+ "ChatGPT request cancelled or connection closed. No API-key fallback was used.": "ChatGPT request cancelled or connection closed. No API-key fallback was used.",
+ "The selected model is unavailable for this ChatGPT account. Refresh Providers and explicitly choose an available model. No API-key fallback was used.": "The selected model is unavailable for this ChatGPT account. Refresh Providers and explicitly choose an available model. No API-key fallback was used.",
+ "ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used.": "ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used.",
+ "ChatGPT request failed. Check your connection and ChatGPT usage, then retry. No API-key fallback was used.": "ChatGPT request failed. Check your connection and ChatGPT usage, then retry. No API-key fallback was used.",
+ "{v0} background use is off in this app. Enable it in Providers to run this work. No API-key fallback was used.": "{v0} background use is off in this app. Enable it in Providers to run this work. No API-key fallback was used.",
+ "{v0} usage limit reached. Check your provider account and try again later. No API-key fallback was used.": "{v0} usage limit reached. Check your provider account and try again later. No API-key fallback was used.",
+ "{v0} authorization could not be refreshed. Retry or reconnect in Providers. No API-key fallback was used.": "{v0} authorization could not be refreshed. Retry or reconnect in Providers. No API-key fallback was used.",
+ "{v0} request cancelled or connection closed. No API-key fallback was used.": "{v0} request cancelled or connection closed. No API-key fallback was used.",
+ "The selected {v0} model or connection is unavailable. Refresh Providers and explicitly choose an available model. No API-key fallback was used.": "The selected {v0} model or connection is unavailable. Refresh Providers and explicitly choose an available model. No API-key fallback was used.",
+ "{v0} request failed. Check the connection, account access and provider limits, then retry. No API-key fallback was used.": "{v0} request failed. Check the connection, account access and provider limits, then retry. No API-key fallback was used.",
+ "GitHub Enterprise URL/domain (blank for github.com)": "GitHub Enterprise URL/domain (blank for github.com)",
+ "Enabling models...": "Enabling models...",
+ "Waiting for authorization...": "Waiting for authorization...",
+ "no search engine configured — set a Tavily or Brave search API key": "no search engine configured — set a Tavily or Brave search API key",
+ "search engine is set to \"{v0}\" but its API key is missing — set the {v1} key": "search engine is set to \"{v0}\" but its API key is missing — set the {v1} key",
+ "search engine: {v0} (chosen)": "search engine: {v0} (chosen)",
+ "search engine: {v0} (auto)": "search engine: {v0} (auto)",
+ "update": "update"
+}
diff --git a/src/core/locales/ru.json b/src/core/locales/ru.json
new file mode 100644
index 0000000..ae99ff1
--- /dev/null
+++ b/src/core/locales/ru.json
@@ -0,0 +1,875 @@
+{
+ "Abort this turn?": "Прервать ответ?",
+ "The partial answer stays in the transcript.": "Частичный ответ останется в истории занятия.",
+ "Abort": "Прервать",
+ "Dismiss this question?": "Пропустить вопрос?",
+ "Skipped — the teacher moves on without it.": "Вопрос будет пропущен — преподаватель продолжит без ответа на него.",
+ "Dismiss": "Пропустить",
+ "Discard this problem?": "Отменить задачу?",
+ "The problem is dropped — no solution is revealed.": "Задача будет отменена, решение не будет показано.",
+ "Discard": "Отменить задачу",
+ " The working turn stops too.": " Текущий ответ тоже будет прерван.",
+ "Cancel": "Отмена",
+ "needs model setup": "нужно настроить модели",
+ "needs a provider key": "нужен ключ провайдера",
+ "{v0}s": "{v0} с",
+ "{v0}m {v1}s": "{v0} мин {v1} с",
+ "{v0}h {v1}m": "{v0} ч {v1} мин",
+ "Background agents": "Фоновые агенты",
+ "Total running cost (inferred)": "Общая текущая стоимость (оценка)",
+ "Extraction — {v0}": "Извлечение знаний — {v0}",
+ "Researcher": "Исследователь",
+ "Demo builder": "Создатель демонстраций",
+ "Figure maker": "Создатель иллюстраций",
+ "PDF reader": "Чтение PDF",
+ "Sub-agent": "Вспомогательный агент",
+ "Abort failed — try again": "Не удалось прервать — повторите попытку",
+ "End this background process": "Завершить фоновый процесс",
+ "End {v0} in {v1}": "Завершить «{v0}» в занятии «{v1}»",
+ "Cost": "Стоимость",
+ "Context": "Контекст",
+ "Model": "Модель",
+ "Steps": "Шаги",
+ "Open session →": "Открыть занятие →",
+ "No messages yet": "Сообщений пока нет",
+ "Start a conversation to see messages here": "Начните разговор, чтобы здесь появились сообщения",
+ "Previous branch": "Предыдущая ветка",
+ "Next branch": "Следующая ветка",
+ "Thinking...": "Размышляю…",
+ "AGENT’S THINKING": "РАССУЖДЕНИЯ АГЕНТА",
+ "Thinking": "Рассуждения",
+ "— skipped": "— пропущено",
+ "YOUR THINKING": "ВАШИ РАССУЖДЕНИЯ",
+ "Probe": "Диагностика",
+ "Recall": "Воспроизведение по памяти",
+ "Prediction": "Предсказание",
+ "Check": "Проверка",
+ "Explain": "Объяснение",
+ "Question": "Вопрос",
+ "didn't know": "не знаю",
+ "Answered “I don't know”.": "Вы ответили «Не знаю».",
+ "Hide why": "Скрыть объяснение",
+ "Why": "Почему",
+ "Why:": "Почему:",
+ "The answer:": "Ответ:",
+ "unknown error": "неизвестная ошибка",
+ "Demo completed, but the result could not be displayed.": "Демонстрация готова, но отобразить её не удалось.",
+ "Interactive demo": "Интерактивная демонстрация",
+ "interactive demo": "интерактивная демонстрация",
+ "Invert demo brightness": "Инвертировать яркость демонстрации",
+ "Invert colors": "Инвертировать цвета",
+ "Download demo as HTML": "Скачать демонстрацию в формате HTML",
+ "Download as HTML": "Скачать HTML",
+ "The demo \"{v0}\" threw an error: {v1}": "В демонстрации «{v0}» возникла ошибка: {v1}",
+ "This demo threw an error": "В демонстрации возникла ошибка",
+ "Send to teacher": "Отправить преподавателю",
+ "Research agent": "Агент-исследователь",
+ "Demo agent": "Агент демонстраций",
+ "Figure agent": "Агент иллюстраций",
+ "Set up models": "Настроить модели",
+ "Add a key": "Добавить ключ",
+ "{v0} running": "{v0}: выполняется",
+ "Dispatched…": "Задача отправлена…",
+ "PDF reading completed, but the result could not be displayed.": "Чтение PDF завершено, но отобразить результат не удалось.",
+ "PDF failed": "Ошибка чтения PDF",
+ "PDF brief": "Обзор PDF",
+ "Pages read:": "Прочитанные страницы:",
+ "Pages consulted:": "Использованные страницы:",
+ "Research completed, but the result could not be displayed.": "Исследование завершено, но отобразить результат не удалось.",
+ "Research failed": "Ошибка исследования",
+ "Research brief": "Обзор исследования",
+ "Sources": "Источники",
+ "{v0} — live": "{v0} — выполняется",
+ "Input (streaming)": "Входные данные (поступают)",
+ "Probe concluded — your assessment": "Диагностика завершена — ваша оценка",
+ "The prober retried:": "Агент диагностики повторил попытку:",
+ "The assessment was malformed — the prober retried with a corrected one.": "Оценка содержала ошибки формата — агент диагностики подготовил исправленный вариант.",
+ "Writing the assessment…": "Готовлю оценку…",
+ "A sub-agent finished, but its result could not be displayed.": "Вспомогательный агент завершил работу, но отобразить результат не удалось.",
+ "aborted by the learner": "прервано пользователем",
+ "reading complete": "чтение завершено",
+ "· read": "· прочитано",
+ "view brief": "показать обзор",
+ "hide brief": "скрыть обзор",
+ "Research": "Исследование",
+ "{v0}\n[{v1} image{v2} attached to the model]": "{v0}\n[Изображений, приложенных для модели: {v1}]",
+ "Input": "Входные данные",
+ "Error": "Ошибка",
+ "Output": "Результат",
+ "running…": "выполняется…",
+ "Figure completed, but the result could not be displayed.": "Иллюстрация готова, но отобразить её не удалось.",
+ "Figure": "Иллюстрация",
+ "Invert figure brightness": "Инвертировать яркость иллюстрации",
+ "Download figure as SVG": "Скачать иллюстрацию в формате SVG",
+ "Download as SVG": "Скачать SVG",
+ "Open figure fullscreen": "Открыть иллюстрацию на весь экран",
+ "Open fullscreen": "На весь экран",
+ "Working": "Выполняется",
+ "{v0}ms": "{v0} мс",
+ "Search nodes…": "Поиск узлов…",
+ "Search nodes": "Поиск узлов",
+ "Close chains": "Закрыть цепочки",
+ "Name": "Название",
+ "Props": "Утверждения",
+ "Created": "Создано",
+ "No matching chains.": "Подходящих цепочек нет.",
+ "Select everything born in {v0}": "Выбрать всё, созданное в занятии «{v0}»",
+ "Expand cluster {v0}": "Развернуть группу «{v0}»",
+ "Select chain {v0}": "Выбрать цепочку «{v0}»",
+ "Expand chain {v0}": "Развернуть цепочку «{v0}»",
+ "Select node {v0}": "Выбрать узел «{v0}»",
+ "Clear": "Очистить",
+ "Done picking — {v0} {v1} selected": "Завершить выбор — выбрано узлов: {v0}",
+ "Done": "Готово",
+ "Delete {v0} selected {v1}": "Удалить выбранные узлы ({v0})",
+ "Delete": "Удалить",
+ "rename failed: {v0}": "не удалось переименовать: {v0}",
+ "rename failed:": "не удалось переименовать:",
+ "Session title": "Название занятия",
+ "Rename this session": "Переименовать занятие",
+ "Rename session": "Переименовать занятие",
+ "Open the full plan": "Открыть полный план",
+ "Plan": "План",
+ "Practice": "Практика",
+ "New session": "Новое занятие",
+ "The teacher will open by probing what you already know.": "Преподаватель начнёт с проверки того, что вы уже знаете.",
+ "Next problem": "Следующая задача",
+ "You": "Вы",
+ "Retrying…": "Повторная попытка…",
+ "Compacting context": "Сжатие контекста",
+ "COMPACTING CONTEXT…": "СЖАТИЕ КОНТЕКСТА…",
+ "THINKING…": "РАЗМЫШЛЯЮ…",
+ "Something went wrong:": "Произошла ошибка:",
+ "Set up your models": "Настройте модели",
+ "Ask anything, or press Enter for the next one.": "Задайте вопрос или нажмите Enter для следующей задачи.",
+ "Context compacted": "Контекст сжат",
+ " · {v0}k tok": " · {v0} тыс. токенов",
+ "History compacted": "История сжата",
+ "{v0} {v1} selected": "Выбрано узлов: {v0}",
+ "Pick nodes": "Выбрать узлы",
+ "Ask, answer, or steer the lesson…": "Спросите, ответьте или задайте направление занятия…",
+ "Stop": "Остановить",
+ "Send": "Отправить",
+ "An extraction is running — try again when the activity dock drains.": "Идёт извлечение знаний — дождитесь завершения процессов на панели активности.",
+ "Delete node": "Удалить узел",
+ "Delete node?": "Удалить узел?",
+ "Delete {v0} nodes?": "Удалить узлы ({v0})?",
+ ", and {v0} more": ", и ещё {v0}",
+ "This permanently deletes the node — this cannot be undone.": "Узел будет удалён безвозвратно. Отменить это действие нельзя.",
+ "This permanently deletes all {v0} nodes — this cannot be undone.": "Все выбранные узлы ({v0}) будут удалены безвозвратно. Отменить это действие нельзя.",
+ "Try again": "Повторить попытку",
+ "Deleting…": "Удаление…",
+ "Delete failed — network error. Try again.": "Не удалось удалить: ошибка сети. Повторите попытку.",
+ "Also remove its graph traces": "Также удалить следы занятия из графа",
+ "This session’s topics, links, and events leave the graph too. Topics other sessions or imported notes also cover survive; topics only this session created are removed entirely.": "Темы, связи и события этого занятия тоже будут удалены из графа. Темы, встречающиеся в других занятиях или импортированных заметках, сохранятся; темы, созданные только этим занятием, будут удалены полностью.",
+ "Delete session": "Удалить занятие",
+ "Delete session?": "Удалить занятие?",
+ "This permanently removes the session — its messages, background jobs, and extraction records. Everything it added to the graph stays unless you check below. This cannot be undone.": "Занятие будет удалено безвозвратно вместе с сообщениями, фоновыми задачами и записями об извлечении знаний. Всё, что оно добавило в граф, сохранится, если не отметить пункт ниже. Отменить это действие нельзя.",
+ "Dictation needs a Deepgram key": "Для диктовки нужен ключ Deepgram",
+ "Deepgram reconnect failed": "Не удалось восстановить соединение с Deepgram",
+ "Deepgram connection lost ({v0})": "Соединение с Deepgram потеряно ({v0})",
+ "Microphone permission denied": "Доступ к микрофону запрещён",
+ "mic unavailable": "микрофон недоступен",
+ "token unavailable": "токен недоступен",
+ "Dictation failed: {v0}": "Ошибка диктовки: {v0}",
+ "Start dictation": "Начать диктовку",
+ "Stop dictation (Esc)": "Остановить диктовку (Esc)",
+ "Stop dictation": "Остановить диктовку",
+ "extracting…": "извлечение знаний…",
+ "fully extracted": "всё извлечено",
+ "tok uncovered": "токенов не обработано",
+ "Running": "Выполняется",
+ "Extract": "Извлечь знания",
+ "How much of this session isn't mapped yet": "Какая часть занятия ещё не отражена в графе",
+ "Extraction coverage": "Полнота извлечения знаний",
+ "Uncovered": "Не обработано",
+ "Covered": "Обработано",
+ "Messages": "Сообщения",
+ "Chunk threshold": "Порог фрагмента",
+ "Last extraction": "Последнее извлечение",
+ "Status": "Состояние",
+ "Graph context": "Контекст графа",
+ "{v0}k": "{v0} тыс.",
+ "{v0}M": "{v0} млн",
+ "{v0}m": "{v0} мин",
+ "{v0}h": "{v0} ч",
+ "{v0}d": "{v0} дн.",
+ "Graph costs — click for details": "Стоимость графа — нажмите для подробностей",
+ "Extraction": "Извлечение знаний",
+ "Audit": "Проверка",
+ "Graph pipeline costs": "Затраты на обработку графа",
+ "in /": "вход /",
+ " +1 unpriced": " +1 без цены",
+ "Graph display settings": "Настройки отображения графа",
+ "Node size": "Размер узлов",
+ "Edge width": "Толщина связей",
+ "Arrowhead size": "Размер стрелок",
+ "Label size": "Размер подписей",
+ "Labels until zoom": "Порог масштаба для подписей",
+ "Regions": "Области",
+ "Background colors": "Цвета фона",
+ "Dilation": "Расширение",
+ "Blur": "Размытие",
+ "Edge alpha": "Непрозрачность границ",
+ "Bitmap resolution": "Разрешение изображения",
+ "Cluster labels": "Подписи групп",
+ "Cluster label size": "Размер подписей групп",
+ "Physics": "Физика",
+ "Node repulsion": "Отталкивание узлов",
+ "Edge attraction": "Притяжение связей",
+ "Edge length": "Длина связей",
+ "Central pull": "Притяжение к центру",
+ "Cluster pull": "Притяжение к группе",
+ "Friction": "Трение",
+ "Depth anchor": "Ориентация по глубине",
+ "Off": "Выкл.",
+ "Vertical (up)": "По вертикали (вверх)",
+ "Radial (outward)": "По радиусу (наружу)",
+ "Depth strength": "Сила разделения по глубине",
+ "Depth spacing": "Расстояние по глубине",
+ "Base ring radius": "Радиус базового кольца",
+ "No matching nodes.": "Подходящих узлов нет.",
+ "Every reasoning chain": "Все цепочки рассуждений",
+ "Chains": "Цепочки",
+ "Import notes into the graph": "Импортировать заметки в граф",
+ "Importing…": "Импорт…",
+ "Import notes…": "Импортировать заметки…",
+ "Knowledge graph": "Граф знаний",
+ "Start a session or import notes to build your graph.": "Начните занятие или импортируйте заметки, чтобы построить граф.",
+ "Sessions": "Занятия",
+ "No sessions recorded.": "Занятий пока нет.",
+ "What do you want to practice?": "Что вы хотите попрактиковать?",
+ "What do you want to learn?": "Что вы хотите изучить?",
+ "Start session": "Начать занятие",
+ "Teach": "Обучение",
+ "A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know.": "Преподаватель с памятью: занятия, проверочные вопросы и граф ваших знаний.",
+ "Could not load language settings": "Не удалось загрузить языковые настройки",
+ "Could not save language settings": "Не удалось сохранить языковые настройки",
+ "Interface language": "Язык интерфейса",
+ "Speech recognition language": "Язык распознавания речи",
+ "Auto": "Авто",
+ "System default": "Язык системы",
+ "Global": "Общие",
+ "Chat": "Занятие",
+ "Graph view": "Граф",
+ "Keyboard shortcuts": "Сочетания клавиш",
+ "Esc to close": "Esc — закрыть",
+ "Copy diagram source": "Копировать исходный код диаграммы",
+ "Open full-screen": "Открыть на весь экран",
+ "Expand diagram": "Развернуть диаграмму",
+ "Copy code": "Копировать код",
+ "Copy table": "Копировать таблицу",
+ "Copy equation source": "Копировать исходный код формулы",
+ "Diagram failed to render": "Не удалось отобразить диаграмму",
+ "Show diagram source": "Показать исходный код диаграммы",
+ "Retry": "Повторить",
+ "teaching session": "учебное занятие",
+ "teaching sessions": "учебные занятия",
+ "practice session": "практическое занятие",
+ "practice sessions": "практические занятия",
+ "imported note": "импортированная заметка",
+ "imported notes": "импортированные заметки",
+ "Nothing here yet — import notes to begin building the graph.": "Здесь пока пусто — импортируйте заметки, чтобы начать строить граф.",
+ "catalog request failed: {v0}": "не удалось загрузить каталог: {v0}",
+ "Could not reach the server — try again.": "Не удалось связаться с сервером — повторите попытку.",
+ "Model selector": "Выбор модели",
+ "No models — add a provider key in Keys": "Моделей нет — добавьте ключ провайдера в настройках провайдеров",
+ "This session's model": "Модель этого занятия",
+ "Strong tier — the default for new sessions": "Основная модель — по умолчанию для новых занятий",
+ "{v0} has no thinking levels.": "Модель {v0} не поддерживает уровни рассуждения.",
+ "Pick a model first.": "Сначала выберите модель.",
+ "Loading…": "Загрузка…",
+ "Levels this model actually supports": "Уровни, поддерживаемые этой моделью",
+ "New practice session": "Новое практическое занятие",
+ "Pick nodes from your knowledge graph, describe what you want to practice, or bring your own problem.": "Выберите узлы графа знаний, опишите, что хотите попрактиковать, или предложите свою задачу.",
+ "Invalid import response. Please retry the file.": "Некорректный ответ на запрос импорта. Попробуйте импортировать файл ещё раз.",
+ "Import request failed (HTTP {v0}){v1}. Please retry the file.": "Ошибка импорта (HTTP {v0}){v1}. Попробуйте импортировать файл ещё раз.",
+ "Probing": "Диагностика",
+ "Planning": "Планирование",
+ "Teaching": "Обучение",
+ "Plan · {v0} node{v1}": "План · узлов: {v0}",
+ "Plan diagram": "Диаграмма плана",
+ "The full plan": "Полный план",
+ "The plan": "План",
+ "live — statuses derive from your check outcomes": "обновляется — состояния определяются результатами ваших проверок",
+ "Close": "Закрыть",
+ "Drag to pan · scroll to zoom": "Перетаскивание — перемещение · прокрутка — масштаб",
+ "Zoom out": "Уменьшить масштаб",
+ "Fit plan to screen": "Вписать план в экран",
+ "Zoom in": "Увеличить масштаб",
+ "No plan in this session yet.": "В этом занятии пока нет плана.",
+ "Problem": "Задача",
+ "Solved": "Решено",
+ "Solved with hints": "Решено с подсказками",
+ "Moved on": "Переход дальше",
+ "Surrendered": "Решение запрошено",
+ "Dismissed": "Пропущено",
+ "Problem dismissed.": "Задача пропущена.",
+ "Concluded": "Завершено",
+ "Model solution": "Образец решения",
+ "Metadata": "Метаданные",
+ "Mode": "Режим",
+ "Targets": "Цели",
+ "Outcome": "Результат",
+ "Hint rung": "Уровень подсказки",
+ "Miss class": "Тип ошибки",
+ "Retest": "Повторная проверка",
+ "Work here, think out loud.": "Решайте здесь и записывайте ход мыслей.",
+ "compacting session…": "сжатие контекста занятия…",
+ "↵ submits · ⇧↵ newline": "↵ — отправить · ⇧↵ — новая строка",
+ "Submit thinking": "Отправить рассуждения",
+ "This question arrived malformed — the transcript keeps the retry.": "Вопрос поступил с ошибкой формата — повторная попытка сохранена в истории.",
+ "Check your understanding": "Проверьте понимание",
+ "Open": "Открыть",
+ "Answer": "Ответить",
+ "I don't know": "Не знаю",
+ "Type your own answer…": "Введите свой вариант ответа…",
+ "Confidence": "Уверенность",
+ "Confidence — the probability your selected answer is correct": "Уверенность — вероятность того, что выбранный ответ верен",
+ "{v0} percent": "{v0} процентов",
+ "The probability your selected answer is correct — 50% is a coin flip. Adjust with ←/→.": "Вероятность того, что выбранный ответ верен. 50% — как подбросить монету. Меняйте значение клавишами ←/→.",
+ "Your answer": "Ваш ответ",
+ "Type your answer…": "Введите ответ…",
+ "Why this answer? Whatever comes to mind.": "Почему вы так ответили? Запишите, что приходит в голову.",
+ "Submit answer": "Отправить ответ",
+ "Thinking while reading": "Размышления при чтении",
+ "Whatever comes to mind as you read.": "Записывайте всё, что приходит в голову во время чтения.",
+ "Aborted": "Прервано",
+ "The turn stream body is empty.": "Поток ответа пуст.",
+ "A turn is already running — try again in a moment.": "Ответ уже формируется — попробуйте чуть позже.",
+ "The dismissal failed ({v0}).": "Не удалось пропустить ({v0}).",
+ "Session Find": "Поиск по занятию",
+ "Find in session": "Найти в занятии",
+ "Find in session…": "Найти в занятии…",
+ "Previous match": "Предыдущее совпадение",
+ "Previous match (Shift+Enter)": "Предыдущее совпадение (Shift+Enter)",
+ "Next match": "Следующее совпадение",
+ "Next match (Enter)": "Следующее совпадение (Enter)",
+ "Close Find": "Закрыть поиск",
+ "Close Find (Esc)": "Закрыть поиск (Esc)",
+ "Diagram focus": "Просмотр диаграммы",
+ "Diagram": "Диаграмма",
+ "from this lesson": "из этого занятия",
+ "Fit diagram to view": "Вписать диаграмму в окно",
+ "Rendering diagram…": "Построение диаграммы…",
+ "Strong": "Основная",
+ "The main model for your sessions": "Основная модель для ваших занятий",
+ "Cheap": "Экономичная",
+ "The assistants that research, build demos, and grade your work": "Помощники для исследований, демонстраций и проверки вашей работы",
+ "Vision": "С поддержкой изображений",
+ "The assistants that draw and read PDFs": "Помощники для создания иллюстраций и чтения PDF",
+ "Your version is in use": "Используется ваша версия",
+ "Teacher": "Преподаватель",
+ "Prober": "Агент диагностики",
+ "Shared": "Общие",
+ "Used by both agents": "Используется обоими агентами",
+ "Catalog unavailable": "Каталог недоступен",
+ "Save failed: {v0}": "Не удалось сохранить: {v0}",
+ "Saved": "Сохранено",
+ "Delete failed: {v0}": "Не удалось удалить: {v0}",
+ "Deleted": "Удалено",
+ "✓ key": "✓ ключ есть",
+ "· no key": "· нет ключа",
+ "prompt {v0}": "инструкции {v0}",
+ "Hide text": "Скрыть текст",
+ "Show text": "Показать текст",
+ "Source": "Исходный текст",
+ "The original instructions": "Исходные инструкции",
+ "Override": "Своя версия",
+ "Your version of the instructions": "Ваша версия инструкций",
+ "Add your own instructions…": "Добавьте свои инструкции…",
+ "Save": "Сохранить",
+ "Models": "Модели",
+ "{v0} model": "Модель: {v0}",
+ "{v0} thinking": "Уровень рассуждения: {v0}",
+ "Runs every agent of this tier": "Используется всеми агентами этого уровня",
+ "Tools": "Инструменты",
+ "Graph tools": "Инструменты графа",
+ "Lets the tutor consult your knowledge map between sessions": "Позволяет преподавателю обращаться к вашей карте знаний между занятиями",
+ "Graph is disabled": "Граф отключён",
+ "graph tools": "инструменты графа",
+ "Quizzing": "Проверка понимания",
+ "How both tutors check your understanding": "Как оба преподавателя проверяют ваше понимание",
+ "Quizzing mechanics": "Механика проверок",
+ "Multiple choice": "Выбор варианта",
+ "Free recall": "Воспроизведение без подсказок",
+ "Tool · {v0}": "Инструмент · {v0}",
+ "tool {v0}": "инструмент {v0}",
+ "Search engine": "Поисковая система",
+ "Automatic": "Автоматически",
+ "Compaction threshold": "Порог сжатия контекста",
+ "{v0} tok": "{v0} токенов",
+ "compaction threshold": "порог сжатия контекста",
+ "System prompts": "Системные инструкции",
+ "Loading prompt segments…": "Загрузка разделов инструкций…",
+ "Coming soon": "Скоро появится",
+ "Dark": "Тёмная",
+ "Background": "Фон",
+ "Empty = {v0}": "Пусто = {v0}",
+ "background hex": "цвет фона в HEX",
+ "Reading font": "Шрифт текста",
+ "Choose from your installed fonts": "Выберите из установленных шрифтов",
+ "Common families — installed fonts could not be read": "Стандартные шрифты — не удалось прочитать список установленных",
+ "Common families — your installed list needs the desktop app": "Стандартные шрифты — список установленных доступен в настольном приложении",
+ "Your installed fonts": "Установленные шрифты",
+ "Reading font size": "Размер текста",
+ "prose font size": "размер текста",
+ "Session names in serif": "Названия занятий шрифтом с засечками",
+ "session names serif": "названия занятий с засечками",
+ "Heading weight": "Насыщенность заголовков",
+ "heading weight": "насыщенность заголовков",
+ "Bold color": "Цвет жирного текста",
+ "Empty = same as text": "Пусто = цвет основного текста",
+ "same as text": "цвет основного текста",
+ "bold color": "цвет жирного текста",
+ "Italic color": "Цвет курсива",
+ "italic color": "цвет курсива",
+ "Off = the graph stops growing": "Выкл. — граф перестанет пополняться",
+ "extraction enabled": "извлечение знаний включено",
+ "Graph rendering": "Отображение графа",
+ "Off = home shows the chains view": "Выкл. — на главной отображаются цепочки",
+ "graph rendering enabled": "отображение графа включено",
+ "Extraction chunk budget": "Размер фрагмента для извлечения знаний",
+ "extraction chunk budget": "размер фрагмента для извлечения знаний",
+ "LearnHarness stores your ChatGPT account details and credentials in an unencrypted local file on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness.": "LearnHarness сохраняет данные вашей учётной записи ChatGPT и учётные данные для входа в незашифрованном локальном файле на этом устройстве. При использовании подключения соответствующий контекст разговора, учебные материалы и результаты инструментов отправляются напрямую в OpenAI. Действуют ограничения тарифа ChatGPT; разрешения на использование дополнительных кредитов и управление ими выполняются в ChatGPT.",
+ "Background work counts toward your ChatGPT plan usage.": "Фоновая работа расходует лимиты вашего тарифа ChatGPT.",
+ "Allow background use for all ChatGPT accounts": "Разрешить фоновое использование для всех учётных записей ChatGPT",
+ "Account disconnected locally. Remote revocation could not be confirmed.": "Учётная запись отключена на этом устройстве. Подтвердить отзыв доступа на стороне сервиса не удалось.",
+ "Disconnect account?": "Отключить учётную запись?",
+ "This removes the saved account and its credentials from this device.": "Сохранённая учётная запись и данные для входа будут удалены с этого устройства.",
+ "The ChatGPT connection could not be changed. Try again.": "Не удалось изменить подключение ChatGPT. Повторите попытку.",
+ "The desktop connection is unavailable. Retry when the app is ready.": "Подключение настольного приложения недоступно. Повторите попытку, когда приложение будет готово.",
+ "Disconnect account": "Отключить учётную запись",
+ "Saved ChatGPT account": "Сохранённая учётная запись ChatGPT",
+ "Add another account or workspace": "Добавить другую учётную запись или рабочее пространство",
+ "Manage…": "Управление…",
+ "OpenAI privacy": "Конфиденциальность OpenAI",
+ "Manage usage": "Использование лимитов",
+ "ChatGPT sign-in is available only in the desktop app. API-key connections remain available here.": "Вход в ChatGPT доступен только в настольном приложении. Подключение через API-ключ доступно и здесь.",
+ "Waiting for browser sign-in…": "Ожидание входа через браузер…",
+ "Manage ChatGPT account": "Управление учётной записью ChatGPT",
+ "Add accounts": "Добавить учётные записи",
+ "Disconnect": "Отключить",
+ "Sign in again": "Войти снова",
+ "Continue with ChatGPT": "Продолжить с ChatGPT",
+ "ChatGPT account": "Учётная запись ChatGPT",
+ "Connect": "Подключить",
+ "Enable background use": "Разрешить фоновое использование",
+ "Graph": "Граф",
+ "Fixed — part of this screen": "Не изменяется — часть этого экрана",
+ "Press keys…": "Нажмите клавиши…",
+ "Rebind": "Изменить сочетание",
+ "Reset": "Сбросить",
+ "Stored on this device (lh:keymap) — overrides only.": "На этом устройстве (lh:keymap) хранятся только изменённые сочетания.",
+ "Reset all to defaults": "Сбросить все сочетания",
+ "not set": "не задан",
+ " · env": " · из окружения",
+ "Paste key…": "Вставьте ключ…",
+ "{v0} key": "Ключ {v0}",
+ "key ↗": "ключ ↗",
+ "+ Add provider key": "+ Добавить ключ провайдера",
+ "Loading keys…": "Загрузка ключей…",
+ "Model API keys": "API-ключи моделей",
+ "Subscriptions": "Подписки",
+ "Dictation": "Диктовка",
+ "Search": "Поиск",
+ "Search models…": "Поиск моделей…",
+ "Loading models…": "Загрузка моделей…",
+ "No models match.": "Подходящих моделей нет.",
+ "Subscription connection": "Подключение по подписке",
+ "USD per 1M tokens in/out": "USD за 1 млн входных/выходных токенов",
+ "{v0} (subscription)": "{v0} (подписка)",
+ "Search providers…": "Поиск провайдеров…",
+ "No providers match.": "Подходящих провайдеров нет.",
+ "Search fonts…": "Поиск шрифтов…",
+ "Loading fonts…": "Загрузка шрифтов…",
+ "Use “": "Использовать «",
+ "Providers": "Провайдеры",
+ "Agent": "Агент",
+ "Appearance": "Оформление",
+ "Keyboard": "Клавиатура",
+ "Stored on this device": "Хранится на этом устройстве",
+ "General": "Общие",
+ "Automatic updates arrive in an upcoming release": "Автоматические обновления появятся в одном из следующих выпусков",
+ "status {v0}": "статус {v0}",
+ "Settings": "Настройки",
+ "Close settings": "Закрыть настройки",
+ "Settings sections": "Разделы настроек",
+ "Loading settings…": "Загрузка настроек…",
+ "Settings could not be loaded — refresh to retry.": "Не удалось загрузить настройки — обновите страницу и повторите попытку.",
+ "Auto-update": "Автообновление",
+ "auto update": "автообновление",
+ "Pi's native Copilot sign-in may enable additional models whose policy is not yet configured. Models explicitly disabled by policy are not enabled. Organization restrictions still apply.": "Вход в Copilot через Pi может открыть доступ к дополнительным моделям, для которых политика ещё не настроена. Модели, явно отключённые политикой, не включаются. Ограничения организации продолжают действовать.",
+ "Allow LearnHarness helper agents and automatic follow-ups to use this connection.": "Разрешить вспомогательным агентам LearnHarness и автоматическим продолжениям использовать это подключение.",
+ "Your prompts, tool results, and relevant session context are sent to {v0} to run the work you select. Credentials are stored locally in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.": "Для выполнения выбранных вами действий ваши запросы, результаты инструментов и соответствующий контекст занятия отправляются провайдеру {v0}. Данные для входа хранятся локально в текстовом файле, доступном только владельцу. Действуют условия провайдера, политика использования данных и ограничения тарифа. Это подключение никогда не переключается на API-ключ.",
+ "Disconnect {v0} from LearnHarness? This stops work using this connection and removes its saved credentials from this device. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.": "Отключить {v0} от LearnHarness? Работа через это подключение будет остановлена, а сохранённые данные для входа удалены с устройства. Подписка не отменится, и доступ на стороне провайдера не будет отозван. Сохранённые модели будут недоступны, пока вы не восстановите подключение или не выберете другие.",
+ "The connection could not be changed. Try again.": "Не удалось изменить подключение. Повторите попытку.",
+ "Sign-in did not complete. Try connecting again.": "Вход не завершён. Попробуйте подключиться снова.",
+ "{v0} connection": "Подключение {v0}",
+ "Allow background use": "Разрешить фоновое использование",
+ "Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.": "Вход по подписке требует защищённого подключения к серверу настольного приложения. Подключение через API-ключ доступно и здесь.",
+ "Device code:": "Код устройства:",
+ "The browser could not be opened. Try again.": "Не удалось открыть браузер. Повторите попытку.",
+ "Open browser": "Открыть браузер",
+ "Select…": "Выберите…",
+ "Continue": "Продолжить",
+ "Add a provider key": "Добавить ключ провайдера",
+ "Prober, teacher": "Агент диагностики, преподаватель",
+ "Researcher, demo builder, extractor, auditor": "Исследователь, создатель демонстраций, извлечение знаний, проверка",
+ "Visual maker + PDF reader": "Создание иллюстраций и чтение PDF",
+ "Choose your AI provider and paste its API key. You’ll pick your models next.": "Выберите провайдера ИИ и вставьте его API-ключ. Затем вы выберете модели.",
+ "Provider": "Провайдер",
+ "Pick a provider…": "Выберите провайдера…",
+ "Saving…": "Сохранение…",
+ "Save key": "Сохранить ключ",
+ "Save failed. Check your connection and try again.": "Не удалось сохранить. Проверьте подключение и повторите попытку.",
+ "Close setup": "Закрыть настройку",
+ "Model catalog could not be loaded. Check the connection and retry.": "Не удалось загрузить каталог моделей. Проверьте подключение и повторите попытку.",
+ "Pick the model for each tier. All three are required before chats can start.": "Выберите модель для каждого уровня. Все три нужны, чтобы начать занятие.",
+ "Pick a model…": "Выберите модель…",
+ "No image-capable models. Add a provider key with access to a vision model.": "Нет моделей с поддержкой изображений. Добавьте ключ провайдера с доступом к такой модели.",
+ "Save models": "Сохранить модели",
+ "Open sidebar": "Открыть боковую панель",
+ "just now": "только что",
+ "{v0}m ago": "{v0} мин назад",
+ "{v0}h ago": "{v0} ч назад",
+ "{v0}d ago": "{v0} дн. назад",
+ "Compacting context · {v0}": "Сжатие контекста · {v0}",
+ "Waiting for input · {v0}": "Ожидание ввода · {v0}",
+ "Problem waiting · {v0}": "Ожидание решения · {v0}",
+ "Working…": "Выполняется…",
+ "Practice session": "Практическое занятие",
+ "Home": "Главная",
+ "Close sidebar": "Закрыть боковую панель",
+ "create a new teaching session": "создать учебное занятие",
+ "create a new practice session": "создать практическое занятие",
+ "Search sessions": "Поиск занятий",
+ "Search…": "Поиск…",
+ "All": "Все",
+ "No sessions match.": "Подходящих занятий нет.",
+ "No sessions yet.": "Занятий пока нет.",
+ "Rename": "Переименовать",
+ "Export as Markdown…": "Экспорт в Markdown…",
+ "Delete…": "Удалить…",
+ "Session stats": "Статистика занятия",
+ "Context (last call)": "Контекст (последний запрос)",
+ "Usage": "Использование",
+ "Total cost": "Общая стоимость",
+ "Manage ChatGPT usage": "Лимиты ChatGPT",
+ "Unpriced turns": "Запросы без цены",
+ "Turns": "Запросы",
+ "is ready": "готова к установке",
+ "— restart to finish installing.": "— перезапустите приложение, чтобы завершить установку.",
+ "Restart": "Перезапустить",
+ "Toggle dictation": "Включить или остановить диктовку",
+ "Open settings": "Открыть настройки",
+ "Cycle thinking level": "Следующий уровень рассуждения",
+ "Toggle sessions sidebar": "Показать или скрыть список занятий",
+ "Toggle all your thinking": "Показать или скрыть все ваши рассуждения",
+ "Find": "Найти",
+ "Shortcut cheat sheet": "Справка по сочетаниям клавиш",
+ "Question · focus thinking ↔ rows": "Вопрос · переключить фокус между рассуждениями и вариантами",
+ "Question · next row": "Вопрос · следующий вариант",
+ "Question · previous row": "Вопрос · предыдущий вариант",
+ "Question · pick row": "Вопрос · выбрать вариант",
+ "Question · submit": "Вопрос · отправить ответ",
+ "Space": "Пробел",
+ "Speech-to-text key for dictation — needs ≥ Member permission in the Deepgram console to mint tokens.": "Ключ распознавания речи для диктовки. Для выпуска токенов нужны права уровня Member или выше в консоли Deepgram.",
+ "Web-search engine for the researcher sub-agent (AI-native search API). Setting the key activates it — no engine toggle needed.": "Поисковая система для агента-исследователя (поисковый API для ИИ). Добавление ключа включает её автоматически.",
+ "Web-search engine for the researcher sub-agent — the alternative to Tavily. Setting the key activates it.": "Поисковая система для агента-исследователя — альтернатива Tavily. Добавление ключа включает её автоматически.",
+ "{v0} must be at least {v1} characters with no whitespace": "Ключ {v0} должен содержать не менее {v1} символов без пробелов",
+ "{v0} must start with \"tvly-\" (Tavily's key format)": "Ключ {v0} должен начинаться с «tvly-» (формат ключей Tavily)",
+ "unknown key \"{v0}\" (supported: {v1})": "Неизвестный ключ «{v0}» (поддерживаются: {v1})",
+ "{v0} must be at least 12 characters with no whitespace": "Ключ {v0} должен содержать не менее 12 символов без пробелов",
+ "Dictate": "Диктовать",
+ "of": "из",
+ "correct": "верно",
+ "incorrect": "неверно",
+ "failed": "ошибка",
+ "complete": "завершено",
+ "sources": "источников",
+ "unlabeled": "без названия",
+ "notes": "заметки",
+ "tokens": "токенов",
+ "unpriced": "цена неизвестна",
+ "out": "выход",
+ "steps": "шагов",
+ "custom": "свой вариант",
+ "needed": "нужно выбрать",
+ "now": "сейчас",
+ "starting": "запуск",
+ "done": "готово",
+ "queued": "в очереди",
+ "running": "выполняется",
+ "extracting": "извлечение знаний",
+ "auditing": "проверка",
+ "merging": "объединение",
+ "delivery failed": "ошибка доставки",
+ "waiting": "ожидание",
+ "waiting for a slot": "ожидание свободного места",
+ "building demo": "создание демонстрации",
+ "rendering figure": "создание иллюстрации",
+ "reading PDF": "чтение PDF",
+ "final verification": "финальная проверка",
+ "off": "выкл.",
+ "minimal": "минимальный",
+ "low": "низкий",
+ "medium": "средний",
+ "high": "высокий",
+ "xhigh": "очень высокий",
+ "max": "максимальный",
+ "No thinking levels.": "Уровни рассуждения недоступны.",
+ "Learner": "Учащийся",
+ "Quiz": "Проверка",
+ "Recall check": "Проверка воспроизведения по памяти",
+ "(no question text)": "(текст вопроса отсутствует)",
+ "The learner skipped this check.": "Проверка пропущена.",
+ "The learner did not know.": "Ответ «Не знаю».",
+ "The learner answered correctly.": "Учащийся ответил верно.",
+ "The learner answered incorrectly.": "Учащийся ответил неверно.",
+ "The learner answered in their own words: {answer}": "Ответ своими словами: {answer}",
+ "The learner answered in his own words: {answer}": "Ответ своими словами: {answer}",
+ "Their thinking: {thinking}": "Ход рассуждений: {thinking}",
+ "his thinking: {thinking}": "Ход рассуждений: {thinking}",
+ "(no statement)": "(условие отсутствует)",
+ "The learner skipped this problem.": "Задача пропущена.",
+ "The learner answered: {answer}": "Ответ: {answer}",
+ "The learner chose: {answer}": "Выбранные варианты: {answer}",
+ "The learner skipped this question.": "Вопрос пропущен.",
+ "Q:": "Вопрос:",
+ "Tool": "Инструмент",
+ "{tool} result delivered": "Результат инструмента {tool} получен",
+ "No model configured — set up your models first": "Модель не выбрана — сначала настройте модели",
+ "No {v0} key is set — add one in Settings → Keys": "Ключ {v0} не задан — добавьте его в разделе Настройки → Провайдеры",
+ "Unknown chat": "Занятие не найдено",
+ "Invalid JSON body": "Некорректное тело JSON",
+ "Failed to fetch": "Не удалось выполнить запрос",
+ "Network error": "Ошибка сети",
+ "Network error. Try again.": "Ошибка сети. Повторите попытку.",
+ "Failed to attach to the running turn.": "Не удалось подключиться к текущему ответу.",
+ "The send failed ({v0}).": "Не удалось отправить ({v0}).",
+ "The steer failed ({v0}).": "Не удалось передать указание ({v0}).",
+ "Save failed ({v0})": "Не удалось сохранить ({v0})",
+ "Delete failed ({v0})": "Не удалось удалить ({v0})",
+ "settings rejected ({v0})": "Настройки отклонены ({v0})",
+ "A turn is already running for this session": "Ответ для этого занятия уже формируется",
+ "This file is already being imported. Try again when it finishes.": "Этот файл уже импортируется. Дождитесь завершения и повторите попытку.",
+ "Notes import did not complete": "Импорт заметок не завершён",
+ "Deepgram grant returned no token": "Deepgram не вернул токен",
+ "Deepgram unreachable": "Deepgram недоступен",
+ "Deepgram token grant failed ({v0}): {v1}": "Не удалось получить токен Deepgram ({v0}): {v1}",
+ "token route {v0}": "Ошибка получения токена ({v0})",
+ "No files": "Файлы не выбраны",
+ "Empty title": "Название не может быть пустым",
+ "Title must not be empty": "Название не может быть пустым",
+ "Copied": "Скопировано",
+ "Copy": "Копировать",
+ "Download": "Скачать",
+ "No results": "Ничего не найдено",
+ "Select": "Выбрать",
+ "Previous": "Назад",
+ "Next": "Вперёд",
+ "Untitled session": "Занятие без названия",
+ "{count} messages unextracted": {
+ "one": "{count} сообщение не обработано",
+ "few": "{count} сообщения не обработаны",
+ "many": "{count} сообщений не обработано",
+ "other": "{count} сообщения не обработано"
+ },
+ "· read {count} pages": {
+ "one": "· прочитана {count} страница",
+ "few": "· прочитаны {count} страницы",
+ "many": "· прочитано {count} страниц",
+ "other": "· прочитано {count} страницы"
+ },
+ "{count} sources": {
+ "one": "{count} источник",
+ "few": "{count} источника",
+ "many": "{count} источников",
+ "other": "{count} источника"
+ },
+ "{count} propositions": {
+ "one": "{count} утверждение",
+ "few": "{count} утверждения",
+ "many": "{count} утверждений",
+ "other": "{count} утверждения"
+ },
+ "{count} chains": {
+ "one": "{count} цепочка",
+ "few": "{count} цепочки",
+ "many": "{count} цепочек",
+ "other": "{count} цепочки"
+ },
+ "{count} teaching sessions": {
+ "one": "{count} учебное занятие",
+ "few": "{count} учебных занятия",
+ "many": "{count} учебных занятий",
+ "other": "{count} учебного занятия"
+ },
+ "{count} practice sessions": {
+ "one": "{count} практическое занятие",
+ "few": "{count} практических занятия",
+ "many": "{count} практических занятий",
+ "other": "{count} практического занятия"
+ },
+ "{count} imported notes": {
+ "one": "{count} импортированная заметка",
+ "few": "{count} импортированные заметки",
+ "many": "{count} импортированных заметок",
+ "other": "{count} импортированной заметки"
+ },
+ "Copy Code": "Копировать код",
+ "Download file": "Скачать файл",
+ "Download diagram": "Скачать диаграмму",
+ "Download diagram as SVG": "Скачать диаграмму в формате SVG",
+ "Download diagram as PNG": "Скачать диаграмму в формате PNG",
+ "Download diagram as MMD": "Скачать диаграмму в формате MMD",
+ "View fullscreen": "Открыть на весь экран",
+ "Exit fullscreen": "Выйти из полноэкранного режима",
+ "Copy table as Markdown": "Копировать таблицу в формате Markdown",
+ "Copy table as CSV": "Копировать таблицу в формате CSV",
+ "Copy table as TSV": "Копировать таблицу в формате TSV",
+ "Download table": "Скачать таблицу",
+ "Download table as CSV": "Скачать таблицу в формате CSV",
+ "Download table as Markdown": "Скачать таблицу в формате Markdown",
+ "Image not available": "Изображение недоступно",
+ "Download image": "Скачать изображение",
+ "Open external link?": "Открыть внешнюю ссылку?",
+ "You're about to visit an external website.": "Вы собираетесь перейти на внешний сайт.",
+ "Copy link": "Копировать ссылку",
+ "Open link": "Открыть ссылку",
+ "Other": "Другой вариант",
+ "Light": "Светлая",
+ "delivered": "доставлено",
+ "No such session": "Такого занятия нет",
+ "A turn is running in this session — try again when it finishes": "В этом занятии формируется ответ — дождитесь завершения и повторите попытку",
+ "Background jobs are still running in this session — try again when they finish": "В этом занятии ещё выполняются фоновые задачи — дождитесь их завершения",
+ "The session's extraction is still winding down — try again in a moment": "Извлечение знаний из занятия ещё завершается — попробуйте чуть позже",
+ "Not found": "Не найдено",
+ "title must be a non-empty string": "Название должно быть непустой строкой",
+ "unknown key \"{v0}\"": "Неизвестный ключ «{v0}»",
+ "body must be JSON": "Тело запроса должно быть в формате JSON",
+ "body must be {value: string}": "Тело запроса должно содержать строковое поле value",
+ "model must be {provider, id}": "Модель должна содержать поля provider и id",
+ "thinkingLevel must be a string": "Уровень рассуждения должен быть строкой",
+ "autoUpdate must be a boolean": "Параметр автообновления должен быть логическим значением",
+ "{v0} must be \"en\" or \"ru\"": "Параметр {v0} должен иметь значение «en» или «ru»",
+ "{v0} must be an object": "Параметр {v0} должен быть объектом",
+ "{v0}.model must be {provider, id}": "Поле {v0}.model должно содержать provider и id",
+ "{v0}.thinkingLevel must be a string": "Поле {v0}.thinkingLevel должно быть строкой",
+ "{v0}.{v1} must be a boolean": "Поле {v0}.{v1} должно быть логическим значением",
+ "models must be an object": "Поле models должно быть объектом",
+ "unknown role \"{v0}\" (supported: {v1})": "Неизвестная роль «{v0}» (поддерживаются: {v1})",
+ "unknown tool \"{v0}\" (supported: {v1})": "Неизвестный инструмент «{v0}» (поддерживаются: {v1})",
+ "search must be an object": "Поле search должно быть объектом",
+ "search.engine must be \"tavily\" or \"brave\"": "Поисковая система должна быть «tavily» или «brave»",
+ "graphTools must be an object": "Поле graphTools должно быть объектом",
+ "graphTools.enabled must be a boolean": "Поле graphTools.enabled должно быть логическим значением",
+ "{v0} must be a #hex color or null": "Поле {v0} должно содержать цвет в формате #hex или null",
+ "mode must be \"dark\" or \"light\"": "Режим должен быть «dark» или «light»",
+ "fontFamily must be a non-empty string or null": "Название шрифта должно быть непустой строкой или null",
+ "proseFontSize must be an integer between 12 and 24": "Размер текста должен быть целым числом от 12 до 24",
+ "sessionNameSerif must be a boolean": "Параметр шрифта названий занятий должен быть логическим значением",
+ "mdRendering must be an object": "Поле mdRendering должно быть объектом",
+ "mdRendering.headingWeight must be an integer between 100 and 900": "Насыщенность заголовков должна быть целым числом от 100 до 900",
+ "enabled must be a boolean": "Поле enabled должно быть логическим значением",
+ "rendering must be a boolean": "Поле rendering должно быть логическим значением",
+ "extractionChunkTokens must be a number": "Размер фрагмента для извлечения должен быть числом",
+ "extractionChunkTokens must be at least {v0} (smaller batches waste the per-run graph overhead)": "Размер фрагмента для извлечения должен быть не менее {v0} токенов (при меньшем размере накладные расходы слишком велики)",
+ "unknown slot \"{v0}\" (supported: {v1})": "Неизвестный уровень «{v0}» (поддерживаются: {v1})",
+ "slot \"{v0}\" must set a model (thinkingLevel alone cannot run — absent slot = unset)": "Для уровня «{v0}» необходимо выбрать модель: одного уровня рассуждения недостаточно",
+ "thresholdTokens must be a number": "Порог сжатия контекста должен быть числом",
+ "thresholdTokens must be at least {v0} (compaction keeps a ~20k-token recent tail — smaller budgets would churn it)": "Порог сжатия должен быть не менее {v0} токенов: сжатие сохраняет около 20 тысяч последних токенов",
+ "The tool call failed.": "Вызов инструмента завершился ошибкой.",
+ "Aborted by learner": "Прервано пользователем",
+ "starting research": "запуск исследования",
+ "starting demo": "запуск демонстрации",
+ "starting figure": "запуск создания иллюстрации",
+ "building visual": "создание иллюстрации",
+ "render_svg: inspecting final render": "render_svg: проверка итогового изображения",
+ "render_svg: inspecting render": "render_svg: проверка изображения",
+ "render_svg: render error": "render_svg: ошибка отрисовки",
+ "edit_svg: applied": "edit_svg: изменения применены",
+ "web_search: \"{v0}\"": "Поиск в интернете: «{v0}»",
+ "web_fetch: {v0}": "Загрузка страницы: {v0}",
+ "Stopping…": "Остановка…",
+ "The demo could not be built.": "Не удалось создать демонстрацию.",
+ "The figure could not be drawn.": "Не удалось создать иллюстрацию.",
+ "untagged": "без привязки",
+ "yes": "да",
+ "no": "нет",
+ "none": "нет",
+ " · select all that apply": " · выберите все подходящие варианты",
+ "recall": "воспроизведение по памяти",
+ "discrimination": "различение",
+ "composition": "составление решения",
+ "solved": "решено",
+ "solvedWithHints": "решено с подсказками",
+ "movedOn": "переход дальше",
+ "surrendered": "решение запрошено",
+ "dismissed": "пропущено",
+ "cue": "не хватает подсказки",
+ "execution": "ошибка выполнения",
+ "careless": "невнимательность",
+ "structural": "ошибка в понимании структуры",
+ "pending": "ожидание",
+ "consent": "подтверждение",
+ "proposition": "утверждение",
+ "propositions": "утверждения",
+ "node": "узел",
+ "nodes": "узлы",
+ "chain": "цепочка",
+ "chains": "цепочки",
+ " · {v0} sources": " · источников: {v0}",
+ "{v0}s elapsed": "прошло {v0} с",
+ "{count} nodes": {
+ "one": "{count} узел",
+ "few": "{count} узла",
+ "many": "{count} узлов",
+ "other": "{count} узла"
+ },
+ "The saved connection could not be read or written safely. Reopen the app or reconnect after resolving the credential-file problem.": "Не удалось безопасно прочитать или сохранить подключение. Устраните проблему с файлом учётных данных, затем откройте приложение заново или подключитесь повторно.",
+ "The connection could not be refreshed. Retry or reconnect in Providers.": "Не удалось обновить подключение. Повторите попытку или подключитесь заново в разделе «Провайдеры».",
+ "Sign-in cancelled.": "Вход отменён.",
+ "This subscription connection is unavailable. Reconnect or explicitly choose another connection.": "Это подключение по подписке недоступно. Подключитесь заново или выберите другое подключение.",
+ "Background use is off for this connection. Automatic follow-up remains deferred in its queue.": "Фоновое использование этого подключения отключено. Автоматическое продолжение остаётся в очереди.",
+ "The connection or model catalog changed. Select an available model and retry.": "Подключение или каталог моделей изменились. Выберите доступную модель и повторите попытку.",
+ "This sign-in action is no longer available.": "Это действие для входа больше недоступно.",
+ "ChatGPT sign-in cancelled.": "Вход в ChatGPT отменён.",
+ "ChatGPT sign-in timed out. Try again.": "Время ожидания входа в ChatGPT истекло. Повторите попытку.",
+ "ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests.": "Не удалось прочитать или сохранить данные для входа в ChatGPT. Проверьте доступ к локальным файлам и подключитесь заново, прежде чем отправлять запросы.",
+ "ChatGPT sign-in could not be verified. Select the intended saved account and try again.": "Не удалось подтвердить вход в ChatGPT. Выберите нужную сохранённую учётную запись и повторите попытку.",
+ "ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.": "Нет разрешения на использование тарифа ChatGPT. Подключитесь заново и предоставьте разрешение.",
+ "This ChatGPT connection is disconnected. Reconnect in Providers.": "Подключение ChatGPT отключено. Подключитесь заново в разделе «Провайдеры».",
+ "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers, or explicitly select an API-key model for that workload.": "Фоновое использование ChatGPT в приложении отключено. В разделе «Провайдеры» включите «Разрешить фоновое использование для всех учётных записей ChatGPT» или выберите для этой задачи модель с API-ключом.",
+ "The ChatGPT account model catalog is unavailable. Retry the connection; no API-key fallback was used.": "Каталог моделей учётной записи ChatGPT недоступен. Повторите подключение. Переключения на API-ключ не было.",
+ "The ChatGPT session expired. Reconnect in Providers.": "Сеанс ChatGPT истёк. Подключитесь заново в разделе «Провайдеры».",
+ "ChatGPT could not be reached. Check your connection and try again.": "Не удалось связаться с ChatGPT. Проверьте подключение и повторите попытку.",
+ "A ChatGPT sign-in is already pending.": "Вход в ChatGPT уже выполняется.",
+ "ChatGPT sign-in is unavailable in browser-only mode.": "Вход в ChatGPT недоступен в браузерной версии.",
+ "ChatGPT sign-in failed. Try again; no API-key fallback was used.": "Не удалось войти в ChatGPT. Повторите попытку. Переключения на API-ключ не было.",
+ "The selected model is unavailable.": "Выбранная модель недоступна.",
+ "ChatGPT plan usage limit reached. Manage usage at https://chatgpt.com/settings/usage. No API-key fallback was used.": "Достигнут лимит тарифа ChatGPT. Управление лимитами: https://chatgpt.com/settings/usage. Переключения на API-ключ не было.",
+ "ChatGPT plan permission is missing. Reconnect in Providers and authorize plan use. No API-key fallback was used.": "Нет разрешения на использование тарифа ChatGPT. Подключитесь заново в разделе «Провайдеры» и предоставьте разрешение. Переключения на API-ключ не было.",
+ "Your ChatGPT session expired. Reconnect in Providers. No API-key fallback was used.": "Сеанс ChatGPT истёк. Подключитесь заново в разделе «Провайдеры». Переключения на API-ключ не было.",
+ "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers to run this work. No API-key fallback was used.": "Фоновое использование ChatGPT отключено. Для выполнения этой работы включите «Разрешить фоновое использование для всех учётных записей ChatGPT» в разделе «Провайдеры». Переключения на API-ключ не было.",
+ "This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility.": "Эта учётная запись или рабочее пространство ChatGPT не допускает такой запрос. Проверьте настройки ChatGPT: повторное подключение может не изменить доступ.",
+ "ChatGPT request cancelled or connection closed. No API-key fallback was used.": "Запрос ChatGPT отменён или подключение закрыто. Переключения на API-ключ не было.",
+ "The selected model is unavailable for this ChatGPT account. Refresh Providers and explicitly choose an available model. No API-key fallback was used.": "Выбранная модель недоступна для этой учётной записи ChatGPT. Обновите раздел «Провайдеры» и выберите доступную модель. Переключения на API-ключ не было.",
+ "ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used.": "ChatGPT вернул неполный ответ. Повторите попытку, когда будете готовы. Переключения на API-ключ не было.",
+ "ChatGPT request failed. Check your connection and ChatGPT usage, then retry. No API-key fallback was used.": "Запрос ChatGPT завершился ошибкой. Проверьте подключение и лимиты ChatGPT, затем повторите попытку. Переключения на API-ключ не было.",
+ "{v0} background use is off in this app. Enable it in Providers to run this work. No API-key fallback was used.": "Фоновое использование {v0} отключено. Для выполнения этой работы включите его в разделе «Провайдеры». Переключения на API-ключ не было.",
+ "{v0} usage limit reached. Check your provider account and try again later. No API-key fallback was used.": "Достигнут лимит использования {v0}. Проверьте учётную запись провайдера и повторите попытку позже. Переключения на API-ключ не было.",
+ "{v0} authorization could not be refreshed. Retry or reconnect in Providers. No API-key fallback was used.": "Не удалось обновить авторизацию {v0}. Повторите попытку или подключитесь заново в разделе «Провайдеры». Переключения на API-ключ не было.",
+ "{v0} request cancelled or connection closed. No API-key fallback was used.": "Запрос {v0} отменён или подключение закрыто. Переключения на API-ключ не было.",
+ "The selected {v0} model or connection is unavailable. Refresh Providers and explicitly choose an available model. No API-key fallback was used.": "Выбранная модель или подключение {v0} недоступны. Обновите раздел «Провайдеры» и выберите доступную модель. Переключения на API-ключ не было.",
+ "{v0} request failed. Check the connection, account access and provider limits, then retry. No API-key fallback was used.": "Запрос {v0} завершился ошибкой. Проверьте подключение, доступ к учётной записи и лимиты провайдера, затем повторите попытку. Переключения на API-ключ не было.",
+ "GitHub Enterprise URL/domain (blank for github.com)": "URL или домен GitHub Enterprise (оставьте пустым для github.com)",
+ "Enabling models...": "Подключение моделей…",
+ "Waiting for authorization...": "Ожидание авторизации…",
+ "no search engine configured — set a Tavily or Brave search API key": "Поисковая система не настроена — добавьте API-ключ Tavily или Brave Search",
+ "search engine is set to \"{v0}\" but its API key is missing — set the {v1} key": "Выбрана поисковая система «{v0}», но её API-ключ отсутствует — добавьте ключ {v1}",
+ "search engine: {v0} (chosen)": "Поисковая система: {v0} (выбрана вручную)",
+ "search engine: {v0} (auto)": "Поисковая система: {v0} (автоматически)",
+ "update": "обновление"
+}
diff --git a/src/core/ports.ts b/src/core/ports.ts
index ea67d93..7fbbccc 100644
--- a/src/core/ports.ts
+++ b/src/core/ports.ts
@@ -31,6 +31,8 @@ import type { UserSettings } from "./settings.ts";
// ─── ChatStore — the session record's home (data/chats) ─────────────────────
export interface ChatStore {
+ /** Persist automatic language context without altering activity ordering. */
+ saveLanguage?(id: string, language: "en" | "ru"): void;
/** Create a session record (persisted immediately). */
/** Create a session record; kind "practice" marks a practice session
* (absent = teach, the default). */
diff --git a/src/core/session-markdown.ts b/src/core/session-markdown.ts
index 64a37b6..a9a0255 100644
--- a/src/core/session-markdown.ts
+++ b/src/core/session-markdown.ts
@@ -1,3 +1,4 @@
+import { translator, type Language } from "./i18n.ts";
/**
* Session → markdown — the ONE formatting engine (owner ruling 2026-09-27).
*
@@ -76,7 +77,8 @@ function outcomeLabel(outcome: string): string {
}
/** One tool result's learner-visible outcome, rendered as body lines. */
-function toolResultBody(result: ToolResultMessage, ctx?: MarkdownContext): { heading: string; body: string[] } {
+function toolResultBody(result: ToolResultMessage, ctx?: MarkdownContext, language: Language = "en"): { heading: string; body: string[] } {
+ const t = translator(language);
const out = result.details as
| Partial<{
questionText: string;
@@ -98,28 +100,28 @@ function toolResultBody(result: ToolResultMessage, ctx?: MarkdownContext): { hea
) {
const heading =
result.toolName === "quiz"
- ? "Quiz"
+ ? t("Quiz")
: result.toolName === "probe"
- ? "Probe"
+ ? t("Probe")
: result.toolName === "recall"
- ? "Recall"
- : "Recall check"; // explain — the teach free-recall name
- const question = typeof out?.questionText === "string" && out.questionText ? out.questionText : "(no question text)";
- const body = [`**Q:** ${question}`];
- if (out?.dismissed === true) body.push("The learner skipped this check.");
- else if (out?.dontKnow === true) body.push("The learner did not know.");
- else if (out?.correct === true) body.push("The learner answered correctly.");
- else if (out?.correct === false) body.push("The learner answered incorrectly.");
- else if (typeof out?.text === "string" && out.text.trim()) body.push(`The learner answered in his own words: ${out.text.trim()}`);
- if (typeof out?.note === "string" && out.note) body.push(`*(his thinking: ${out.note})*`);
+ ? t("Recall")
+ : t("Recall check"); // explain — the teach free-recall name
+ const question = typeof out?.questionText === "string" && out.questionText ? out.questionText : t("(no question text)");
+ const body = [`**${t("Q:")}** ${question}`];
+ if (out?.dismissed === true) body.push(t("The learner skipped this check."));
+ else if (out?.dontKnow === true) body.push(t("The learner did not know."));
+ else if (out?.correct === true) body.push(t("The learner answered correctly."));
+ else if (out?.correct === false) body.push(t("The learner answered incorrectly."));
+ else if (typeof out?.text === "string" && out.text.trim()) body.push(t("The learner answered in his own words: {answer}", { answer: out.text.trim() }));
+ if (typeof out?.note === "string" && out.note) body.push(`*(${t("his thinking: {thinking}", { thinking: out.note })})*`);
return { heading, body };
}
if (ctx !== undefined && result.toolName === PRACTICE_PROBLEM_TOOL) {
const statement = ctx.calls.get(result.toolCallId)?.args?.statement;
return {
- heading: "Problem",
- body: [typeof statement === "string" && statement.trim() ? statement.trim() : "(no statement)"],
+ heading: t("Problem"),
+ body: [typeof statement === "string" && statement.trim() ? statement.trim() : t("(no statement)")],
};
}
@@ -127,33 +129,33 @@ function toolResultBody(result: ToolResultMessage, ctx?: MarkdownContext): { hea
const args = ctx.calls.get(result.toolCallId)?.args;
const outcome = typeof args?.outcome === "string" && args.outcome.trim() ? args.outcome.trim() : "";
const verdict = typeof args?.verdict === "string" && args.verdict.trim() ? args.verdict.trim() : undefined;
- if (outcome === "dismissed") return { heading: "Concluded", body: ["The learner skipped this problem."] };
- const label = outcome !== "" ? `**${outcomeLabel(outcome)}.**` : "**Concluded.**";
+ if (outcome === "dismissed") return { heading: t("Concluded"), body: [t("The learner skipped this problem.")] };
+ const label = outcome !== "" ? `**${t(outcomeLabel(outcome))}.**` : `**${t("Concluded")}.**`;
const body = [verdict !== undefined ? `${label} ${verdict}` : label];
// The reveal (owner round, 2026-10-05): the solution committed at
// pose time — the newest cleanly-posed problem before this conclude.
const input = problemInputForConclude(ctx.messages, result.toolCallId);
const solution = typeof input?.modelSolution === "string" && input.modelSolution.trim() ? input.modelSolution.trim() : undefined;
- if (solution !== undefined) body.push(`> **Model solution**\n>\n> ${solution.replace(/\n/g, "\n> ")}`);
- return { heading: "Concluded", body };
+ if (solution !== undefined) body.push(`> **${t("Model solution")}**\n>\n> ${solution.replace(/\n/g, "\n> ")}`);
+ return { heading: t("Concluded"), body };
}
if (result.toolName === "ask_user_question") {
- const question = typeof out?.questionText === "string" && out.questionText ? out.questionText : "(no question text)";
- const body = [`**Q:** ${question}`];
+ const question = typeof out?.questionText === "string" && out.questionText ? out.questionText : t("(no question text)");
+ const body = [`**${t("Q:")}** ${question}`];
const chosen = [
...(Array.isArray(out?.answers) ? out!.answers! : []),
...(typeof out?.other === "string" && out.other.trim() ? [out.other.trim()] : []),
];
- if (typeof out?.text === "string" && out.text.trim()) body.push(`The learner answered: ${out.text.trim()}`);
- else if (chosen.length > 0) body.push(`The learner chose: ${chosen.join(", ")}`);
- else if (out?.dismissed === true) body.push("The learner skipped this question.");
- if (typeof out?.note === "string" && out.note) body.push(`*(his thinking: ${out.note})*`);
- return { heading: "Question", body };
+ if (typeof out?.text === "string" && out.text.trim()) body.push(t("The learner answered: {answer}", { answer: out.text.trim() }));
+ else if (chosen.length > 0) body.push(t("The learner chose: {answer}", { answer: chosen.join(", ") }));
+ else if (out?.dismissed === true) body.push(t("The learner skipped this question."));
+ if (typeof out?.note === "string" && out.note) body.push(`*(${t("his thinking: {thinking}", { thinking: out.note })})*`);
+ return { heading: t("Question"), body };
}
// Parity with the line builder: other clean tool results are one line.
- return { heading: "Tool", body: [`*${result.toolName} result delivered*`] };
+ return { heading: t("Tool"), body: [`*${t("{tool} result delivered", { tool: result.toolName })}*`] };
}
/**
@@ -166,23 +168,25 @@ export function messageMarkdown(
mid: string,
anchors = true,
ctx?: MarkdownContext,
+ language: Language = "en",
): string | null {
+ const t = translator(language);
const role = (message as { role?: string }).role;
const anchor = anchors ? ` · ${mid}` : "";
const parts: string[] = [];
if (role === "user") {
for (const text of textOf((message as { content: unknown }).content)) {
- parts.push(`### Learner${anchor}\n\n${text.trim()}`);
+ parts.push(`### ${t("Learner")}${anchor}\n\n${text.trim()}`);
}
} else if (role === "assistant") {
for (const text of textOf((message as { content: unknown }).content)) {
- parts.push(`### Teacher${anchor}\n\n${text.trim()}`);
+ parts.push(`### ${t("Teacher")}${anchor}\n\n${text.trim()}`);
}
} else if (role === "toolResult") {
const result = message as ToolResultMessage;
if (result.isError) return null; // retry context, not learning evidence
- const { heading, body } = toolResultBody(result, ctx);
+ const { heading, body } = toolResultBody(result, ctx, language);
parts.push(`### ${heading}${anchor}\n\n${body.join("\n\n")}`);
}
@@ -198,13 +202,13 @@ export function messageMarkdown(
*/
export function sessionMarkdown(
messages: ReadonlyArray,
- opts: { title?: string; anchors?: boolean } = {},
+ opts: { title?: string; anchors?: boolean; language?: Language } = {},
): string {
const blocks: string[] = [];
if (opts.title !== undefined && opts.title.length > 0) blocks.push(`# ${opts.title}`);
const ctx = markdownContext(messages);
for (const message of messages) {
- const block = messageMarkdown(message, (message as { id?: string }).id ?? "", opts.anchors ?? true, ctx);
+ const block = messageMarkdown(message, (message as { id?: string }).id ?? "", opts.anchors ?? true, ctx, opts.language);
if (block !== null) blocks.push(block);
}
return blocks.join("\n\n");
diff --git a/src/core/settings-resolution.ts b/src/core/settings-resolution.ts
index ce4b6aa..6184801 100644
--- a/src/core/settings-resolution.ts
+++ b/src/core/settings-resolution.ts
@@ -68,7 +68,7 @@ export type EffectiveAppearance = FactorySettings["appearance"];
/** The concrete view: every knob resolved, no absent fields. */
export interface EffectiveSettings {
- general: Required;
+ general: GeneralSettings & { autoUpdate: boolean };
agent: {
models: Record;
/** Delta map as stored — consumers resolve absent key → enabled. */
@@ -175,6 +175,8 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
return {
general: {
autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate,
+ ...(record?.general?.language ? { language: record.general.language } : {}),
+ ...(record?.general?.sttLanguage ? { sttLanguage: record.general.sttLanguage } : {}),
},
agent: {
models,
diff --git a/src/core/settings.ts b/src/core/settings.ts
index 4daece8..3265b20 100644
--- a/src/core/settings.ts
+++ b/src/core/settings.ts
@@ -233,6 +233,10 @@ export const ROLE_SLOT: Readonly> = {
export interface GeneralSettings {
/** Auto-update the app (default ON). Mechanism ships in a later wave. */
autoUpdate?: boolean;
+ /** Absent = browser/system language (English for unsupported languages). */
+ language?: "en" | "ru";
+ /** Independent dictation language; absent = browser/system language. */
+ sttLanguage?: "en" | "ru";
}
/** Agent settings — everything about the non-conversation machinery. */
@@ -377,7 +381,7 @@ export interface UserSettings {
* prompts default off — hence their absence here. */
export interface FactorySettings {
version: number;
- general: Required;
+ general: GeneralSettings & { autoUpdate: boolean };
appearance: {
mode: "dark" | "light";
background: string | null;
@@ -441,6 +445,9 @@ function sanitizeGeneral(raw: Record): GeneralSettings {
const out: GeneralSettings = {};
const autoUpdate = boolOrDrop(raw.autoUpdate);
if (autoUpdate !== undefined) out.autoUpdate = autoUpdate;
+ for (const key of ["language", "sttLanguage"] as const) {
+ if (raw[key] === "en" || raw[key] === "ru") out[key] = raw[key];
+ }
return out;
}
diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts
index af92de7..7167da6 100644
--- a/src/server/chat-handler.ts
+++ b/src/server/chat-handler.ts
@@ -1,3 +1,5 @@
+import { learningLanguageInstruction } from "../core/i18n.ts";
+import { applicationLanguage, chatLanguage } from "./language.ts";
import type { Usage } from "@earendil-works/pi-ai";
import {
createPracticeAgent,
@@ -80,6 +82,7 @@ import type {CallUsage, SteerMessage} from "../core/contracts.ts";
*/
const jobRunner = createJobRunner({
+ languageForChat: chatLanguage,
chatStore: stores.chatStore,
jobStore: stores.jobStore,
deliver: deliverJobResult,
@@ -105,6 +108,7 @@ const jobRunner = createJobRunner({
* quiz-triggered session runs (turn-service) and notes imports
* (/api/graph/import) hit the same single-flight registry. */
export const extraction = createExtractionPipeline({
+ languageForSource: (id) => chatLanguage(id),
...stores,
// BYOK (owner, 2026-09-22): the extraction stages resolve the chain —
// role override → slot → NOTHING — read FRESH per stage call; an empty
@@ -164,7 +168,7 @@ export async function handleChatRequest(
}
throw err;
}
- return handleChatInput(chatId, input, buildAgent);
+ return handleChatInput(chatId, input, buildAgent, req.headers.get("accept-language"));
}
/**
@@ -176,6 +180,7 @@ export async function handleChatInput(
chatId: string,
input: TurnInput,
buildAgent?: (opts: AgentBuildOptions & { teaching: boolean }) => TeachingAgent,
+ browserLanguages?: string | null,
): Promise {
let record = stores.chatStore.getChat(chatId);
if (!record) {
@@ -191,6 +196,7 @@ export async function handleChatInput(
// write-ahead persist (nothing durable changes), and every downstream
// resolution (jobs/extraction gate per-need on their own tiers).
const effective = effectiveSettings();
+ const language = applicationLanguage(browserLanguages ?? record.language);
const conversation = resolveConversationSpec(record.settings, effective.models.strong);
if (!conversation) {
const envelope = modelsNotConfigured(["strong"]);
@@ -250,7 +256,7 @@ export async function handleChatInput(
effective.compaction?.thresholdTokens,
catalogContextLimits().get(modelIdForCompaction),
),
- systemPromptTokens: Math.ceil(composeInstructions(record.kind === "practice" ? "practice" : "teacher").length / 4),
+ systemPromptTokens: Math.ceil((composeInstructions(record.kind === "practice" ? "practice" : "teacher").length + learningLanguageInstruction(language).length) / 4),
},
chatId,
);
@@ -312,6 +318,7 @@ export async function handleChatInput(
const stagedAutomatic = isSubscriptionProvider(conversation.provider) && input.type === "steers";
let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
try {
+ stores.chatStore.saveLanguage?.(chatId, language);
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
// Dock fix (owner ruling 2026-09-26): persisted markers are visible
// in the record — flip their jobs now (the wake's steers ride this
@@ -374,6 +381,7 @@ export async function handleChatInput(
restoreAutomatic: (automaticIds, retainedIds) => restoreUncommittedAutomatic(chatId, constructed, automaticIds, retainedIds),
});
const agentOpts: AgentBuildOptions = {
+ language,
engine,
callPurpose,
requestCycle,
@@ -579,7 +587,7 @@ export async function handleChatInput(
fresh.compaction?.thresholdTokens,
catalogContextLimits().get(kickModelId),
),
- systemPromptTokens: Math.ceil(composeInstructions(freshRecord.kind === "practice" ? "practice" : "teacher").length / 4),
+ systemPromptTokens: Math.ceil((composeInstructions(freshRecord.kind === "practice" ? "practice" : "teacher").length + learningLanguageInstruction(chatLanguage(chatId)).length) / 4),
};
}, chatId);
},
diff --git a/src/server/index.ts b/src/server/index.ts
index 49968ca..3510600 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -44,3 +44,5 @@ export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from ".
export * from "./observability.ts";
export * from "./model-catalog.ts";
export { sessionCost } from "./pricing.ts";
+
+export { applicationLanguage, chatLanguage } from "./language.ts";
diff --git a/src/server/language.ts b/src/server/language.ts
new file mode 100644
index 0000000..e4c2dad
--- /dev/null
+++ b/src/server/language.ts
@@ -0,0 +1,9 @@
+import { resolveLanguage, type Language } from "../core/i18n.ts";
+import { stores } from "./stores.ts";
+/** Explicit app preference wins; automatic follow-ups retain the foreground browser's language. */
+export function applicationLanguage(system?: string | null): Language {
+ return resolveLanguage(stores.settingsStore.readSettings()?.general?.language, system ?? process.env.LH_SYSTEM_LANGUAGE ?? Intl.DateTimeFormat().resolvedOptions().locale);
+}
+export function chatLanguage(chatId: string): Language {
+ return applicationLanguage(stores.chatStore.getChat(chatId)?.language);
+}
diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts
index 2cf1050..8a1347e 100644
--- a/src/server/settings-body.ts
+++ b/src/server/settings-body.ts
@@ -86,6 +86,12 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult {
it("preload exposes OS identity without exposing Node/Electron to the renderer", () => {
let bridge: Record = {};
+ const sent: unknown[][] = [];
const context = {
process: { platform: "win32" },
require: () => ({
@@ -72,13 +73,15 @@ describe("desktop window chrome", () => {
bridge = api;
},
},
- ipcRenderer: {},
+ ipcRenderer: { send: (...args: unknown[]) => sent.push(args) },
}),
};
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
assert.equal(bridge.platform, "win32");
+ (bridge.setLanguage as (language: string) => void)("ru");
+ assert.deepEqual(sent, [["lh:language", "ru"]]);
assert.deepEqual(Object.keys(bridge).sort(), [
- "chatgpt", "expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform", "subscriptions",
+ "chatgpt", "expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform", "setLanguage", "subscriptions",
]);
assert.deepEqual(Object.keys(bridge.subscriptions as object).sort(), ["cancel", "consent", "disconnect", "login", "onChange", "open", "respond", "retry", "status"]);
});
diff --git a/tests/i18n.test.tsx b/tests/i18n.test.tsx
new file mode 100644
index 0000000..d366075
--- /dev/null
+++ b/tests/i18n.test.tsx
@@ -0,0 +1,132 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { createFauxCore } from "@earendil-works/pi-ai";
+import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
+import { createPracticeAgent } from "../src/agent/teacher-agent.ts";
+import { sanitizeUserSettings } from "../src/core/settings.ts";
+import { parseGeneralSettingsBody } from "../src/server/settings-body.ts";
+import { countLabel, dictationUrl, resolveLanguage, systemLanguage, translate } from "../src/core/i18n.ts";
+import { sessionMarkdown } from "../src/core/session-markdown.ts";
+import type { LhMessage } from "../src/core/native-messages.ts";
+import { I18nProvider, LanguageSelect } from "../components/i18n.tsx";
+import { KnowledgeSummary } from "../components/minimal-home.tsx";
+import { AbortTurnBody } from "../components/abort-turn-dialog.tsx";
+import en from "../src/core/locales/en.json";
+import ru from "../src/core/locales/ru.json";
+import { createRequire } from "node:module";
+const require = createRequire(import.meta.url);
+const native = require("../electron/language.cjs");
+
+describe("language preferences", () => {
+ it("uses the preferred browser/system language and English for unsupported languages", () => {
+ assert.equal(systemLanguage("ru-RU,ru;q=0.9,en;q=0.8"), "ru");
+ assert.equal(systemLanguage("en;q=0.4,ru;q=0.9"), "ru");
+ assert.equal(systemLanguage("ru;q=0,en;q=1"), "en");
+ assert.equal(systemLanguage(["de-DE", "ru"]), "en");
+ assert.equal(systemLanguage(undefined), "en");
+ assert.equal(resolveLanguage("en", "ru-RU"), "en");
+ assert.equal(resolveLanguage("ru", "en-US"), "ru");
+ });
+ it("validates both independent preferences without changing existing general options", () => {
+ const general = { autoUpdate: false, language: "ru", sttLanguage: "en" };
+ assert.deepEqual(parseGeneralSettingsBody(general), { ok: true, settings: general });
+ assert.deepEqual(sanitizeUserSettings({ version: 1, general })?.general, general);
+ assert.deepEqual(parseGeneralSettingsBody({}), { ok: true, settings: {} });
+ assert.equal(parseGeneralSettingsBody({ language: "auto" }).ok, false);
+ assert.equal(parseGeneralSettingsBody({ sttLanguage: "fr" }).ok, false);
+ assert.deepEqual(sanitizeUserSettings({ version: 1, general: { language: null, sttLanguage: "fr", autoUpdate: false } })?.general, { autoUpdate: false });
+ });
+ it("pins an explicit supported language in the STT provider request", () => {
+ for (const language of ["en", "ru"] as const) {
+ const url = new URL(dictationUrl(language));
+ assert.equal(url.protocol, "wss:");
+ assert.equal(url.hostname, "api.deepgram.com");
+ assert.equal(url.searchParams.get("language"), language);
+ assert.equal(url.searchParams.get("model"), "nova-3");
+ assert.equal(url.searchParams.get("encoding"), "linear16");
+ assert.equal(url.searchParams.get("sample_rate"), "16000");
+ }
+ });
+});
+
+describe("translation quality and rendering", () => {
+ it("keeps catalogs complete and parameter names valid", () => {
+ assert.deepEqual(Object.keys(en).sort(), Object.keys(ru).sort());
+ for (const [key, value] of Object.entries(ru)) {
+ const variants = typeof value === "string" ? [value] : Object.values(value);
+ assert(variants.length > 0, key);
+ for (const text of variants) {
+ assert(text.trim(), key);
+ for (const placeholder of text.matchAll(/\{(\w+)\}/g)) assert(key.includes(placeholder[0]), `${key}: unexpected ${placeholder[0]}`);
+ }
+ }
+ });
+ it("uses Russian plural forms including 11 and 21 while retaining English wording", () => {
+ for (const [count, expected] of [[1, "1 утверждение"], [2, "2 утверждения"], [5, "5 утверждений"], [11, "11 утверждений"], [21, "21 утверждение"], [22, "22 утверждения"], [25, "25 утверждений"]] as const) {
+ assert.equal(countLabel("ru", count, "proposition", "propositions"), expected);
+ }
+ assert.equal(countLabel("en", 1, "proposition", "propositions"), "1 proposition");
+ assert.equal(countLabel("en", 5, "proposition", "propositions"), "5 propositions");
+ assert.equal(translate("en", "· read {count} pages", { count: 1 }), "· read 1 page");
+ });
+ it("localizes canonical protocol errors without changing provider diagnostics or parameters", () => {
+ assert.equal(translate("ru", "No OpenAI key is set — add one in Settings → Keys"), "Ключ OpenAI не задан — добавьте его в разделе Настройки → Провайдеры");
+ assert.equal(translate("ru", "Save failed (503)"), "Не удалось сохранить (503)");
+ assert.equal(translate("ru", JSON.stringify({ error: "Unknown chat" })), "Занятие не найдено");
+ assert.equal(translate("ru", "Provider diagnostic 9f1c"), "Provider diagnostic 9f1c");
+ assert.equal(translate("ru", "Select node {v0}", { v0: "English " }), "Выбрать узел «English »");
+ });
+ it("renders Russian and English concurrently without a shared mutable locale", () => {
+ const summary = createElement(KnowledgeSummary, { propositions: 22, chains: 5, teachSessions: 1, practiceSessions: 2, importedNotes: 11 });
+ const russian = renderToStaticMarkup({summary});
+ const english = renderToStaticMarkup({summary});
+ assert(russian.includes("22 утверждения"));
+ assert(russian.includes("11 импортированных заметок"));
+ assert(english.includes("22 propositions"));
+ assert(!english.includes("утверждения"));
+ });
+ it("offers independent saved language choices with localized accessible labels", () => {
+ const html = renderToStaticMarkup();
+ assert(html.includes('aria-label="Язык распознавания речи"'));
+ assert(html.includes('value="en" selected=""'));
+ assert(html.includes("Язык системы"));
+ assert(html.includes("English"));
+ assert(html.includes("Русский"));
+ });
+ it("translates dialog text and accessibility labels", () => {
+ const html = renderToStaticMarkup( {}} onConfirm={() => {}} />);
+ assert(html.includes('aria-label="Прервать ответ?"'));
+ assert(html.includes("Частичный ответ останется в истории занятия."));
+ assert(!html.includes("Abort this turn?"));
+ });
+ it("exports Russian headings and feedback while preserving authored content", () => {
+ const messages: LhMessage[] = [
+ { role: "user", id: "u", content: "Original English text", timestamp: 1 },
+ { role: "toolResult", id: "t", toolName: "quiz", toolCallId: "q", content: [], details: { questionText: "Original question", correct: true }, timestamp: 2, isError: false },
+ ] as LhMessage[];
+ const markdown = sessionMarkdown(messages, { anchors: false, language: "ru" });
+ assert(markdown.includes("### Учащийся"));
+ assert(markdown.includes("Original English text"));
+ assert(markdown.includes("Original question"));
+ assert(markdown.includes("Учащийся ответил верно."));
+ });
+ it("supplies language instructions to rebuilt teaching and practice agents", () => {
+ const faux = createFauxCore({});
+ const engine = { model: faux.getModel(), streamFn: faux.streamSimple };
+ const teacher = createTeacherAgent(undefined, { engine, tools: {}, language: "ru" });
+ assert(teacher.state.systemPrompt.includes("LANGUAGE: Use Russian"));
+ assert(teacher.state.systemPrompt.includes("interactive demos"));
+ // An explicit tool registry avoids IO: this checks the actual agent composition seam.
+ const practice = createPracticeAgent({} as never, { engine, tools: {}, language: "en" });
+ assert(practice.state.systemPrompt.includes("LANGUAGE: Use English"));
+ });
+ it("localizes native desktop controls and uses the same automatic fallback", () => {
+ assert.equal(native.resolveLanguage(undefined, ["ru-RU"]), "ru");
+ assert.equal(native.resolveLanguage(undefined, ["fr-FR"]), "en");
+ assert.equal(native.resolveLanguage("en", ["ru-RU"]), "en");
+ assert.equal(native.translate("ru", "Check for Updates…"), "Проверить обновления…");
+ assert.equal(native.translate("ru", "LearnHarness {version} is the latest version.", { version: "0.3.7" }), "LearnHarness 0.3.7 — последняя версия.");
+ });
+});
diff --git a/tests/server-title-guard.test.ts b/tests/server-title-guard.test.ts
index 503b68c..32f27c2 100644
--- a/tests/server-title-guard.test.ts
+++ b/tests/server-title-guard.test.ts
@@ -95,7 +95,7 @@ async function serverLaunch(platform: string) {
const child = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), stderr: new EventEmitter() });
const context = {
require: (id: string) => id === "electron" ? {
- app: { setName() {}, requestSingleInstanceLock: () => false, quit() {}, getPath: () => "/fixture/user-data" },
+ app: { setName() {}, requestSingleInstanceLock: () => false, quit() {}, getPreferredSystemLanguages: () => ["ru-RU"], getPath: () => "/fixture/user-data" },
} : id === "node:child_process" ? {
spawn(binary: string, args: string[], options: typeof launch.options) {
launch = JSON.parse(JSON.stringify({ binary, args, options }));
@@ -128,6 +128,7 @@ describe("backend-only preload wiring and packaging", () => {
assert.equal(launch.options.env.PORT, "54321");
assert.equal(launch.options.env.HOSTNAME, "127.0.0.1");
assert.equal(launch.options.env.FIXTURE, "kept");
+ assert.equal(launch.options.env.LH_SYSTEM_LANGUAGE, "ru-RU");
assert.deepEqual(launch.options.stdio, ["ignore", "pipe", "pipe", "ipc"]);
});
}