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
+11 -3
View File
@@ -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<Response> {
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);
},
+2
View File
@@ -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";
+9
View File
@@ -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);
}
+6
View File
@@ -86,6 +86,12 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<Gene
}
settings.autoUpdate = raw.autoUpdate;
}
for (const key of ["language", "sttLanguage"] as const) {
if (raw[key] !== undefined) {
if (raw[key] !== "en" && raw[key] !== "ru") return { ok: false, error: `${key} must be "en" or "ru"` };
settings[key] = raw[key];
}
}
return { ok: true, settings };
}