feat: add multilingual dictation and simplify chat STT picker

This commit is contained in:
2026-10-26 00:58:25 +03:00
parent 1c6ae6b2e2
commit bdd7d5e866
10 changed files with 140 additions and 40 deletions
+6 -1
View File
@@ -2,9 +2,11 @@
import en from "./locales/en.json";
import ru from "./locales/ru.json";
export type Language = "en" | "ru";
export type SttLanguage = Language | "multi";
export type TranslationParams = Record<string, string | number | null | undefined>;
export type Translator = (message: string | null | undefined, params?: TranslationParams) => string;
export function isLanguage(value: unknown): value is Language { return value === "en" || value === "ru"; }
export function isSttLanguage(value: unknown): value is SttLanguage { return isLanguage(value) || value === "multi"; }
/** The preferred system language wins; unsupported preferences resolve to English. */
export function systemLanguage(languages: string | readonly string[] | null | undefined): Language {
@@ -20,6 +22,9 @@ export function systemLanguage(languages: string | readonly string[] | null | un
export function resolveLanguage(preference: unknown, system?: string | readonly string[] | null): Language {
return isLanguage(preference) ? preference : systemLanguage(system);
}
export function resolveSttLanguage(preference: unknown, system?: string | readonly string[] | null): SttLanguage {
return isSttLanguage(preference) ? preference : systemLanguage(system);
}
export function localeTag(language: Language): string { return language === "ru" ? "ru-RU" : "en-US"; }
type Entry = string | Partial<Record<Intl.LDMLPluralRule, string>>;
@@ -58,7 +63,7 @@ export function learningLanguageInstruction(language: Language): string {
: "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 {
export function dictationUrl(language: SttLanguage): 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}`;
}
+3
View File
@@ -249,6 +249,8 @@
"Speech recognition language": "Speech recognition language",
"Auto": "Auto",
"System default": "System default",
"Multilingual": "Multilingual",
"Multi": "Multi",
"Global": "Global",
"Chat": "Chat",
"Graph view": "Graph view",
@@ -723,6 +725,7 @@
"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\"",
"sttLanguage must be \"en\", \"ru\", or \"multi\"": "sttLanguage must be \"en\", \"ru\", or \"multi\"",
"{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",
+3
View File
@@ -249,6 +249,8 @@
"Speech recognition language": "Язык распознавания речи",
"Auto": "Авто",
"System default": "Язык системы",
"Multilingual": "Несколько языков",
"Multi": "Мульти",
"Global": "Общие",
"Chat": "Занятие",
"Graph view": "Граф",
@@ -754,6 +756,7 @@
"thinkingLevel must be a string": "Уровень рассуждения должен быть строкой",
"autoUpdate must be a boolean": "Параметр автообновления должен быть логическим значением",
"{v0} must be \"en\" or \"ru\"": "Параметр {v0} должен иметь значение «en» или «ru»",
"sttLanguage must be \"en\", \"ru\", or \"multi\"": "Параметр sttLanguage должен иметь значение «en», «ru» или «multi»",
"{v0} must be an object": "Параметр {v0} должен быть объектом",
"{v0}.model must be {provider, id}": "Поле {v0}.model должно содержать provider и id",
"{v0}.thinkingLevel must be a string": "Поле {v0}.thinkingLevel должно быть строкой",
+6 -6
View File
@@ -32,6 +32,7 @@ import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts";
import type { ModelRole } from "../../config.ts";
import type { ChatSettings } from "./contracts/chat.ts";
import type { SearchEngineId } from "./search.ts";
import { isLanguage, isSttLanguage, type Language, type SttLanguage } from "./i18n.ts";
/** The schema version this code understands. Bump ONLY for breaking shape
* changes (a field renamed/meaning changed); pure additions never bump. */
@@ -236,9 +237,9 @@ 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";
language?: Language;
/** Independent dictation language; multi = multilingual, absent = browser/system language. */
sttLanguage?: SttLanguage;
}
/** Agent settings — everything about the non-conversation machinery. */
@@ -447,9 +448,8 @@ function sanitizeGeneral(raw: Record<string, unknown>): 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];
}
if (isLanguage(raw.language)) out.language = raw.language;
if (isSttLanguage(raw.sttLanguage)) out.sttLanguage = raw.sttLanguage;
return out;
}
+9 -7
View File
@@ -12,7 +12,7 @@
*/
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { isHexColor, type ChatSettings } from "../core";
import { isHexColor, isLanguage, isSttLanguage, type ChatSettings } from "../core";
import type {
AgentSettings,
AppearanceSettings,
@@ -75,7 +75,7 @@ function asRecord(body: unknown): Record<string, unknown> | null {
return body as Record<string, unknown>;
}
/** general: { autoUpdate?: boolean } */
/** Independent interface/STT language choices and automatic updates. */
export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<GeneralSettings> {
const raw = asRecord(body);
if (!raw) return { ok: false, error: "Invalid JSON body" };
@@ -86,11 +86,13 @@ 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];
}
if (raw.language !== undefined) {
if (!isLanguage(raw.language)) return { ok: false, error: 'language must be "en" or "ru"' };
settings.language = raw.language;
}
if (raw.sttLanguage !== undefined) {
if (!isSttLanguage(raw.sttLanguage)) return { ok: false, error: 'sttLanguage must be "en", "ru", or "multi"' };
settings.sttLanguage = raw.sttLanguage;
}
return { ok: true, settings };
}