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

This commit is contained in:
2026-10-25 17:18:44 +03:00
parent b277ee3e0f
commit a50ca4b867
93 changed files with 3261 additions and 904 deletions
+77 -75
View File
@@ -1,4 +1,7 @@
"use client";
import { translator, countLabel, type Language } from "@/src/core";
import { useI18n } from "@/components/i18n";
/**
* The home knowledge-graph view (W4; vision §1 "Rendering (DECIDED)").
@@ -276,7 +279,8 @@ function SettingSlider({
}
/** Compact token display: 45231 → "45.2k"; absent → "—". */
function fmtTokens(n: number | undefined): string {
function fmtTokens(n: number | undefined, language: Language = "en"): string {
if (language === "ru" && n !== undefined) return new Intl.NumberFormat("ru-RU", { notation: "compact", maximumFractionDigits: 1 }).format(n);
if (n === undefined) return "—";
if (n < 1000) return String(Math.round(n));
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
@@ -284,29 +288,30 @@ function fmtTokens(n: number | undefined): string {
}
/** Relative time for run rows: "now", "12m", "3h", "2d". */
function timeAgo(startedAt: number): string {
function timeAgo(startedAt: number, language: Language = "en"): string {
const t = translator(language);
const mins = Math.round((Date.now() - startedAt) / 60_000);
if (mins < 1) return "now";
if (mins < 60) return `${mins}m`;
if (mins < 1) return t("now");
if (mins < 60) return t("{v0}m", { v0: mins });
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h`;
return `${Math.round(hours / 24)}d`;
if (hours < 24) return t("{v0}h", { v0: hours });
return t("{v0}d", { v0: Math.round(hours / 24) });
}
/** Run/stage cost at 3 decimals (pipeline pennies are real money at this
* scale); headline totals use 2. Null (unpriced model) renders "—". */
function fmtRunCost(dollars: number | null, providers: readonly string[] = []): string {
return planCostLabel(dollars, providers, (value) => `$${value.toFixed(3)}`);
function fmtRunCost(dollars: number | null, providers: readonly string[] = [], language: Language = "en"): string {
return planCostLabel(dollars, providers, (value) => language === "ru" ? `${value.toLocaleString("ru-RU", { minimumFractionDigits: 3, maximumFractionDigits: 3 })} $` : `$${value.toFixed(3)}`);
}
function graphProviders(value: { subscriptionRuns?: number; subscriptionProviders?: string[] }): string[] {
return value.subscriptionProviders ?? ((value.subscriptionRuns ?? 0) > 0 ? ["ChatGPT"] : []);
}
function graphUsageLabel(costs: Pick<GraphCostSummary, "stages">): string {
function graphUsageLabel(costs: Pick<GraphCostSummary, "stages">, language: Language = "en"): string {
const stages = [costs.stages.extraction, costs.stages.audit];
const providers = [...new Set(stages.flatMap(graphProviders))];
const priced = stages.some((stage) => stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0);
const dollars = displayCostTotals(costs).totalDollars;
return planCostLabel(priced ? dollars : null, providers, (value) => `$${value.toFixed(2)}`);
return planCostLabel(priced ? dollars : null, providers, (value) => language === "ru" ? `${value.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} $` : `$${value.toFixed(2)}`);
}
/**
@@ -340,6 +345,7 @@ export function displayCostTotals(costs: Pick<GraphCostSummary, "stages">): {
/** Exported for the chains home (graph rendering off, owner
* 2026-10-06): the same bare cumulative-dollars pill + panel. */
export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
const { t, language } = useI18n();
const [open, setOpen] = useState(false);
// Click anywhere outside (or Escape) closes the panel — the root
// wraps button + panel, so the toggle itself keeps working.
@@ -349,7 +355,7 @@ export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
<div ref={rootRef} className="relative">
<Hint
// Trimmed (owner, subtlety round 2026-09-25).
label="Graph costs — click for details"
label={t("Graph costs — click for details")}
side="bottom"
>
<button
@@ -360,7 +366,7 @@ export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{graphUsageLabel(costs)}
{graphUsageLabel(costs, language)}
</button>
</Hint>
{open && (
@@ -383,9 +389,10 @@ export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
* naming-kind runs never reach the list.
*/
function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
const { t, language } = useI18n();
const stageRows: [string, GraphCostSummary["stages"]["extraction"]][] = [
["Extraction", costs.stages.extraction],
["Audit", costs.stages.audit],
[t("Extraction"), costs.stages.extraction],
[t("Audit"), costs.stages.audit],
];
// The displayed figures come from displayCostTotals (naming excluded —
// the pill and this panel must agree).
@@ -394,12 +401,11 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
<div className="w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border bg-card/95 p-3 text-xs shadow-lg backdrop-blur">
<div className="mb-2 flex items-baseline justify-between">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Graph pipeline costs
</span>
{t("Graph pipeline costs")}</span>
<span className="text-foreground tabular-nums">
{graphUsageLabel(costs)}
{graphUsageLabel(costs, language)}
{unpricedRuns > 0 && (
<span className="text-muted-foreground"> · {unpricedRuns} unpriced</span>
<span className="text-muted-foreground"> · {unpricedRuns}{" "}{t("unpriced")}</span>
)}
</span>
</div>
@@ -408,14 +414,13 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
<div key={label} className="flex items-baseline justify-between gap-2 text-[11px]">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="min-w-0 text-right tabular-nums">
{fmtRunCost(stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) <= 0 ? null : stage.dollars, graphProviders(stage))}
{fmtRunCost(stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) <= 0 ? null : stage.dollars, graphProviders(stage), language)}
{stage.unpricedRuns > 0 && (stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0 || (stage.subscriptionRuns ?? 0) > 0) && (
<span className="text-muted-foreground"> +{stage.unpricedRuns} unpriced</span>
<span className="text-muted-foreground"> +{stage.unpricedRuns}{" "}{t("unpriced")}</span>
)}
<span className="text-muted-foreground">
{" "}
· {fmtTokens(stage.tokens.inputTokens)} in / {fmtTokens(stage.tokens.outputTokens)} out
</span>
· {fmtTokens(stage.tokens.inputTokens, language)}{" "}{t("in /")}{" "}{fmtTokens(stage.tokens.outputTokens, language)}{" "}{t("out")}</span>
</span>
</div>
))}
@@ -424,16 +429,16 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
{costs.runs.map((run) => (
<div key={run.startedAt} className="flex items-baseline justify-between gap-2 text-[11px]">
<span className="min-w-0 flex-1 truncate text-muted-foreground">
<span className="text-foreground/70">{timeAgo(run.startedAt)}</span>{" "}
<span className="text-foreground/70">{timeAgo(run.startedAt, language)}</span>{" "}
{run.title}
{run.merged && (
<span className="text-muted-foreground">
{" "}
+{run.merged.nodes}n
+{language === "ru" ? countLabel(language, run.merged.nodes, "node", "nodes") : `${run.merged.nodes}n`}
</span>
)}
</span>
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars, graphProviders(run))}{run.dollars === null && run.subscriptionRuns === 1 ? " +1 unpriced" : ""}</span>
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars, graphProviders(run), language)}{run.dollars === null && run.subscriptionRuns === 1 ? t(" +1 unpriced") : ""}</span>
</div>
))}
</div>
@@ -454,6 +459,7 @@ function GraphSettingsControl({
settings: GraphRenderSettings;
changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
// Click anywhere outside (or Escape) closes the panel (2026-09-17) —
// the root wraps button + panel, so the toggle itself keeps working.
@@ -465,7 +471,7 @@ function GraphSettingsControl({
<div ref={rootRef} className="relative">
<Hint
// User language (tooltip wave) — "rendering" is internal.
label="Graph display settings"
label={t("Graph display settings")}
side="bottom"
>
<button
@@ -498,7 +504,7 @@ function GraphSettingsControl({
number — src/core/render-settings.ts). Physics keeps the
fine-grained log sliders (step 5). */}
<SettingSlider
label="Node size"
label={t("Node size")}
min={0}
max={100}
step={10}
@@ -508,7 +514,7 @@ function GraphSettingsControl({
changeSettings((cur) => ({ ...cur, nodeSize: fromLadderPosition(p, NODE_SIZE_LADDER) }))}
/>
<SettingSlider
label="Edge width"
label={t("Edge width")}
min={0}
max={100}
step={10}
@@ -518,7 +524,7 @@ function GraphSettingsControl({
changeSettings((cur) => ({ ...cur, edgeWidth: fromLadderPosition(p, EDGE_WIDTH_LADDER) }))}
/>
<SettingSlider
label="Arrowhead size"
label={t("Arrowhead size")}
min={0}
max={100}
step={10}
@@ -528,7 +534,7 @@ function GraphSettingsControl({
changeSettings((cur) => ({ ...cur, arrowLength: fromLadderPosition(p, ARROW_LENGTH_LADDER) }))}
/>
<SettingSlider
label="Label size"
label={t("Label size")}
min={0}
max={100}
step={10}
@@ -539,7 +545,7 @@ function GraphSettingsControl({
/>
{/* Display is absolute (ratio / 0.5): "1×" is the default, dead-center (owner, 2026-10-07). */}
<SettingSlider
label="Labels until zoom"
label={t("Labels until zoom")}
min={0}
max={100}
step={10}
@@ -557,10 +563,9 @@ function GraphSettingsControl({
the paint. */}
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Regions
</span>
{t("Regions")}</span>
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
<span>Background colors</span>
<span>{t("Background colors")}</span>
<input
type="checkbox"
checked={settings.regions.enabled}
@@ -573,7 +578,7 @@ function GraphSettingsControl({
/>
</label>
<SettingSlider
label="Dilation"
label={t("Dilation")}
disabled={!regionTuning.enabled}
min={0}
max={100}
@@ -587,7 +592,7 @@ function GraphSettingsControl({
}))}
/>
<SettingSlider
label="Blur"
label={t("Blur")}
disabled={!regionTuning.enabled}
min={0}
max={100}
@@ -602,7 +607,7 @@ function GraphSettingsControl({
}
/>
<SettingSlider
label="Edge alpha"
label={t("Edge alpha")}
disabled={!regionTuning.enabled}
min={0}
max={100}
@@ -618,7 +623,7 @@ function GraphSettingsControl({
<label
className={`flex items-center justify-between gap-2 text-[11px] text-muted-foreground${!regionTuning.enabled ? " opacity-40" : ""}`}
>
<span>Bitmap resolution</span>
<span>{t("Bitmap resolution")}</span>
<select
disabled={!regionTuning.enabled}
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
@@ -643,7 +648,7 @@ function GraphSettingsControl({
their own clusterLabels toggle. Size is screen px
(constant at any zoom, unlike node labels). */}
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
<span>Cluster labels</span>
<span>{t("Cluster labels")}</span>
<input
type="checkbox"
checked={settings.clusterLabels}
@@ -652,7 +657,7 @@ function GraphSettingsControl({
/>
</label>
<SettingSlider
label="Cluster label size"
label={t("Cluster label size")}
disabled={!settings.clusterLabels}
min={0}
max={100}
@@ -678,10 +683,9 @@ function GraphSettingsControl({
anchor + its gray-out-able controls last. */}
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Physics
</span>
{t("Physics")}</span>
<SettingSlider
label="Node repulsion"
label={t("Node repulsion")}
min={0}
max={100}
step={5}
@@ -694,7 +698,7 @@ function GraphSettingsControl({
}))}
/>
<SettingSlider
label="Edge attraction"
label={t("Edge attraction")}
min={0}
max={100}
step={5}
@@ -707,7 +711,7 @@ function GraphSettingsControl({
}))}
/>
<SettingSlider
label="Edge length"
label={t("Edge length")}
min={0}
max={100}
step={5}
@@ -720,7 +724,7 @@ function GraphSettingsControl({
}))}
/>
<SettingSlider
label="Central pull"
label={t("Central pull")}
min={0}
max={100}
step={5}
@@ -733,12 +737,12 @@ function GraphSettingsControl({
}))}
/>
<SettingSlider
label="Cluster pull"
label={t("Cluster pull")}
min={0}
max={100}
step={5}
value={toOffSliderPosition(physicsTuning.clusterPull, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max)}
display={physicsTuning.clusterPull <= 0 ? "off" : sizeDisplay(physicsTuning.clusterPull)}
display={physicsTuning.clusterPull <= 0 ? t("off") : sizeDisplay(physicsTuning.clusterPull)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
@@ -746,7 +750,7 @@ function GraphSettingsControl({
}))}
/>
<SettingSlider
label="Friction"
label={t("Friction")}
min={0}
max={100}
step={5}
@@ -763,8 +767,7 @@ function GraphSettingsControl({
controls at the very bottom — the grayed group reads
as a self-contained sub-section. */}
<label className="flex items-center justify-between gap-2 text-xs">
Depth anchor
<select
{t("Depth anchor")}<select
className="rounded border bg-background px-1 py-0.5 text-xs"
value={physicsTuning.depthMode}
onChange={(e) =>
@@ -774,13 +777,13 @@ function GraphSettingsControl({
}))
}
>
<option value="off">Off</option>
<option value="vertical">Vertical (up)</option>
<option value="radial">Radial (outward)</option>
<option value="off">{t("Off")}</option>
<option value="vertical">{t("Vertical (up)")}</option>
<option value="radial">{t("Radial (outward)")}</option>
</select>
</label>
<SettingSlider
label="Depth strength"
label={t("Depth strength")}
disabled={physicsTuning.depthMode === "off"}
min={0}
max={100}
@@ -794,7 +797,7 @@ function GraphSettingsControl({
}))}
/>
<SettingSlider
label="Depth spacing"
label={t("Depth spacing")}
disabled={physicsTuning.depthMode === "off"}
min={0}
max={100}
@@ -808,7 +811,7 @@ function GraphSettingsControl({
}))}
/>
<SettingSlider
label="Base ring radius"
label={t("Base ring radius")}
disabled={physicsTuning.depthMode !== "radial"}
min={0}
max={100}
@@ -866,13 +869,14 @@ export function GraphChromeRow({
* 2026-09-29). */
onOpenChains: () => void;
}) {
const { t } = useI18n();
return (
<>
{!isEmpty && (
<div className="graph-chrome-fade-in w-56 min-w-0 shrink rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
<Hint
// ⌘F focuses node search (surface-scoped registry action).
label="Search nodes"
label={t("Search nodes")}
action="find"
side="bottom"
>
@@ -882,15 +886,15 @@ export function GraphChromeRow({
ref={searchInputRef}
value={query}
onChange={(e) => onQuery(e.target.value)}
placeholder="Search nodes…"
aria-label="Search nodes"
placeholder={t("Search nodes…")}
aria-label={t("Search nodes")}
spellCheck={false}
className="h-7 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
/>
</div>
</Hint>
{noMatch && (
<div className="px-1.5 pt-1 text-[11px] text-muted-foreground">No matching nodes.</div>
<div className="px-1.5 pt-1 text-[11px] text-muted-foreground">{t("No matching nodes.")}</div>
)}
</div>
)}
@@ -898,7 +902,7 @@ export function GraphChromeRow({
container (36px band), between the search and Import. */}
{!isEmpty && (
<div className="graph-chrome-fade-in shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
<Hint label="Every reasoning chain" side="bottom">
<Hint label={t("Every reasoning chain")} side="bottom">
<button
type="button"
onClick={onOpenChains}
@@ -906,8 +910,7 @@ export function GraphChromeRow({
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent"
>
<ListTreeIcon className="size-3" />
Chains
</button>
{t("Chains")}</button>
</Hint>
</div>
)}
@@ -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"
>
<button
@@ -940,7 +943,7 @@ export function GraphChromeRow({
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
>
<UploadIcon className="size-3" />
{importing ? "Importing…" : "Import notes…"}
{importing ? t("Importing…") : t("Import notes…")}
</button>
</Hint>
</div>
@@ -974,6 +977,7 @@ export function GraphView({
onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void;
onChainSelectClose?: () => void;
}) {
const { t } = useI18n();
const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const cardRef = useRef<HTMLDivElement | null>(null);
@@ -1498,10 +1502,9 @@ export function GraphView({
<div className="flex h-full w-full items-center justify-center px-4">
<div className="flex flex-col items-center gap-3 text-center text-muted-foreground">
<WaypointsIcon className="size-10 opacity-40" strokeWidth={1.25} />
<div className="text-lg font-medium text-foreground">Knowledge graph</div>
<div className="text-lg font-medium text-foreground">{t("Knowledge graph")}</div>
<p className="max-w-sm text-sm">
Start a session or import notes to build your graph.
</p>
{t("Start a session or import notes to build your graph.")}</p>
</div>
</div>
) : (
@@ -1541,8 +1544,7 @@ export function GraphView({
}}
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-muted-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
>
Delete
</button>
{t("Delete")}</button>
</MenuShell>
)}
@@ -1630,9 +1632,9 @@ export function GraphView({
className="flex items-center gap-2 rounded-full border bg-card/85 px-3 py-1.5 text-xs text-muted-foreground shadow-lg backdrop-blur"
>
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
<span className="capitalize">{run.phase}</span>
<span className="capitalize">{t(run.phase)}</span>
<span className="max-w-48 truncate text-foreground">{run.title}</span>
<span className="tabular-nums">{Math.max(0, Math.round((Date.now() - run.startedAt) / 1000))}s</span>
<span className="tabular-nums">{t("{v0}s", { v0: Math.max(0, Math.round((Date.now() - run.startedAt) / 1000)) })}</span>
</div>
))}
</div>
@@ -1652,7 +1654,7 @@ export function GraphView({
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.statement}</div>
{selected.sourceIds.length > 0 ? (
<div className="mt-3 flex flex-col gap-1">
<div className="text-xs text-muted-foreground">Sessions</div>
<div className="text-xs text-muted-foreground">{t("Sessions")}</div>
{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({
})}
</div>
) : (
<div className="mt-3 text-xs text-muted-foreground">No sessions recorded.</div>
<div className="mt-3 text-xs text-muted-foreground">{t("No sessions recorded.")}</div>
)}
</div>
)}