From a50ca4b867224d17b3c6bd1b3bb18e57e0a991cc Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 25 Oct 2026 17:18:44 +0300 Subject: [PATCH] feat: add Russian localization and independent STT languages --- app/api/chats/[id]/markdown/route.ts | 8 +- app/api/graph/import/route.ts | 4 +- app/globals.css | 3 + app/layout.tsx | 31 +- components/abort-turn-dialog.tsx | 16 +- components/activity-dock.tsx | 62 +- components/ai-elements/message.tsx | 16 +- components/ai-elements/reasoning.tsx | 13 +- components/answered-card.tsx | 36 +- components/artifacts/demo-card.tsx | 31 +- components/artifacts/dispatch-card.tsx | 28 +- components/artifacts/download.ts | 2 +- components/artifacts/job-card.tsx | 26 +- components/artifacts/live-tool-card.tsx | 7 +- components/artifacts/probe-done-card.tsx | 13 +- components/artifacts/steer-card.tsx | 29 +- components/artifacts/tool-call-card.tsx | 16 +- components/artifacts/visual-card.tsx | 25 +- components/braille-spinner.tsx | 6 +- components/chain-table.tsx | 52 +- components/chat.tsx | 48 +- components/compaction-divider.tsx | 11 +- components/composer-actions.tsx | 8 +- components/composer.tsx | 12 +- components/delete-node-dialog.tsx | 24 +- components/delete-session-dialog.tsx | 26 +- components/dictation.tsx | 39 +- components/extraction-coverage.tsx | 45 +- components/graph-context-card.tsx | 8 +- components/graph-view.tsx | 152 +-- components/home.tsx | 12 +- components/i18n.tsx | 96 ++ components/keyboard-shortcuts.tsx | 17 +- components/md-blocks.tsx | 18 +- components/mermaid-error.tsx | 12 +- components/minimal-home.tsx | 27 +- components/model-picker.tsx | 23 +- components/new-session.tsx | 15 +- components/notes-import.tsx | 13 +- components/phase-divider.tsx | 11 +- components/plan-block.tsx | 38 +- components/practice-cards.tsx | 47 +- components/practice-dock.tsx | 17 +- components/question-dock.tsx | 58 +- components/reading-thinking.tsx | 8 +- components/session-find.tsx | 15 +- components/session-overlay.tsx | 30 +- components/settings/agent-section.tsx | 84 +- components/settings/appearance-section.tsx | 68 +- components/settings/chatgpt-connection.tsx | 76 +- components/settings/connection-controls.tsx | 5 +- components/settings/controls.tsx | 20 +- components/settings/keyboard-section.tsx | 25 +- components/settings/keys-section.tsx | 55 +- components/settings/model-menu.tsx | 53 +- components/settings/settings-dialog.tsx | 50 +- .../settings/subscription-connections.tsx | 56 +- components/setup-cta.tsx | 6 +- components/setup-dialog.tsx | 56 +- components/sidebar.tsx | 87 +- components/stats-bar.tsx | 33 +- components/thinking-block.tsx | 5 +- components/ui/hint.tsx | 5 +- components/update-banner.tsx | 10 +- components/usage-format.ts | 7 +- docs/localization.md | 54 ++ electron/language.cjs | 13 + electron/main.cjs | 50 +- electron/preload.cjs | 1 + src/agent/subagents/demo-builder.ts | 4 +- src/agent/subagents/extraction.ts | 15 +- src/agent/subagents/pdf-reader.ts | 4 +- src/agent/subagents/researcher.ts | 4 +- src/agent/subagents/runner.ts | 6 + src/agent/subagents/visual-maker.ts | 4 +- src/agent/teacher-agent.ts | 5 +- src/core/contracts/chat.ts | 2 + src/core/i18n.ts | 69 ++ src/core/index.ts | 2 + src/core/locales/en.json | 839 +++++++++++++++++ src/core/locales/ru.json | 875 ++++++++++++++++++ src/core/ports.ts | 2 + src/core/session-markdown.ts | 68 +- src/core/settings-resolution.ts | 4 +- src/core/settings.ts | 9 +- src/server/chat-handler.ts | 14 +- src/server/index.ts | 2 + src/server/language.ts | 9 + src/server/settings-body.ts | 6 + src/store/chats.ts | 7 + tests/desktop-platform.test.ts | 7 +- tests/i18n.test.tsx | 132 +++ tests/server-title-guard.test.ts | 3 +- 93 files changed, 3261 insertions(+), 904 deletions(-) create mode 100644 components/i18n.tsx create mode 100644 docs/localization.md create mode 100644 electron/language.cjs create mode 100644 src/core/i18n.ts create mode 100644 src/core/locales/en.json create mode 100644 src/core/locales/ru.json create mode 100644 src/server/language.ts create mode 100644 tests/i18n.test.tsx diff --git a/app/api/chats/[id]/markdown/route.ts b/app/api/chats/[id]/markdown/route.ts index 98aadc1..0ba70e1 100644 --- a/app/api/chats/[id]/markdown/route.ts +++ b/app/api/chats/[id]/markdown/route.ts @@ -11,14 +11,14 @@ * markers are invisible by the engine's law). */ import { sessionMarkdown } from "@/src/core"; -import { getChat } from "@/src/server"; +import { getChat, applicationLanguage } from "@/src/server"; /** Filename-safe title (own module scope on purpose: tiny + pure). */ function slugify(title: string): string { const slug = title .toLowerCase() .normalize("NFKD") - .replace(/[^a-z0-9]+/g, "-") + .replace(/[^\p{L}\p{N}]+/gu, "-") .replace(/^-+|-+$/g, ""); return slug.length > 0 ? slug : "session"; } @@ -27,11 +27,11 @@ export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> const { id } = await ctx.params; const record = getChat(id); if (!record) return Response.json({ error: "Unknown chat" }, { status: 404 }); - const markdown = sessionMarkdown(record.messages, { title: record.title, anchors: false }); + const markdown = sessionMarkdown(record.messages, { title: record.title, anchors: false, language: applicationLanguage(_req.headers.get("accept-language")) }); return new Response(markdown, { headers: { "content-type": "text/markdown; charset=utf-8", - "content-disposition": `attachment; filename="${slugify(record.title)}.md"`, + "content-disposition": `attachment; filename="${/^[a-z0-9-]+$/.test(slugify(record.title)) ? slugify(record.title) : "session"}.md"; filename*=UTF-8''${encodeURIComponent(slugify(record.title))}.md`, }, }); } diff --git a/app/api/graph/import/route.ts b/app/api/graph/import/route.ts index e719e1d..fd6b2da 100644 --- a/app/api/graph/import/route.ts +++ b/app/api/graph/import/route.ts @@ -13,7 +13,7 @@ * healthy. The dock's chips + graph refetch track progress live through * /api/graph/status, as with session runs. */ -import { extraction, NotesImportError } from "@/src/server"; +import { extraction, NotesImportError, applicationLanguage } from "@/src/server"; import { ModelsNotConfiguredError, PROVIDER_KEY_MISSING_CODE, @@ -56,7 +56,7 @@ export async function POST(request: Request) { const { file, requestIndex } = next; try { const text = await file.text(); - const stats = await extraction.importNotes({ filePath: file.name, text }); + const stats = await extraction.importNotes({ filePath: file.name, text, language: applicationLanguage(request.headers.get("accept-language")) }); results.push({ filePath: file.name, requestIndex, outcome: "completed", ok: true, stats }); } catch (err) { if (err instanceof ModelsNotConfiguredError) { diff --git a/app/globals.css b/app/globals.css index 5e61b68..8b103cc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -598,3 +598,6 @@ body { outline: 1px solid var(--border); border-radius: 0.25rem; } + +/* Source Sans 3 lacks Cyrillic; Russian prose uses the bundled Noto Sans subset. */ +html[lang="ru"] { --font-sans3: var(--font-russian-prose); } diff --git a/app/layout.tsx b/app/layout.tsx index 2343a78..d5f7334 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,9 @@ +import { headers } from "next/headers"; +import { readSettingsRecord } from "@/src/server"; +import { resolveLanguage, systemLanguage, translate } from "@/src/core"; +import { I18nProvider } from "@/components/i18n"; import type { Metadata } from "next"; -import { JetBrains_Mono, Source_Sans_3 } from "next/font/google"; +import { JetBrains_Mono, Source_Sans_3, Noto_Sans } from "next/font/google"; import "katex/dist/katex.min.css"; import "./globals.css"; import { AppShell, SidebarProvider } from "@/components/sidebar"; @@ -10,21 +14,29 @@ import UpdateBanner from "@/components/update-banner"; // Chrome = JetBrains Mono, teaching prose = Source Sans 3, titles = CMU // Serif (the bundled lh-cmu @font-face in globals.css — no CDN). -const ui = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains" }); +const ui = JetBrains_Mono({ subsets: ["latin", "cyrillic"], variable: "--font-jetbrains" }); const prose = Source_Sans_3({ subsets: ["latin"], variable: "--font-sans3" }); -export const metadata: Metadata = { - title: "Learning Harness", - description: "A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know.", -}; +const russianProse = Noto_Sans({ subsets: ["latin", "cyrillic"], variable: "--font-russian-prose" }); -export default function RootLayout({ children }: { children: React.ReactNode }) { +export async function generateMetadata(): Promise { + const language = resolveLanguage(readSettingsRecord()?.general?.language, (await headers()).get("accept-language")); + return { + title: "Learning Harness", + description: translate(language, "A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know."), + }; +} + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const general = readSettingsRecord()?.general ?? {}; + const system = systemLanguage((await headers()).get("accept-language")); + const language = resolveLanguage(general.language, system); return ( - + {/* The notification channel rides OUTSIDE everything: sidebar, dock, and pages all subscribe as the polls retire (push S3, 2026-10-02). */} - + @@ -35,6 +47,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {/* Auto-update banner (Wave C): fixed card, Electron-only pushes. */} + ); diff --git a/components/abort-turn-dialog.tsx b/components/abort-turn-dialog.tsx index a97b59b..ebbf6cb 100644 --- a/components/abort-turn-dialog.tsx +++ b/components/abort-turn-dialog.tsx @@ -1,4 +1,6 @@ "use client"; +import { useI18n } from "@/components/i18n"; + import { useEffect } from "react"; import { createPortal } from "react-dom"; @@ -60,13 +62,14 @@ export function AbortTurnBody({ * (owner, 2026-10-04): the line says so. */ busy?: boolean; }) { + const { t } = useI18n(); const backdropDismiss = useBackdropDismiss(onClose); const copy = COPY[mode]; return (
e.stopPropagation()} >

- {copy.title} + {t(copy.title)}

- {copy.line} - {busy && mode !== "turn" ? " The working turn stops too." : ""} + {t(copy.line)} + {busy && mode !== "turn" ? t(" The working turn stops too.") : ""}

+ {t("Cancel")}
diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index 41f456e..bd1e7fc 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.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 { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react"; @@ -257,12 +260,13 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] { } /** Elapsed as a compact ticking string: 45s / 3m 04s / 1h 12m. */ -export function formatElapsed(ms: number): string { +export function formatElapsed(ms: number, language: Language = "en"): string { + const t = translator(language); const s = Math.max(0, Math.floor(ms / 1000)); - if (s < 60) return `${s}s`; + if (s < 60) return t("{v0}s", { v0: s }); const m = Math.floor(s / 60); - if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`; - return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`; + if (m < 60) return t("{v0}m {v1}s", { v0: m, v1: String(s % 60).padStart(2, "0") }); + return t("{v0}h {v1}m", { v0: Math.floor(m / 60), v1: String(m % 60).padStart(2, "0") }); } // ─── The section (the sidebar's bottom strip) ───────────────────────────────── @@ -289,6 +293,7 @@ const KIND_ICON: Record = { type AbortState = "pending" | "failed"; export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) { + const { t } = useI18n(); const ctx = useContext(ActivityContext); const feed = feedProp ?? ctx.feed; const refresh = ctx.refresh; @@ -347,9 +352,8 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
- Background agents - {blobs.length > 0 && ( - + {t("Background agents")}{blobs.length > 0 && ( + {planCostLabel(totalCost, subscriptionLabels(blobs.map((blob) => blob.model)))} )} @@ -389,19 +393,20 @@ function ActivityBlobRow({ abortState: AbortState | undefined; onAbort: () => void; }) { + const { t, language } = useI18n(); const Icon = blob.process === "extraction" ? WaypointsIcon : (blob.jobKind && KIND_ICON[blob.jobKind]) || BotIcon; const label = blob.process === "extraction" - ? `Extraction — ${blob.phase ?? "running"}` + ? t("Extraction — {v0}", { v0: t(blob.phase ?? "running") }) : blob.jobKind === "research" - ? "Researcher" + ? t("Researcher") : blob.jobKind === "demo" - ? "Demo builder" + ? t("Demo builder") : blob.jobKind === "visual" - ? "Figure maker" + ? t("Figure maker") : blob.jobKind === "pdf" - ? "PDF reader" - : "Sub-agent"; + ? t("PDF reader") + : t("Sub-agent"); return (
{blob.title} {blob.activity ? ( - - {blob.activity} + + {t(blob.activity)} ) : ( {label} @@ -426,7 +431,7 @@ function ActivityBlobRow({ {planCostLabel(blob.cost, subscriptionLabel(blob.model))} - {formatElapsed(now - blob.startedAt)} + {formatElapsed(now - blob.startedAt, language)} {/* The explicit END always on the blob's face (SCOPE) — the popover @@ -437,8 +442,8 @@ function ActivityBlobRow({ data-testid="activity-abort" onClick={onAbort} disabled={abortState !== undefined} - title={abortState === "failed" ? "Abort failed — try again" : "End this background process"} - aria-label={`End ${label} in ${blob.title}`} + title={abortState === "failed" ? t("Abort failed — try again") : t("End this background process")} + aria-label={t("End {v0} in {v1}", { v0: label, v1: blob.title })} className="absolute right-1 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50" > @@ -453,47 +458,46 @@ function ActivityBlobRow({ {label} - {formatElapsed(now - blob.startedAt)} + {formatElapsed(now - blob.startedAt, language)}
{blob.title}
- {blob.activity &&
{blob.activity}
} + {blob.activity &&
{t(blob.activity)}
}
- Cost + {t("Cost")} {planCostLabel(blob.cost, subscriptionLabel(blob.model))}
- Context + {t("Context")} {blob.context === null ? "—" - : `${formatTokens(blob.context.used)} / ${formatTokens(blob.context.limit)}`} + : `${formatTokens(blob.context.used, language)} / ${formatTokens(blob.context.limit, language)}`}
{blob.model && (
- Model + {t("Model")} {blob.model}
)} {blob.steps !== undefined && (
- Steps + {t("Steps")} {blob.steps}
)} - Open session → - + {t("Open session →")}
- {abortState === "failed" && "Abort failed — try again"} - {abortState === "pending" && "Stopping…"} + {abortState === "failed" && t("Abort failed — try again")} + {abortState === "pending" && t("Stopping…")}
)} diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx index e4b533b..41b3f5b 100644 --- a/components/ai-elements/message.tsx +++ b/components/ai-elements/message.tsx @@ -1,4 +1,6 @@ "use client"; +import { useI18n } from "@/components/i18n"; + import { Button } from "@/components/ui/button"; import { @@ -31,6 +33,8 @@ import { import remarkBreaks from "remark-breaks"; import { Streamdown, + defaultTranslations, + type StreamdownTranslations, type PluginConfig, } from "streamdown"; import { mdComponents } from "@/components/md-blocks"; @@ -264,11 +268,12 @@ export const MessageBranchPrevious = ({ children, ...props }: MessageBranchPreviousProps) => { + const { t } = useI18n(); const { goToPrevious, totalBranches } = useMessageBranch(); return ( {whyOpen && (
- Why: + {t("Why:")}{" "} {/* The markdown wrapper must go truly inline — a block

after the "Why:" label created the oversized leading gap (owner's fix list). */} {answer.explanation} @@ -224,7 +226,7 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in the model's next reply IS the grading. */} {isExplain && answer.committed && (

- The answer: + {t("The answer:")}{" "} {answer.committed}
)} diff --git a/components/artifacts/demo-card.tsx b/components/artifacts/demo-card.tsx index fd781e8..5a8fa47 100644 --- a/components/artifacts/demo-card.tsx +++ b/components/artifacts/demo-card.tsx @@ -1,4 +1,6 @@ "use client"; +import { useI18n } from "@/components/i18n"; + import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { DownloadIcon } from "lucide-react"; @@ -100,12 +102,12 @@ export function DemoOutputCard({ messageId?: string; initialInverted?: boolean; }) { + const { t } = useI18n(); const result = output as DemoResult | undefined; if (!result || typeof result.html !== "string" || typeof result.title !== "string") { return (
- Demo completed, but the result could not be displayed. -
+ {t("Demo completed, but the result could not be displayed.")}
); } const failed = result.html.length === 0; @@ -113,10 +115,9 @@ export function DemoOutputCard({ return (
- Interactive demo - failed + {t("Interactive demo")}{t("failed")}
-

{result.notes ?? "The demo could not be built."}

+

{result.notes ?? t("The demo could not be built.")}

); } @@ -136,6 +137,7 @@ function DemoFrame({ messageId?: string; initialInverted?: boolean; }) { + const { t } = useI18n(); const actions = useContext(ChatActionsContext); const iframeRef = useRef(null); const [state, setState] = useState(INITIAL_DEMO_BRIDGE_STATE); @@ -177,22 +179,21 @@ function DemoFrame({
- interactive demo - + {t("interactive demo")} {title}
} />