feat: English UI/i18n, summary slide-over, model-mode UX (v0.3.0)

- i18n: lightweight RU/EN system + interface-language picker; full UI translated, OS-detected default
- summary: right slide-over modal with per-record summary history table (generate/regenerate/edit/copy/delete); replaces inline SummaryPanel; triggers disabled with hint when no text model configured
- models: API & Local split into Recognition/Text sub-tabs; explicit select-mode commit row; TextModelCard restyled to match adapter cards; honest Configured badge
- widget: click-to-expand notice bubble
- bump version to 0.3.0
This commit is contained in:
David Perov 2026-06-25 13:17:48 +03:00
parent fe81d2e7e8
commit 2296e9bda8
48 changed files with 3770 additions and 1018 deletions

View file

@ -1,7 +1,7 @@
{
"name": "talkis",
"private": true,
"version": "0.2.0",
"version": "0.3.0",
"license": "AGPL-3.0-or-later",
"type": "module",
"packageManager": "bun@1.2.13",

2
src-tauri/Cargo.lock generated
View file

@ -4969,7 +4969,7 @@ dependencies = [
[[package]]
name = "talkis"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"base64 0.22.1",
"block2 0.6.2",

View file

@ -1,6 +1,6 @@
[package]
name = "talkis"
version = "0.2.0"
version = "0.3.0"
description = "Talkis - Voice to Text Desktop Widget"
authors = ["trixter"]
license = "AGPL-3.0-or-later"

View file

@ -2164,7 +2164,60 @@ pub async fn install_stt_model(
request = request.bearer_auth(whisper_key);
}
let response = request.send().await.map_err(|err| {
// The Qwen/Parakeet model download runs inside the managed Python runtime via
// this one blocking POST, so the streaming cancel flag (which Whisper/LLM poll
// per chunk) has nothing to watch here — that's why "Отмена" did nothing for
// Qwen. Race the request against the cancel flag; on abort, kill the managed
// runtime (stops the in-flight huggingface download) and reset to "not downloaded".
let cancellable_engine_download =
managed_runtime_kind.is_some_and(|kind| kind != local_stt::LocalRuntimeKind::Whisper);
if cancellable_engine_download {
crate::download_cancel::clear(requested_model);
}
let send_result = if cancellable_engine_download {
let send = request.send();
tokio::pin!(send);
loop {
tokio::select! {
result = &mut send => break result,
_ = tokio::time::sleep(Duration::from_millis(400)) => {
if crate::download_cancel::is_cancel_requested(requested_model) {
if let Some(stop) = &local_progress_stop {
stop.store(true, Ordering::Relaxed);
}
if let (Some(kind), Some(port)) =
(managed_runtime_kind, port_from_url(&models_url))
{
if let Err(err) = local_stt::stop_managed_runtime(kind, port) {
logger::log_error("STT_INSTALL", &err);
}
}
crate::download_cancel::clear(requested_model);
local_stt::emit_model_download_progress_message(
&app,
requested_model,
"cancelled",
0,
None,
crate::download_cancel::CANCELLED_MESSAGE,
);
logger::log_info(
"STT_INSTALL",
&format!(
"User cancelled managed STT model download: {}",
requested_model
),
);
return Err(crate::download_cancel::CANCELLED_MESSAGE.to_string());
}
}
}
}
} else {
request.send().await
};
let response = send_result.map_err(|err| {
if let Some(stop) = &local_progress_stop {
stop.store(true, Ordering::Relaxed);
}
@ -2186,6 +2239,9 @@ pub async fn install_stt_model(
if let Some(stop) = &local_progress_stop {
stop.store(true, Ordering::Relaxed);
}
if cancellable_engine_download {
crate::download_cancel::clear(requested_model);
}
let status = response.status();
if !status.is_success() {

View file

@ -66,23 +66,29 @@ pub fn ensure_widget_notice_window(app: &AppHandle) -> Result<tauri::WebviewWind
Ok(win)
}
/// Maximum expanded height for the notice bubble (logical px) — keeps a very long
/// hint from running off the top of the screen.
pub const NOTICE_MAX_HEIGHT: f64 = 360.0;
fn position_widget_notice_window(
widget_window: &tauri::WebviewWindow,
notice_window: &tauri::WebviewWindow,
height: f64,
) -> Result<(), String> {
let widget_position = widget_window.outer_position().map_err(|e| e.to_string())?;
let widget_size = widget_window.outer_size().map_err(|e| e.to_string())?;
let scale_factor = widget_window.scale_factor().map_err(|e| e.to_string())?;
let notice_width = NOTICE_WIDTH * scale_factor;
let notice_height = NOTICE_HEIGHT * scale_factor;
let notice_height = height * scale_factor;
let notice_gap = NOTICE_GAP * scale_factor;
let x = widget_position.x as f64 + (widget_size.width as f64 - notice_width) / 2.0;
// Anchor the bubble's bottom edge just above the widget, so it grows upward.
let y = widget_position.y as f64 - notice_gap - notice_height;
notice_window
.set_size(tauri::Size::Logical(tauri::LogicalSize {
width: NOTICE_WIDTH,
height: NOTICE_HEIGHT,
height,
}))
.map_err(|e| e.to_string())?;
@ -271,7 +277,8 @@ pub async fn show_widget_notice(
.ok_or_else(|| "Widget window not found".to_string())?;
let notice_window = ensure_widget_notice_window(&app)?;
position_widget_notice_window(&widget_window, &notice_window)?;
// Always (re)show collapsed; the overlay expands on click.
position_widget_notice_window(&widget_window, &notice_window, NOTICE_HEIGHT)?;
app.emit_to(
NOTICE_WINDOW_LABEL,
@ -294,6 +301,20 @@ pub async fn hide_widget_notice(app: AppHandle) -> Result<(), String> {
Ok(())
}
/// Resize the notice bubble to fit its full text on click (or back to the
/// collapsed height). Re-anchors above the widget so it grows upward.
#[tauri::command]
pub async fn expand_widget_notice(app: AppHandle, height: f64) -> Result<(), String> {
let widget_window = app
.get_webview_window("widget")
.ok_or_else(|| "Widget window not found".to_string())?;
let notice_window = app
.get_webview_window(NOTICE_WINDOW_LABEL)
.ok_or_else(|| "Notice window not found".to_string())?;
let clamped = height.clamp(NOTICE_HEIGHT, NOTICE_MAX_HEIGHT);
position_widget_notice_window(&widget_window, &notice_window, clamped)
}
#[tauri::command]
pub async fn widget_resize(
app: AppHandle,

View file

@ -146,6 +146,7 @@ pub fn run() {
widget::activate_widget_for_hotkey,
widget::show_widget_notice,
widget::hide_widget_notice,
widget::expand_widget_notice,
paste::remember_paste_target_window,
paste::paste_text,
ai::transcribe_and_clean,

View file

@ -22,6 +22,7 @@ struct LlmModelInfo {
file_name: &'static str,
url: &'static str,
label: &'static str,
description: &'static str,
size_label: &'static str,
min_ram_gb: u32,
}
@ -34,6 +35,7 @@ static LLM_CATALOG: &[LlmModelInfo] = &[
file_name: "Qwen2.5-3B-Instruct-Q4_K_M.gguf",
url: "https://huggingface.co/bartowski/Qwen2.5-3B-Instruct-GGUF/resolve/main/Qwen2.5-3B-Instruct-Q4_K_M.gguf",
label: "Qwen2.5 3B Instruct",
description: "Компактная текстовая модель для саммари на слабых машинах: быстрая, с хорошей поддержкой русского.",
size_label: "2.0 ГБ",
min_ram_gb: 8,
},
@ -42,6 +44,7 @@ static LLM_CATALOG: &[LlmModelInfo] = &[
file_name: "Qwen2.5-7B-Instruct-Q4_K_M.gguf",
url: "https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF/resolve/main/Qwen2.5-7B-Instruct-Q4_K_M.gguf",
label: "Qwen2.5 7B Instruct",
description: "Более качественная текстовая модель для детальных саммари: точнее, но требовательнее к памяти.",
size_label: "4.7 ГБ",
min_ram_gb: 16,
},
@ -374,6 +377,7 @@ pub fn stop_runtime() {
pub struct LocalLlmModel {
id: String,
label: String,
description: String,
file_name: String,
size_label: String,
min_ram_gb: u32,
@ -387,6 +391,7 @@ pub fn list_local_llm_models(app: AppHandle) -> Vec<LocalLlmModel> {
.map(|model| LocalLlmModel {
id: model.id.to_string(),
label: model.label.to_string(),
description: model.description.to_string(),
file_name: model.file_name.to_string(),
size_label: model.size_label.to_string(),
min_ram_gb: model.min_ram_gb,

View file

@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "Talkis",
"mainBinaryName": "Talkis",
"version": "0.2.0",
"version": "0.3.0",
"identifier": "com.trixter.talkis",
"build": {
"beforeDevCommand": "bun run prepare:sidecars && bun run dev",

124
src/components/Dropdown.tsx Normal file
View file

@ -0,0 +1,124 @@
import { useEffect, useRef, useState } from "react";
import { Check, ChevronDown } from "lucide-react";
export interface DropdownOption {
value: string;
label: string;
}
/**
* Custom select styled like the dropdowns in Settings (a `.btn` trigger + a
* popover list with a check on the active item) used instead of a native
* <select> so it matches the rest of the app.
*/
export function Dropdown({
value,
options,
onChange,
disabled,
}: {
value: string;
options: DropdownOption[];
onChange: (value: string) => void;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const current = options.find((option) => option.value === value);
return (
<div ref={ref} style={{ position: "relative", width: "100%" }}>
<button
type="button"
className="btn"
disabled={disabled}
onClick={() => setOpen((value) => !value)}
style={{
width: "100%",
justifyContent: "space-between",
gap: 8,
minHeight: 38,
padding: "0 10px",
borderRadius: 8,
fontSize: 13,
opacity: disabled ? 0.6 : 1,
}}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{current?.label ?? value}
</span>
<ChevronDown
size={14}
strokeWidth={2}
style={{ flexShrink: 0, transform: open ? "rotate(180deg)" : "none", transition: "transform 0.15s" }}
/>
</button>
{open && (
<div
style={{
position: "absolute",
top: "calc(100% + 6px)",
left: 0,
right: 0,
maxHeight: 280,
overflow: "auto",
background: "var(--dropdown-bg)",
border: "1px solid var(--border)",
borderRadius: 14,
boxShadow: "var(--shadow-panel)",
zIndex: 100,
padding: 6,
}}
>
{options.map((option) => {
const active = option.value === value;
return (
<button
key={option.value}
type="button"
onClick={() => {
onChange(option.value);
setOpen(false);
}}
style={{
width: "100%",
textAlign: "left",
border: "none",
cursor: "pointer",
padding: "9px 12px",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
background: active ? "var(--dropdown-active)" : "transparent",
color: active ? "var(--text-hi)" : "var(--text-mid)",
fontSize: 13,
fontFamily: "var(--font-main)",
}}
onMouseEnter={(e) => (e.currentTarget.style.background = active ? "var(--dropdown-active)" : "var(--dropdown-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.background = active ? "var(--dropdown-active)" : "transparent")}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{option.label}</span>
{active && <Check size={14} strokeWidth={2.5} style={{ flexShrink: 0, color: "var(--text-hi)" }} />}
</button>
);
})}
</div>
)}
</div>
);
}

View file

@ -1,14 +1,16 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { Check, Download, HardDrive, Loader2, MemoryStick, Trash2, X } from "lucide-react";
import { AlertCircle, Check, Download, HardDrive, Loader2, MemoryStick, Trash2, X } from "lucide-react";
import { AppSettings } from "../lib/store";
import { useI18n } from "../lib/i18n";
import qwenAvatar from "../assets/adapters/qwen.png";
interface LocalLlmModel {
id: string;
label: string;
description: string;
file_name: string;
size_label: string;
min_ram_gb: number;
@ -52,10 +54,12 @@ export function LocalLlmModels({
settings: AppSettings;
update: (patch: Partial<AppSettings>) => void;
}) {
const { t } = useI18n();
const [models, setModels] = useState<LocalLlmModel[]>([]);
const [status, setStatus] = useState<LocalLlmStatus | null>(null);
const [progress, setProgress] = useState<Record<string, DownloadProgress>>({});
const [busy, setBusy] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = async (): Promise<void> => {
@ -143,6 +147,7 @@ export function LocalLlmModels({
const remove = async (model: LocalLlmModel): Promise<void> => {
setBusy(model.id);
setDeleting(model.id);
setError(null);
try {
await invoke("delete_local_llm_model", { modelId: model.id });
@ -156,6 +161,7 @@ export function LocalLlmModels({
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(null);
setDeleting(null);
}
};
@ -182,10 +188,10 @@ export function LocalLlmModels({
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: "var(--text-hi)", marginBottom: 4 }}>
Текстовые модели
{t("localLlm.title")}
</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>
Скачайте локальную модель и используйте ее для summary без облака.
{t("localLlm.desc")}
</div>
</div>
@ -205,26 +211,51 @@ export function LocalLlmModels({
</div>
)}
{!usingLocalEndpoint && (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 8,
fontSize: 12,
lineHeight: 1.55,
padding: "9px 11px",
borderRadius: 8,
background: "var(--control-muted)",
color: "var(--text-hi)",
border: "1px solid var(--border-subtle)",
}}
>
<AlertCircle size={15} strokeWidth={2} style={{ flexShrink: 0, marginTop: 1, color: "var(--accent)" }} />
<span>{t("localLlm.required")}</span>
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{models.map((model) => {
const prog = progress[model.id];
const isDownloading =
prog?.status === "downloading" || prog?.status === "starting";
const isBusy = busy === model.id;
const isDeleting = deleting === model.id;
const isActive = activeModelId === model.id && usingLocalEndpoint;
const statusLabel = isActive
? "Активна"
: model.downloaded
? "Скачана"
: isDownloading
? "Загрузка"
: "Не скачана";
const statusLabel = isDeleting
? t("localLlm.status.deleting")
: isDownloading
? t("localLlm.status.downloading")
: isActive
? t("localLlm.status.selected")
: model.downloaded
? t("localLlm.status.ready")
: t("localLlm.status.notDownloaded");
const statusColor = isActive
? "var(--success)"
? "var(--success-bright)"
: model.downloaded
? "var(--text-hi)"
: "var(--text-low)";
? "var(--success-bright)"
: isDownloading || isDeleting
? "var(--text-hi)"
: "var(--text-low)";
return (
<div
@ -290,6 +321,9 @@ export function LocalLlmModels({
{statusLabel}
</div>
</div>
<div style={{ fontSize: 12, lineHeight: 1.45, color: "var(--text-mid)" }}>
{model.description} Runtime: Talkis Local / llama.cpp.
</div>
<div
style={{
display: "flex",
@ -300,8 +334,8 @@ export function LocalLlmModels({
}}
>
<div
title={`Размер на диске: ${model.size_label}`}
aria-label={`Размер на диске: ${model.size_label}`}
title={t("localLlm.diskSize", { size: model.size_label })}
aria-label={t("localLlm.diskSize", { size: model.size_label })}
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
<HardDrive size={14} strokeWidth={1.9} color="var(--text-hi)" />
@ -310,13 +344,13 @@ export function LocalLlmModels({
</span>
</div>
<div
title={`Требуется ОЗУ: от ${model.min_ram_gb} ГБ`}
aria-label={`Требуется ОЗУ: от ${model.min_ram_gb} ГБ`}
title={t("localLlm.ramRequired", { ram: model.min_ram_gb })}
aria-label={t("localLlm.ramRequired", { ram: model.min_ram_gb })}
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
<MemoryStick size={14} strokeWidth={1.9} color="var(--text-hi)" />
<span style={{ fontSize: 12, fontWeight: 650, color: "var(--text-hi)", lineHeight: 1 }}>
{model.min_ram_gb} ГБ
{t("localLlm.ramShort", { ram: model.min_ram_gb })}
</span>
</div>
</div>
@ -345,9 +379,9 @@ export function LocalLlmModels({
fontWeight: 650,
}}
>
<span>Загрузка модели</span>
<span>{t("localLlm.downloadingModel")}</span>
<span style={{ color: "var(--text-hi)" }}>
{prog?.percent != null ? `${prog.percent}%` : "Подготовка"}
{prog?.percent != null ? `${prog.percent}%` : t("localLlm.preparing")}
</span>
</div>
<div
@ -382,22 +416,6 @@ export function LocalLlmModels({
flexWrap: "wrap",
}}
>
{isActive && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
color: "var(--success)",
fontSize: 12,
fontWeight: 600,
}}
>
<Check size={15} strokeWidth={2.5} />
Выбрана
</div>
)}
<div
style={{
display: "flex",
@ -408,44 +426,31 @@ export function LocalLlmModels({
}}
>
{!model.downloaded ? (
<>
isDownloading ? (
<button
onClick={() => void cancelDownload(model)}
style={{
...ACTION_BUTTON_BASE,
background: "var(--control-muted)",
color: "var(--text-hi)",
}}
>
<X size={14} strokeWidth={2.2} />
{t("localLlm.cancel")}
</button>
) : (
<button
onClick={() => void download(model)}
disabled={isDownloading}
style={{
...ACTION_BUTTON_BASE,
background: "var(--accent)",
color: "var(--accent-contrast)",
opacity: isDownloading ? 0.85 : 1,
}}
>
{isDownloading ? (
<Loader2
size={14}
strokeWidth={2.2}
style={{ animation: "spin 1s linear infinite" }}
/>
) : (
<Download size={14} strokeWidth={2.2} />
)}
{isDownloading
? `Загрузка${prog?.percent != null ? ` ${prog.percent}%` : ""}`
: "Скачать"}
<Download size={14} strokeWidth={2.2} />
{t("localLlm.download")}
</button>
{isDownloading && (
<button
onClick={() => void cancelDownload(model)}
style={{
...ACTION_BUTTON_BASE,
background: "var(--control-muted)",
color: "var(--text-hi)",
}}
>
<X size={14} strokeWidth={2.2} />
Отмена
</button>
)}
</>
)
) : (
<>
{!isActive && (
@ -468,7 +473,7 @@ export function LocalLlmModels({
) : (
<Check size={14} strokeWidth={2.5} />
)}
Выбрать
{t("localLlm.select")}
</button>
)}
<button
@ -477,11 +482,11 @@ export function LocalLlmModels({
style={{
...ACTION_BUTTON_BASE,
background: "var(--control-muted)",
color: "var(--danger)",
color: "var(--text-hi)",
}}
>
<Trash2 size={14} strokeWidth={2.2} />
Удалить
{t("localLlm.delete")}
</button>
</>
)}

View file

@ -12,6 +12,7 @@ import {
} from "../lib/permissions";
import { getSettings } from "../lib/store";
import { logError } from "../lib/logger";
import { useI18n } from "../lib/i18n";
import { scaleWidgetDimension } from "../lib/widgetScale";
import {
CALL_STACK_WIDGET_HEIGHT,
@ -35,6 +36,7 @@ function PermissionRow({
onAction,
helpText,
}: PermissionRowProps) {
const { t } = useI18n();
const isGranted = status === "granted";
const isDenied = status === "denied";
const isPrompting = status === "prompting";
@ -94,10 +96,10 @@ function PermissionRow({
}}
>
{isPrompting
? "Проверьте"
? t("permission.badge.check")
: isDenied
? "Нужно действие"
: "Не выдано"}
? t("permission.badge.actionNeeded")
: t("permission.badge.notGranted")}
</span>
)}
</div>
@ -124,7 +126,7 @@ function PermissionRow({
transition: "opacity 0.15s",
}}
>
{isPrompting ? "Проверить" : isDenied ? "Повторить" : "Разрешить"}
{isPrompting ? t("permission.action.check") : isDenied ? t("permission.action.retry") : t("permission.action.allow")}
</button>
)}
</div>
@ -180,23 +182,26 @@ function detectDesktopPlatform(): DesktopPlatform {
return "unknown";
}
function microphoneHelpText(platform: DesktopPlatform): string {
type TranslateFn = ReturnType<typeof useI18n>["t"];
function microphoneHelpText(platform: DesktopPlatform, t: TranslateFn): string {
if (platform === "macos") {
return "Если микрофон был отклонен, откройте Системные настройки -> Конфиденциальность -> Микрофон и включите Talkis.";
return t("permission.micHelp.macos");
}
if (platform === "windows") {
return "Если микрофон был отклонен, откройте Параметры -> Конфиденциальность и безопасность -> Микрофон и разрешите доступ для Talkis.";
return t("permission.micHelp.windows");
}
if (platform === "linux") {
return "Если микрофон недоступен, проверьте системные настройки звука и разрешения браузерного WebView для записи.";
return t("permission.micHelp.linux");
}
return "Если микрофон был отклонен, откройте системные настройки приватности и разрешите доступ для Talkis.";
return t("permission.micHelp.default");
}
export function PermissionScreen({ onComplete }: PermissionScreenProps) {
const { t } = useI18n();
const [micStatus, setMicStatus] = useState<PermissionStatus>("unknown");
const [accStatus, setAccStatus] = useState<PermissionStatus>("unknown");
const [systemAudioStatus, setSystemAudioStatus] =
@ -358,17 +363,17 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
? false
: Boolean(runtimeInfo?.shouldMoveToApplications);
const pastePermissionTitle = requiresAccessibility
? "Универсальный доступ"
: "Вставка текста";
? t("permission.paste.titleAccessibility")
: t("permission.paste.titlePaste");
const pastePermissionDescription = requiresAccessibility
? "Нужен для глобальной горячей клавиши и вставки текста."
? t("permission.paste.descAccessibility")
: platform === "linux"
? "Talkis использует буфер обмена и Ctrl+V. В некоторых Wayland/X11 окружениях автоматическая вставка может быть ограничена."
: "Talkis использует буфер обмена и Ctrl+V. Отдельное системное разрешение обычно не требуется.";
? t("permission.paste.descLinux")
: t("permission.paste.descDefault");
const pastePermissionHelpText = shouldShowInstallWarning
? `Приложение запущено не из Applications: ${runtimeInfo?.bundlePath ?? "-"}`
? t("permission.paste.helpNotInApplications", { path: runtimeInfo?.bundlePath ?? "-" })
: platform === "linux"
? "Если вставка не сработает, скопированный текст останется в буфере обмена и его можно вставить вручную."
? t("permission.paste.helpLinux")
: undefined;
const canContinue =
micStatus === "granted" &&
@ -426,7 +431,7 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
color: "var(--text-hi)",
}}
>
Доступы для Talkis
{t("permission.header.title")}
</h1>
<p
style={{
@ -437,8 +442,7 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
lineHeight: 1.7,
}}
>
Осталось выдать системные разрешения для записи голоса, звука
созвона и работы горячей клавиши.
{t("permission.header.subtitle")}
</p>
</div>
@ -472,7 +476,7 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
color: "var(--danger)",
}}
>
Переместите приложение в Applications
{t("permission.installWarning.title")}
</div>
<div
style={{
@ -481,8 +485,7 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
lineHeight: 1.6,
}}
>
Текущая сборка запущена из временного места. Переместите
Talkis в Applications и откройте оттуда.
{t("permission.installWarning.desc")}
</div>
</div>
</div>
@ -490,8 +493,8 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
<PermissionRow
icon={<Mic size={16} strokeWidth={1.8} />}
title="Микрофон"
description="Нужен для записи голоса перед отправкой на распознавание."
title={t("permission.mic.title")}
description={t("permission.mic.desc")}
status={micStatus}
onAction={handleMicRequest}
/>
@ -499,11 +502,11 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
{requiresSystemAudio && (
<PermissionRow
icon={<Volume2 size={16} strokeWidth={1.8} />}
title="Звук системы"
description="Нужен, чтобы слушать звук созвона вместе с микрофоном."
title={t("permission.systemAudio.title")}
description={t("permission.systemAudio.desc")}
status={systemAudioStatus}
onAction={handleSystemAudioRequest}
helpText="После нажатия macOS может попросить разрешить Talkis запись системного аудио."
helpText={t("permission.systemAudio.help")}
/>
)}
@ -561,12 +564,12 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
}}
>
{micStatus === "denied"
? microphoneHelpText(platform)
? microphoneHelpText(platform, t)
: systemAudioStatus === "denied"
? "Если доступ был отклонен, откройте Системные настройки -> Конфиденциальность и безопасность -> Запись экрана и системного аудио и включите Talkis."
? t("permission.hint.systemAudioDenied")
: shouldShowInstallWarning
? "После перемещения приложения в Applications откройте его заново."
: "macOS применяет доступ не мгновенно. После изменения настройки вернитесь в приложение."}
? t("permission.hint.reopenAfterMove")
: t("permission.hint.macosDelay")}
</div>
</div>
)}
@ -588,14 +591,14 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
}}
>
{shouldShowInstallWarning
? "Сначала запустите из Applications."
? t("permission.footer.launchFromApplications")
: canContinue
? "Все доступы выданы."
? t("permission.footer.allGranted")
: requiresSystemAudio
? "Выдайте разрешения для продолжения."
? t("permission.footer.grantToContinue")
: requiresAccessibility
? "Выдайте оба разрешения для продолжения."
: "Разрешите микрофон для продолжения."}
? t("permission.footer.grantBothToContinue")
: t("permission.footer.grantMicToContinue")}
</div>
<button
onClick={handleContinue}
@ -620,10 +623,10 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
}}
>
{canCompleteOnboarding
? "Продолжить"
? t("permission.button.continue")
: shouldShowInstallWarning
? "Applications"
: "Проверить"}
: t("permission.button.check")}
</button>
</div>
</div>

View file

@ -0,0 +1,115 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { MoreHorizontal } from "lucide-react";
export interface RowActionItem {
key: string;
label: string;
icon?: ReactNode;
onSelect: () => void;
danger?: boolean;
disabled?: boolean;
/** Tooltip shown on a disabled item, explaining why it can't be used. */
hint?: string;
}
/**
* Compact "⋯" button that opens a small dropdown of row actions. Reused by the
* history rows (MainTab) and the summary-history rows (SummaryModal). Closes on
* outside click or after an item is chosen.
*/
export function RowActionsMenu({
items,
label,
}: {
items: RowActionItem[];
label?: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
return (
<div ref={ref} style={{ position: "relative", flexShrink: 0 }}>
<button
type="button"
className="btn"
aria-label={label}
title={label}
onClick={(event) => {
event.stopPropagation();
setOpen((value) => !value);
}}
style={{ width: 32, minWidth: 32, height: 32, minHeight: 32, padding: 0, borderRadius: 8, flexShrink: 0, justifyContent: "center" }}
>
<MoreHorizontal size={15} strokeWidth={2} />
</button>
{open && (
<div
onClick={(event) => event.stopPropagation()}
style={{
position: "absolute",
top: "calc(100% + 6px)",
right: 0,
minWidth: 168,
background: "var(--dropdown-bg)",
border: "1px solid var(--border)",
borderRadius: 12,
boxShadow: "var(--shadow-panel)",
zIndex: 50,
padding: 6,
display: "flex",
flexDirection: "column",
}}
>
{items.map((item) => (
<button
key={item.key}
type="button"
disabled={item.disabled}
title={item.disabled ? item.hint : undefined}
onClick={() => {
if (item.disabled) return;
setOpen(false);
item.onSelect();
}}
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
textAlign: "left",
border: "none",
background: "transparent",
cursor: item.disabled ? "not-allowed" : "pointer",
padding: "9px 10px",
borderRadius: 8,
fontSize: 13,
fontFamily: "var(--font-main)",
color: item.danger ? "var(--danger)" : "var(--text-hi)",
opacity: item.disabled ? 0.45 : 1,
}}
onMouseEnter={(event) => {
if (!item.disabled) event.currentTarget.style.background = "var(--dropdown-hover)";
}}
onMouseLeave={(event) => (event.currentTarget.style.background = "transparent")}
>
{item.icon}
<span>{item.label}</span>
</button>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,109 @@
import { useEffect, useState, type ReactNode } from "react";
import { X } from "lucide-react";
import { useI18n } from "../lib/i18n";
/**
* A panel that slides in from the right over a dimmed backdrop. Closes on the
* close button, a click on the free area to the left (backdrop), or Escape.
* Manages its own enter/exit animation, then calls `onClose` once the exit
* transition finishes, so the parent can simply unmount it.
*/
export function SlideOverModal({
onClose,
title,
width = 440,
children,
}: {
onClose: () => void;
title?: ReactNode;
width?: number | string;
children: ReactNode;
}) {
const { t } = useI18n();
const [entered, setEntered] = useState(false);
useEffect(() => {
const raf = requestAnimationFrame(() => setEntered(true));
return () => cancelAnimationFrame(raf);
}, []);
const requestClose = () => {
setEntered(false);
window.setTimeout(onClose, 240);
};
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
requestClose();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div
onClick={requestClose}
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
display: "flex",
justifyContent: "flex-end",
background: entered ? "rgba(0,0,0,0.32)" : "rgba(0,0,0,0)",
transition: "background 0.24s ease",
}}
>
<div
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
style={{
width,
maxWidth: "100vw",
height: "100%",
background: "var(--surface-solid)",
borderLeft: "1px solid var(--border)",
boxShadow: "var(--shadow-panel)",
transform: entered ? "translateX(0)" : "translateX(100%)",
transition: "transform 0.24s cubic-bezier(0.22, 1, 0.36, 1)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "14px 16px",
borderBottom: "1px solid var(--border-subtle)",
flexShrink: 0,
}}
>
<div style={{ fontSize: 16, fontWeight: 750, color: "var(--text-hi)", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{title}
</div>
<button
type="button"
className="btn"
onClick={requestClose}
title={t("summary.close")}
aria-label={t("summary.close")}
style={{ width: 32, minWidth: 32, minHeight: 32, padding: 0, justifyContent: "center", flexShrink: 0 }}
>
<X size={14} strokeWidth={2} />
</button>
</div>
<div style={{ flex: 1, overflow: "auto", padding: 16 }}>{children}</div>
</div>
</div>
);
}

View file

@ -0,0 +1,300 @@
import { useEffect, useMemo, useState } from "react";
import { Check, Clipboard, Pencil, Trash2 } from "lucide-react";
import {
getSettings,
listPromptsByKind,
addSummaryToEntry,
updateSummaryInEntry,
deleteSummaryFromEntry,
type AppSettings,
type HistoryEntry,
type SummaryEntry,
} from "../lib/store";
import {
summarizeTranscript,
resolveSummaryBackend,
transcriptToText,
type SummarizeProgress,
} from "../lib/summarize";
import { formatErrorMessage } from "../lib/utils";
import { useI18n } from "../lib/i18n";
import { SlideOverModal } from "./SlideOverModal";
import { RowActionsMenu, type RowActionItem } from "./RowActionsMenu";
import { Dropdown } from "./Dropdown";
const PREFERRED_DEFAULT_PROMPT = "summary-short";
const TEXT_PREVIEW_LIMIT = 250;
export function SummaryModal({
entry,
onClose,
onEntryChange,
}: {
entry: HistoryEntry;
onClose: () => void;
onEntryChange?: (entry: HistoryEntry) => void;
}) {
const { lang, t } = useI18n();
const text = useMemo(() => transcriptToText(entry).trim(), [entry]);
const [settings, setSettings] = useState<AppSettings | null>(null);
const [promptId, setPromptId] = useState("");
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<SummarizeProgress | null>(null);
const [error, setError] = useState<string | null>(null);
const [summaries, setSummaries] = useState<SummaryEntry[]>(entry.summaries ?? []);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editText, setEditText] = useState("");
const [copiedId, setCopiedId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getSettings({ reload: true })
.then((next) => {
if (cancelled) return;
setSettings(next);
setPromptId((current) => {
if (current) return current;
const list = listPromptsByKind(next, "summary");
const preferred = list.find((p) => p.id === PREFERRED_DEFAULT_PROMPT);
return preferred?.id || next.defaultSummaryPromptId || list[0]?.id || "";
});
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const prompts = settings ? listPromptsByKind(settings, "summary") : [];
const selected = prompts.find((p) => p.id === promptId) ?? prompts[0];
const backend = settings ? resolveSummaryBackend(settings) : null;
const canRun = Boolean(settings && selected && text && backend && !running);
const formatTime = (iso: string): string => {
try {
return new Date(iso).toLocaleString(lang === "en" ? "en-US" : "ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
};
const durationLabel = (ms: number): string =>
ms < 1000
? t("mainTab.durationMs", { value: ms })
: t("mainTab.durationS", { value: (ms / 1000).toFixed(1) });
const progressLabel = (): string => {
if (!progress) return t("summary.progress.preparing");
if (progress.phase === "map") {
return t("summary.progress.map", { current: progress.current, total: progress.total });
}
if (progress.phase === "reduce") return t("summary.progress.reduce");
return t("summary.progress.preparing");
};
const progressPercent = (): number => {
if (!progress) return 8;
if (progress.phase === "map" && progress.total > 0) {
return Math.max(8, Math.min(90, Math.round((progress.current / progress.total) * 90)));
}
if (progress.phase === "reduce") return 95;
return 8;
};
const generate = async (): Promise<void> => {
if (!settings || !selected || !text) {
if (!text) setError(t("summary.empty.noText"));
return;
}
setRunning(true);
setError(null);
setProgress(null);
const startedAt = Date.now();
try {
const result = await summarizeTranscript({
text,
prompt: selected,
settings,
onProgress: setProgress,
});
const summary: SummaryEntry = {
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
durationMs: Date.now() - startedAt,
promptId: selected.id,
promptName: selected.name,
text: result.trim(),
};
const updated = await addSummaryToEntry(entry.id, summary);
setSummaries(updated?.summaries ?? [summary, ...summaries]);
if (updated) onEntryChange?.(updated);
setExpandedId(summary.id);
} catch (e) {
setError(formatErrorMessage(e));
} finally {
setRunning(false);
setProgress(null);
}
};
const copy = async (item: SummaryEntry): Promise<void> => {
try {
await navigator.clipboard.writeText(item.text);
setCopiedId(item.id);
window.setTimeout(() => setCopiedId((id) => (id === item.id ? null : id)), 1500);
} catch {
/* clipboard may be unavailable */
}
};
const startEdit = (item: SummaryEntry): void => {
setEditingId(item.id);
setEditText(item.text);
setExpandedId(item.id);
};
const saveEdit = async (item: SummaryEntry): Promise<void> => {
const updated = await updateSummaryInEntry(entry.id, item.id, editText);
setSummaries(updated?.summaries ?? summaries.map((s) => (s.id === item.id ? { ...s, text: editText } : s)));
if (updated) onEntryChange?.(updated);
setEditingId(null);
};
const remove = async (item: SummaryEntry): Promise<void> => {
const updated = await deleteSummaryFromEntry(entry.id, item.id);
setSummaries(updated?.summaries ?? summaries.filter((s) => s.id !== item.id));
if (updated) onEntryChange?.(updated);
};
const promptOptions = prompts.map((p) => ({ value: p.id, label: p.name }));
return (
<SlideOverModal onClose={onClose} title={t("summary.title")} width="100vw">
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Generate controls */}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ fontSize: 12, color: "var(--text-mid)" }}>{t("summary.type")}</div>
<div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<Dropdown value={selected?.id ?? ""} options={promptOptions} onChange={setPromptId} disabled={running || prompts.length === 0} />
</div>
<button
type="button"
className="btn"
onClick={() => void generate()}
disabled={!canRun}
style={{ minHeight: 38, padding: "0 18px", borderRadius: 8, justifyContent: "center", background: "var(--accent)", color: "var(--accent-contrast)", opacity: canRun ? 1 : 0.6, flexShrink: 0, whiteSpace: "nowrap", fontWeight: 600 }}
>
{running ? t("summary.generating") : summaries.length ? t("summary.regenerate") : t("summary.generate")}
</button>
</div>
{!backend && (
<div style={{ fontSize: 12, color: "var(--text-mid)", lineHeight: 1.5 }}>{t("summary.noModel")}</div>
)}
{running && (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<div style={{ fontSize: 12, color: "var(--text-mid)" }}>{progressLabel()}</div>
<div style={{ width: "100%", height: 8, borderRadius: 999, background: "var(--progress-track)", overflow: "hidden" }}>
<div style={{ width: `${progressPercent()}%`, height: "100%", borderRadius: 999, background: "var(--accent)", transition: "width 0.3s ease" }} />
</div>
</div>
)}
{error && (
<div style={{ fontSize: 12, color: "var(--danger)", lineHeight: 1.5 }}>{error}</div>
)}
</div>
{/* Summary history — same table as the main history list */}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "var(--text-hi)" }}>{t("summary.history.title")}</div>
{summaries.length === 0 ? (
<div style={{ fontSize: 12, color: "var(--text-low)", lineHeight: 1.6 }}>{t("summary.history.empty")}</div>
) : (
<table className="b-table" style={{ background: "transparent" }}>
<thead>
<tr>
<th style={{ width: 124 }}>{t("mainTab.colTime")}</th>
<th style={{ paddingLeft: 8 }}>{t("mainTab.colText")}</th>
</tr>
</thead>
<tbody>
{summaries.map((item, index) => {
const expanded = expandedId === item.id;
const isEditing = editingId === item.id;
const tooLong = item.text.length > TEXT_PREVIEW_LIMIT;
const visible = tooLong && !expanded ? `${item.text.slice(0, TEXT_PREVIEW_LIMIT).trimEnd()}...` : item.text;
return (
<tr
key={item.id}
style={{ borderBottom: index < summaries.length - 1 ? "1px solid var(--table-row-border)" : "none", cursor: "default" }}
>
<td style={{ verticalAlign: "top", color: "var(--text-low)" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<span style={{ whiteSpace: "nowrap" }}>{formatTime(item.createdAt)}</span>
<span style={{ fontSize: 10, opacity: 0.55, letterSpacing: "0.02em", whiteSpace: "nowrap" }}>{durationLabel(item.durationMs)}</span>
<span style={{ fontSize: 10, opacity: 0.55, letterSpacing: "0.02em", whiteSpace: "normal", wordBreak: "break-word", lineHeight: 1.35 }}>{item.promptName}</span>
</div>
</td>
<td style={{ verticalAlign: "top", paddingLeft: 8 }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0, display: "grid", gap: 8 }}>
{isEditing ? (
<>
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
className="input"
style={{ width: "100%", minHeight: 320, padding: 12, fontSize: 13, lineHeight: 1.6, resize: "vertical", fontFamily: "var(--font-main)" }}
/>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button type="button" className="btn" onClick={() => setEditingId(null)} style={{ minHeight: 32, padding: "0 12px" }}>{t("summary.cancel")}</button>
<button type="button" className="btn" onClick={() => void saveEdit(item)} style={{ minHeight: 32, padding: "0 14px", background: "var(--accent)", color: "var(--accent-contrast)" }}>{t("summary.save")}</button>
</div>
</>
) : (
<div style={{ display: "grid", gap: 2, color: "var(--text-mid)", lineHeight: 1.7, overflowWrap: "anywhere", wordBreak: "break-word" }}>
<span style={{ whiteSpace: "pre-wrap" }}>{visible}</span>
{tooLong && (
<button
type="button"
onClick={() => setExpandedId(expanded ? null : item.id)}
style={{ marginLeft: 0, padding: 0, border: "none", background: "transparent", color: "var(--text-hi)", fontSize: 13, fontWeight: 600, cursor: "pointer", justifySelf: "start" }}
>
{expanded ? t("mainTab.collapse") : t("mainTab.expand")}
</button>
)}
</div>
)}
</div>
<RowActionsMenu
label={t("summary.actions")}
items={[
{ key: "copy", label: copiedId === item.id ? t("summary.copied") : t("summary.copy"), icon: copiedId === item.id ? <Check size={14} strokeWidth={2.5} /> : <Clipboard size={14} strokeWidth={2} />, onSelect: () => void copy(item) },
{ key: "edit", label: t("summary.edit"), icon: <Pencil size={14} strokeWidth={2} />, onSelect: () => startEdit(item) },
{ key: "delete", label: t("summary.delete"), icon: <Trash2 size={14} strokeWidth={2} />, danger: true, onSelect: () => void remove(item) },
] as RowActionItem[]}
/>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
</SlideOverModal>
);
}

View file

@ -1,242 +0,0 @@
import { useEffect, useState } from "react";
import { Check, Clipboard, Loader2, Sparkles } from "lucide-react";
import {
getSettings,
listPromptsByKind,
type AppSettings,
} from "../lib/store";
import {
summarizeTranscript,
resolveSummaryBackend,
willUseMapReduce,
type SummarizeProgress,
} from "../lib/summarize";
import { formatErrorMessage } from "../lib/utils";
/**
* Post-transcription summary panel. Lets the user pick a prompt from the
* library and run it over the transcript through Talkis Cloud (Phase 1).
* Long transcripts are summarized with map-reduce inside `summarizeWithCloud`.
*/
export function SummaryPanel({ text }: { text: string }) {
const [settings, setSettings] = useState<AppSettings | null>(null);
const [promptId, setPromptId] = useState<string>("");
const [output, setOutput] = useState<string>("");
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState<SummarizeProgress | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
let cancelled = false;
getSettings({ reload: true })
.then((next) => {
if (cancelled) return;
setSettings(next);
setPromptId((current) => current || next.defaultSummaryPromptId);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const prompts = settings ? listPromptsByKind(settings, "summary") : [];
const selected =
prompts.find((preset) => preset.id === promptId) ?? prompts[0];
const backend = settings ? resolveSummaryBackend(settings) : null;
const canRun = Boolean(settings && selected && text.trim() && backend);
const run = async (): Promise<void> => {
if (!settings || !selected) return;
if (!text.trim()) {
setError("Нет текста для summary");
return;
}
setRunning(true);
setError(null);
setOutput("");
setProgress(null);
try {
const result = await summarizeTranscript({
text,
prompt: selected,
settings,
onProgress: setProgress,
});
setOutput(result.trim());
} catch (e) {
setError(formatErrorMessage(e));
} finally {
setRunning(false);
setProgress(null);
}
};
const copy = async (): Promise<void> => {
if (!output) return;
try {
await navigator.clipboard.writeText(output);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard may be unavailable; ignore */
}
};
const progressLabel = (): string => {
if (!progress) return "Готовим summary…";
if (progress.phase === "map") {
return `Обрабатываем часть ${progress.current} из ${progress.total}`;
}
if (progress.phase === "reduce") {
return "Объединяем части…";
}
return "Готовим summary…";
};
return (
<div
className="card"
style={{ padding: 14, display: "grid", gap: 12, marginTop: 12 }}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 14,
fontWeight: 700,
color: "var(--text-hi)",
}}
>
<Sparkles size={16} strokeWidth={1.9} />
Summary
</div>
<div
style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}
>
<select
value={selected?.id ?? ""}
onChange={(e) => setPromptId(e.target.value)}
disabled={running || prompts.length === 0}
style={{
flex: "1 1 200px",
minWidth: 160,
padding: "9px 12px",
borderRadius: 10,
border: "1px solid var(--border)",
background: "var(--control-bg)",
color: "var(--text-hi)",
fontSize: 13,
fontFamily: "var(--font-main)",
}}
>
{prompts.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
</option>
))}
</select>
<button
type="button"
className="btn"
onClick={() => void run()}
disabled={!canRun || running}
style={{ minHeight: 36, padding: "0 16px" }}
>
{running ? (
<Loader2
size={14}
strokeWidth={2.2}
style={{ animation: "spin 1s linear infinite" }}
/>
) : (
<Sparkles size={14} strokeWidth={2} />
)}
{running ? "Готовим…" : "Сделать summary"}
</button>
</div>
{!backend && (
<div style={{ fontSize: 12, color: "var(--text-mid)", lineHeight: 1.5 }}>
Для summary нужен вход в Talkis Cloud или указанная текстовая модель
(вкладка «Модели»). Встроенную локальную модель добавим следующими
шагами.
</div>
)}
{backend && backend.kind !== "cloud" && (
<div style={{ fontSize: 12, color: "var(--text-low)", lineHeight: 1.5 }}>
Модель: {backend.label}
</div>
)}
{willUseMapReduce(text) && (
<div style={{ fontSize: 12, color: "var(--text-low)", lineHeight: 1.5 }}>
Длинная расшифровка обработаем по частям и соберём общий результат.
</div>
)}
{running && (
<div style={{ fontSize: 12, color: "var(--text-mid)" }}>
{progressLabel()}
</div>
)}
{error && (
<div style={{ fontSize: 12, color: "var(--danger)", lineHeight: 1.5 }}>
{error}
</div>
)}
{output && (
<div style={{ display: "grid", gap: 8 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<div className="label">Результат summary</div>
<button
type="button"
className="btn"
onClick={() => void copy()}
style={{ minHeight: 30, padding: "0 12px" }}
>
{copied ? (
<Check size={13} strokeWidth={2.2} />
) : (
<Clipboard size={13} strokeWidth={2} />
)}
{copied ? "Скопировано" : "Скопировать"}
</button>
</div>
<div
style={{
fontSize: 13,
color: "var(--text-hi)",
lineHeight: 1.7,
whiteSpace: "pre-wrap",
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: 10,
padding: 14,
maxHeight: 320,
overflow: "auto",
}}
>
{output}
</div>
</div>
)}
</div>
);
}

View file

@ -1,5 +1,7 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useI18n } from "../lib/i18n";
function TrafficLight({ color, onClick, title }: { color: string; onClick: () => void; title: string }) {
return (
<button
@ -24,6 +26,7 @@ function TrafficLight({ color, onClick, title }: { color: string; onClick: () =>
}
export function TitleBar() {
const { t } = useI18n();
const win = getCurrentWindow();
return (
@ -55,9 +58,9 @@ export function TitleBar() {
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, width: 56 }}>
<TrafficLight color="#ff5f57" title="Закрыть" onClick={() => win.close()} />
<TrafficLight color="#febc2e" title="Свернуть" onClick={() => win.minimize()} />
<TrafficLight color="#28c840" title="Развернуть" onClick={() => win.toggleMaximize()} />
<TrafficLight color="#ff5f57" title={t("titleBar.close")} onClick={() => win.close()} />
<TrafficLight color="#febc2e" title={t("titleBar.minimize")} onClick={() => win.minimize()} />
<TrafficLight color="#28c840" title={t("titleBar.maximize")} onClick={() => win.toggleMaximize()} />
</div>
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--titlebar-text)", letterSpacing: "-0.02em" }}>

View file

@ -9,6 +9,9 @@ import {
STATS_UPDATED_EVENT,
type TranscriptionStatsView,
} from "../lib/stats";
import { useI18n } from "../lib/i18n";
type TranslateFn = ReturnType<typeof useI18n>["t"];
function compact(scaled: number, suffix: string): string {
// One decimal below 100 (12,5к), whole numbers above (123к); comma decimal.
@ -20,14 +23,14 @@ function compact(scaled: number, suffix: string): string {
}
/** Full number up to 10k, then abbreviated: 200000 → "200к", 1 200 000 → "1,2М". */
function formatStatValue(value: number): string {
function formatStatValue(value: number, t: TranslateFn): string {
if (value < 10_000) {
return String(value);
}
if (value < 1_000_000) {
return compact(value / 1000, "к");
return compact(value / 1000, t("stats.suffix.thousand"));
}
return compact(value / 1_000_000, "М");
return compact(value / 1_000_000, t("stats.suffix.million"));
}
interface StatItem {
@ -36,27 +39,28 @@ interface StatItem {
value: string;
}
function buildStats(view: TranscriptionStatsView): StatItem[] {
function buildStats(view: TranscriptionStatsView, t: TranslateFn): StatItem[] {
return [
{
key: "month",
label: "Слов за месяц",
value: formatStatValue(view.monthWords),
label: t("stats.wordsThisMonth"),
value: formatStatValue(view.monthWords, t),
},
{
key: "all",
label: "Слов всего",
value: formatStatValue(view.allTimeWords),
label: t("stats.wordsTotal"),
value: formatStatValue(view.allTimeWords, t),
},
{
key: "speed",
label: "Слов в минуту",
value: view.hasSpeed ? formatStatValue(view.averageWpm) : "—",
label: t("stats.wordsPerMinute"),
value: view.hasSpeed ? formatStatValue(view.averageWpm, t) : "—",
},
];
}
export function TranscriptionStatsPanel(): ReactElement {
const { t } = useI18n();
const [view, setView] = useState<TranscriptionStatsView>(() => getEmptyView());
useEffect(() => {
@ -87,7 +91,7 @@ export function TranscriptionStatsPanel(): ReactElement {
};
}, []);
const stats = buildStats(view);
const stats = buildStats(view, t);
return (
<section

View file

@ -7,6 +7,7 @@ import { LogOut, User, Crown } from "lucide-react";
import { CloudProfile, fetchCloudProfile, cloudLogout, getAuthLoginUrl, handleAuthToken, generateExchangeCode, getAuthLoginUrlWithCode, pollForToken, getCachedCloudProfile, subscribeCloudProfile } from "../lib/cloudAuth";
import { logError, logInfo } from "../lib/logger";
import { SETTINGS_UPDATED_EVENT } from "../lib/hotkeyEvents";
import { useI18n } from "../lib/i18n";
/** Extract token from talkis://auth?token=... */
function extractTokenFromUrl(url: string): string | null {
@ -19,6 +20,7 @@ function extractTokenFromUrl(url: string): string | null {
}
export function UserPanel() {
const { t } = useI18n();
const [profile, setProfile] = useState<CloudProfile | null | undefined>(() => getCachedCloudProfile());
const [loading, setLoading] = useState(() => getCachedCloudProfile() === undefined);
const [waitingForAuth, setWaitingForAuth] = useState(false);
@ -188,7 +190,7 @@ export function UserPanel() {
<ProfileRow profile={profile} onLogout={handleLogout} />
<div style={styles.badgeActive}>
<div style={styles.badgeDot} />
Подписка активна
{t("userPanel.subscriptionActive")}
</div>
</div>
);
@ -201,7 +203,7 @@ export function UserPanel() {
<ProfileRow profile={profile} onLogout={handleLogout} />
<button onClick={handleActivate} style={styles.compactCta}>
<Crown size={13} strokeWidth={2} color="var(--accent-contrast)" />
<span style={styles.compactCtaLabel}>Перейти на PRO</span>
<span style={styles.compactCtaLabel}>{t("userPanel.upgradeToPro")}</span>
</button>
</div>
);
@ -216,6 +218,7 @@ export function UserPanel() {
}
function ProfileRow({ profile, onLogout }: { profile: CloudProfile; onLogout: () => void }) {
const { t } = useI18n();
return (
<div style={styles.profileRow}>
<div style={styles.avatar}>
@ -235,7 +238,7 @@ function ProfileRow({ profile, onLogout }: { profile: CloudProfile; onLogout: ()
</div>
<div style={styles.profileEmail}>{profile.user.email}</div>
</div>
<button onClick={onLogout} style={styles.logoutButton} title="Выйти">
<button onClick={onLogout} style={styles.logoutButton} title={t("userPanel.logout")}>
<LogOut size={14} strokeWidth={1.8} />
</button>
</div>
@ -243,23 +246,24 @@ function ProfileRow({ profile, onLogout }: { profile: CloudProfile; onLogout: ()
}
function SubscriptionCTA({ onActivate }: { onActivate: () => void }) {
const { t } = useI18n();
return (
<div style={styles.ctaBox}>
<div style={styles.ctaHeader}>
<Crown size={14} strokeWidth={2} color="var(--text-hi)" />
<span style={{ fontWeight: 700, fontSize: 12, letterSpacing: "-0.02em", color: "var(--text-hi)" }}>
Активируйте Talkis
{t("userPanel.cta.title")}
</span>
</div>
<ul style={styles.ctaList}>
<li>Безлимитное использование</li>
<li>Без VPN и Прокси</li>
<li>Синхронизация устройств</li>
<li>{t("userPanel.cta.feature.unlimited")}</li>
<li>{t("userPanel.cta.feature.noVpn")}</li>
<li>{t("userPanel.cta.feature.deviceSync")}</li>
</ul>
<button onClick={onActivate} style={styles.ctaButton}>
Перейти на PRO
{t("userPanel.upgradeToPro")}
</button>
</div>
);

View file

@ -4,13 +4,14 @@ import { emit } from "@tauri-apps/api/event";
import { HISTORY_UPDATED_EVENT } from "./hotkeyEvents";
import { logError, logInfo } from "./logger";
import { beginProcessing, finishProcessing } from "./processingControl";
import { tn } from "./i18n";
import {
addHistoryEntry,
type AppSettings,
type HistoryEntry,
} from "./store";
const CALL_INTERRUPTED_MESSAGE = "Обработка остановлена. Можно запустить повторно.";
const callInterruptedMessage = (): string => tn("callCapture.interrupted");
import {
type FileTranscriptionResult,
transcribeFilePathOnly,
@ -128,7 +129,7 @@ export async function saveFailedCallCaptureEntry({
raw: "",
cleaned: "",
source: "call",
fileName: "Созвон",
fileName: tn("callCapture.fileName"),
status: "failed",
errorMessage,
processingTime: startedAt ? Date.now() - startedAt : undefined,
@ -146,7 +147,7 @@ export async function saveFailedCallCaptureEntry({
}
function callTrackTitle(track: CallCaptureTrack): string {
return track.kind === "mic" ? "Вы" : "Созвон";
return track.kind === "mic" ? tn("callCapture.speakerYou") : tn("callCapture.speakerCall");
}
function formatTrackTranscript(track: CallCaptureTrack, text: string): string {
@ -154,7 +155,8 @@ function formatTrackTranscript(track: CallCaptureTrack, text: string): string {
}
function micPlainText(part: string): string {
return part.replace(/^Вы:\s*/i, "").trim();
const label = tn("callCapture.speakerYou").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return part.replace(new RegExp(`^${label}:\\s*`, "i"), "").trim();
}
function errorMessage(error: unknown): string {
@ -214,22 +216,22 @@ function normalizeCallSpeakerResult(
orderedSpeakerIds(result).forEach((speakerId) => {
if (speakerId === firstMicSpeakerId) {
labelsById.set(speakerId, "Вы");
labelsById.set(speakerId, tn("callCapture.speakerYou"));
return;
}
labelsById.set(speakerId, `Гость ${guestIndex}`);
labelsById.set(speakerId, tn("callCapture.speakerGuestN", { index: guestIndex }));
guestIndex += 1;
});
const speakers = orderedSpeakerIds(result).map((speakerId) => ({
id: speakerId,
label: labelsById.get(speakerId) || "Гость 1",
label: labelsById.get(speakerId) || tn("callCapture.speakerGuestN", { index: 1 }),
}));
const segments = result.segments.map((segment) => ({
...segment,
speakerLabel:
labelsById.get(segment.speakerId) || segment.speakerLabel || "Гость 1",
labelsById.get(segment.speakerId) || segment.speakerLabel || tn("callCapture.speakerGuestN", { index: 1 }),
}));
return {
@ -308,10 +310,10 @@ async function buildCallCaptureHistoryEntry({
settings,
onStatus,
});
micPlainPart = `Вы:\n${micResult.text.trim()}`;
micPlainPart = `${tn("callCapture.speakerYou")}:\n${micResult.text.trim()}`;
} catch (error) {
const message = errorMessage(error);
failedTracks.push(`микрофон: ${message}`);
failedTracks.push(`${tn("callCapture.trackMic")}: ${message}`);
void logError(
"CALL_CAPTURE",
`Mic track transcription failed: ${message}`,
@ -351,7 +353,7 @@ async function buildCallCaptureHistoryEntry({
}
if (shouldDiarizeSystemTrack) {
throw new Error("Разделение говорящих не вернуло сегменты.");
throw new Error(tn("callCapture.errDiarizationNoSegments"));
}
if (track.kind === "mic") {
@ -400,7 +402,7 @@ async function buildCallCaptureHistoryEntry({
}
} catch (error) {
const message = errorMessage(error);
failedTracks.push(`микрофон: ${message}`);
failedTracks.push(`${tn("callCapture.trackMic")}: ${message}`);
void logError(
"CALL_CAPTURE",
`Mic track diarization fallback failed: ${message}`,
@ -418,12 +420,12 @@ async function buildCallCaptureHistoryEntry({
if (speakerSegments.length > 0) {
const selfSpeakerId = "call_self";
speakersById.set(selfSpeakerId, { id: selfSpeakerId, label: "Вы" });
speakersById.set(selfSpeakerId, { id: selfSpeakerId, label: tn("callCapture.speakerYou") });
speakerSegments.unshift({
start: 0,
end: 0,
speakerId: selfSpeakerId,
speakerLabel: "Вы",
speakerLabel: tn("callCapture.speakerYou"),
text: micPlainText(micPlainPart),
});
}
@ -437,7 +439,7 @@ async function buildCallCaptureHistoryEntry({
throw new Error(
failedTracks.length > 0
? failedTracks.join("; ")
: "В созвоне не удалось распознать речь.",
: tn("callCapture.errNoSpeech"),
);
}
@ -448,15 +450,16 @@ async function buildCallCaptureHistoryEntry({
raw: text,
cleaned: text,
source: "call",
fileName: "Созвон",
fileName: tn("callCapture.fileName"),
status: "completed",
processingTime: startedAt ? Date.now() - startedAt : undefined,
mode,
speakers:
speakersById.size > 0
? Array.from(speakersById.values()).sort((left, right) => {
if (left.label === "Вы") return -1;
if (right.label === "Вы") return 1;
const youLabel = tn("callCapture.speakerYou");
if (left.label === youLabel) return -1;
if (right.label === youLabel) return 1;
return 0;
})
: undefined,
@ -490,7 +493,7 @@ export async function transcribeCallCaptureSession(
raw: "",
cleaned: "",
source: "call",
fileName: "Созвон",
fileName: tn("callCapture.fileName"),
status: "processing",
callSessionId: params.session.id,
callTracks: params.session.tracks.map((track) => ({
@ -503,7 +506,7 @@ export async function transcribeCallCaptureSession(
const interruptedEntry = (): HistoryEntry => ({
...baseEntry,
status: "interrupted",
errorMessage: CALL_INTERRUPTED_MESSAGE,
errorMessage: callInterruptedMessage(),
});
const handle = await beginProcessing(baseEntry, "add");
@ -533,7 +536,7 @@ export async function transcribeCallCaptureSession(
const failed: HistoryEntry = {
...baseEntry,
status: "failed",
errorMessage: "Не удалось обработать запись. Попробуйте повторить попытку.",
errorMessage: tn("callCapture.errProcessRetry"),
};
await finishProcessing(failed);
return failed;
@ -565,7 +568,7 @@ export async function retryCallCaptureHistoryEntry(
settings: AppSettings,
): Promise<HistoryEntry> {
if (!entry.callTracks?.length) {
throw new Error("У этой записи нет сохраненных дорожек созвона для повторной обработки.");
throw new Error(tn("callCapture.errNoSavedTracks"));
}
const session = sessionFromHistoryEntry(entry);
@ -589,7 +592,7 @@ export async function retryCallCaptureHistoryEntry(
const interrupted: HistoryEntry = {
...entry,
status: "interrupted",
errorMessage: CALL_INTERRUPTED_MESSAGE,
errorMessage: callInterruptedMessage(),
};
await finishProcessing(interrupted);
return interrupted;
@ -602,14 +605,13 @@ export async function retryCallCaptureHistoryEntry(
const interrupted: HistoryEntry = {
...entry,
status: "interrupted",
errorMessage: CALL_INTERRUPTED_MESSAGE,
errorMessage: callInterruptedMessage(),
};
await finishProcessing(interrupted);
return interrupted;
}
const userFacingMessage =
"Не удалось обработать запись. Попробуйте повторить попытку.";
const userFacingMessage = tn("callCapture.errProcessRetry");
const failedEntry: HistoryEntry = {
...entry,
status: "failed",

View file

@ -6,8 +6,9 @@ import { fetchCloudProfile } from "./cloudAuth";
import { logError, logInfo } from "./logger";
import { beginProcessing, finishProcessing } from "./processingControl";
import { formatErrorMessage } from "./utils";
import { tn } from "./i18n";
const FILE_INTERRUPTED_MESSAGE = "Обработка остановлена. Можно запустить повторно.";
const fileInterruptedMessage = (): string => tn("fileTranscription.interrupted");
const PROXY_BASE_URL = "https://proxy.talkis.ru";
const TRANSCRIPTION_MAX_BYTES = 25 * 1024 * 1024;
@ -128,7 +129,7 @@ function fileExtension(fileName: string): string {
export function fileNameFromPath(filePath: string): string {
const normalized = filePath.replace(/\\/g, "/");
const name = normalized.split("/").filter(Boolean).pop();
return name || "Файл";
return name || tn("fileTranscription.fallbackFileName");
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
@ -276,18 +277,18 @@ function getDiarizedWhisperModel(settings: AppSettings): string {
export function formatFileSize(bytes: number): string {
if (bytes <= 0) {
return "0 Б";
return tn("fileTranscription.sizeBytes", { value: 0 });
}
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
return tn("fileTranscription.sizeMb", { value: (bytes / 1024 / 1024).toFixed(1) });
}
if (bytes >= 1024) {
return `${Math.round(bytes / 1024)} КБ`;
return tn("fileTranscription.sizeKb", { value: Math.round(bytes / 1024) });
}
return `${bytes} Б`;
return tn("fileTranscription.sizeBytes", { value: bytes });
}
export function getFileTranscriptionPercent(
@ -319,11 +320,11 @@ export function toFileTranscriptionErrorMessage(error: unknown): string {
const normalized = raw.toLowerCase();
if (normalized.includes("ffmpeg") || normalized.includes("медиаконвертер")) {
return "Для этого файла нужно извлечь и сжать аудио, но встроенный медиаконвертер недоступен. Попробуйте поддерживаемый файл до 25 МБ или переустановите приложение.";
return tn("fileTranscription.errMediaConverterUnavailable");
}
if (normalized.includes("больше 25") || normalized.includes("too large")) {
return "Файл слишком большой для транскрибации. Попробуйте более короткий фрагмент.";
return tn("fileTranscription.errTooLargeShorter");
}
if (
@ -332,53 +333,53 @@ export function toFileTranscriptionErrorMessage(error: unknown): string {
normalized.includes("8 гб") ||
normalized.includes("8 gb")
) {
return "Файл слишком большой для транскрибации. Максимальный размер: 8 ГБ.";
return tn("fileTranscription.errTooLargeMax8gb");
}
if (normalized.includes("unsupported") || normalized.includes("не удалось извлечь аудио")) {
return "Не удалось прочитать аудио из этого файла. Попробуйте MP3, WAV, M4A, MP4 или MOV.";
return tn("fileTranscription.errCannotReadAudio");
}
if (normalized.includes("talkis cloud session missing")) {
return "Войдите в Talkis Cloud заново, чтобы использовать облачный режим.";
return tn("fileTranscription.errCloudSessionMissing");
}
if (normalized.includes("speaker diarization is not configured")) {
return "Облачная разметка говорящих пока недоступна. Используйте локальную подготовку в блоке транскрибации файла.";
return tn("fileTranscription.errCloudDiarizationNotConfigured");
}
if (normalized.includes("cloud speaker diarization unavailable")) {
return "Облачное разделение по говорящим сейчас недоступно. Проверьте активную подписку PRO или переключитесь на локальный режим.";
return tn("fileTranscription.errCloudDiarizationUnavailable");
}
if (normalized.includes("subscription inactive") || normalized.includes("403")) {
return "Для облачной транскрибации нужна активная подписка Talkis.";
return tn("fileTranscription.errSubscriptionInactive");
}
if (normalized.includes("не удалось подготовить аудио для разделения говорящих")) {
return "Не удалось подготовить аудио для разметки говорящих. Попробуйте другой аудио- или видеофайл.";
return tn("fileTranscription.errCannotPrepareDiarization");
}
if (normalized.includes("таймкод")) {
return "Для разделения по говорящим нужна локальная Whisper-модель с таймкодами.";
return tn("fileTranscription.errNeedTimestampModel");
}
if (
normalized.includes("разделения говорящих ещё не скачана")
|| normalized.includes("sherpa-diarization-pyannote-titanet-int8") && normalized.includes("ещё не скачана")
) {
return "Для разделения по говорящим скачайте локальные компоненты в блоке транскрибации файла.";
return tn("fileTranscription.errDownloadDiarizationComponents");
}
if (
normalized.includes("sherpa-onnx установлен")
&& (normalized.includes("diarization binary") || normalized.includes("binary для разметки говорящих"))
) {
return "Runtime для разметки установился не полностью. Нажмите «Скачать» в подготовке разметки, чтобы Talkis восстановил его.";
return tn("fileTranscription.errRuntimeIncomplete");
}
if (normalized.includes("sherpa-onnx diarization не вернул сегменты")) {
return "Не удалось найти отдельные реплики говорящих в этом файле.";
return tn("fileTranscription.errNoSpeakerSegments");
}
if (normalized.includes("sherpa-onnx diarization завершился с ошибкой")) {
@ -386,22 +387,32 @@ export function toFileTranscriptionErrorMessage(error: unknown): string {
}
if (normalized.includes("not installed locally") || normalized.includes("ещё не скачана")) {
return "Локальная модель распознавания не установлена. Откройте Настройки -> Модели -> Локально и нажмите «Скачать» для выбранной модели.";
return tn("fileTranscription.errLocalModelNotInstalled");
}
if (normalized.includes("401") || normalized.includes("unauthorized") || normalized.includes("invalid api key")) {
return "Не удалось авторизоваться в API. Проверьте ключ доступа.";
return tn("fileTranscription.errAuthFailed");
}
if (normalized.includes("429") || normalized.includes("rate limit") || normalized.includes("quota")) {
return "Превышен лимит запросов или закончилась квота API. Попробуйте позже.";
return tn("fileTranscription.errRateLimit");
}
if (
normalized.includes("proxy diarized")
|| normalized.includes("502")
|| normalized.includes("503")
|| normalized.includes("504")
|| normalized.includes("gateway")
) {
return tn("fileTranscription.errCloudDiarizationTimeout");
}
if (normalized.includes("network") || normalized.includes("fetch") || normalized.includes("timed out")) {
return "Не удалось связаться с сервером. Проверьте интернет и попробуйте снова.";
return tn("fileTranscription.errNetwork");
}
return "Не удалось транскрибировать файл. Попробуйте другой формат или более короткий фрагмент.";
return tn("fileTranscription.errGeneric");
}
async function prepareFile(
@ -409,11 +420,11 @@ async function prepareFile(
onStatus?: (status: FileTranscriptionStatus) => void,
): Promise<PreparedTranscriptionFile> {
if (file.size <= 0) {
throw new Error("Пустой файл нельзя транскрибировать.");
throw new Error(tn("fileTranscription.errEmptyFile"));
}
if (file.size > INPUT_MAX_BYTES) {
throw new Error("Файл слишком большой. Максимум для подготовки в приложении: 200 МБ.");
throw new Error(tn("fileTranscription.errTooLargeForPrep"));
}
onStatus?.("reading");
@ -543,7 +554,7 @@ export async function transcribeFileOnly({
const text = (result.raw || result.cleaned).trim();
if (!text) {
throw new Error("В файле не удалось распознать речь.");
throw new Error(tn("fileTranscription.errNoSpeech"));
}
return {
@ -594,7 +605,7 @@ export async function transcribeFilePathOnly({
status: "preparing",
currentChunk: 0,
totalChunks: 0,
message: "Готовим файл",
message: tn("fileTranscription.statusPreparing"),
});
logInfo("FILE_TRANSCRIPTION", `Sending file path ${fileName} through native pipeline`);
@ -625,7 +636,7 @@ export async function transcribeFilePathOnly({
const text = (result.raw || result.cleaned).trim();
if (!text) {
throw new Error("В файле не удалось распознать речь.");
throw new Error(tn("fileTranscription.errNoSpeech"));
}
return {
@ -652,7 +663,7 @@ export async function retryFileHistoryEntry(
settings: AppSettings,
): Promise<HistoryEntry> {
if (!entry.filePath) {
throw new Error("У этой записи нет сохраненного файла для повторной обработки.");
throw new Error(tn("fileTranscription.errNoSavedFile"));
}
const startedAt = Date.now();
@ -669,7 +680,7 @@ export async function retryFileHistoryEntry(
const interrupted: HistoryEntry = {
...entry,
status: "interrupted",
errorMessage: FILE_INTERRUPTED_MESSAGE,
errorMessage: fileInterruptedMessage(),
};
await finishProcessing(interrupted);
return interrupted;
@ -693,7 +704,7 @@ export async function retryFileHistoryEntry(
const interrupted: HistoryEntry = {
...entry,
status: "interrupted",
errorMessage: FILE_INTERRUPTED_MESSAGE,
errorMessage: fileInterruptedMessage(),
};
await finishProcessing(interrupted);
return interrupted;
@ -704,7 +715,7 @@ export async function retryFileHistoryEntry(
const failed: HistoryEntry = {
...entry,
status: "failed",
errorMessage: "Не удалось обработать файл. Попробуйте повторить попытку.",
errorMessage: tn("fileTranscription.errProcessFileRetry"),
};
await finishProcessing(failed);
throw new Error(failed.errorMessage);

View file

@ -0,0 +1,11 @@
// Shared UI strings used across multiple windows/components.
// Each entry carries both languages so ru/en can never drift apart.
export const common = {
"common.save": { ru: "Сохранить", en: "Save" },
"common.cancel": { ru: "Отмена", en: "Cancel" },
"common.delete": { ru: "Удалить", en: "Delete" },
"common.change": { ru: "Изменить", en: "Change" },
"common.default": { ru: "По умолчанию", en: "Default" },
"common.download": { ru: "Скачать", en: "Download" },
"common.notFound": { ru: "Не найдено", en: "Not found" },
} as const;

View file

@ -0,0 +1,148 @@
// i18n strings for src/components: PermissionScreen, TranscriptionStatsPanel, TitleBar, UserPanel.
export const components = {
// ── PermissionScreen ──────────────────────────────────────
// PermissionRow status badges (not granted yet)
"permission.badge.check": { ru: "Проверьте", en: "Check" },
"permission.badge.actionNeeded": { ru: "Нужно действие", en: "Action needed" },
"permission.badge.notGranted": { ru: "Не выдано", en: "Not granted" },
// PermissionRow action button
"permission.action.check": { ru: "Проверить", en: "Check" },
"permission.action.retry": { ru: "Повторить", en: "Retry" },
"permission.action.allow": { ru: "Разрешить", en: "Allow" },
// Microphone help text per platform
"permission.micHelp.macos": {
ru: "Если микрофон был отклонен, откройте Системные настройки -> Конфиденциальность -> Микрофон и включите Talkis.",
en: "If the microphone was denied, open System Settings -> Privacy -> Microphone and enable Talkis.",
},
"permission.micHelp.windows": {
ru: "Если микрофон был отклонен, откройте Параметры -> Конфиденциальность и безопасность -> Микрофон и разрешите доступ для Talkis.",
en: "If the microphone was denied, open Settings -> Privacy & security -> Microphone and allow access for Talkis.",
},
"permission.micHelp.linux": {
ru: "Если микрофон недоступен, проверьте системные настройки звука и разрешения браузерного WebView для записи.",
en: "If the microphone is unavailable, check your system sound settings and the WebView recording permissions.",
},
"permission.micHelp.default": {
ru: "Если микрофон был отклонен, откройте системные настройки приватности и разрешите доступ для Talkis.",
en: "If the microphone was denied, open your system privacy settings and allow access for Talkis.",
},
// Paste / accessibility permission row
"permission.paste.titleAccessibility": { ru: "Универсальный доступ", en: "Accessibility" },
"permission.paste.titlePaste": { ru: "Вставка текста", en: "Text insertion" },
"permission.paste.descAccessibility": {
ru: "Нужен для глобальной горячей клавиши и вставки текста.",
en: "Required for the global hotkey and text insertion.",
},
"permission.paste.descLinux": {
ru: "Talkis использует буфер обмена и Ctrl+V. В некоторых Wayland/X11 окружениях автоматическая вставка может быть ограничена.",
en: "Talkis uses the clipboard and Ctrl+V. In some Wayland/X11 environments automatic insertion may be limited.",
},
"permission.paste.descDefault": {
ru: "Talkis использует буфер обмена и Ctrl+V. Отдельное системное разрешение обычно не требуется.",
en: "Talkis uses the clipboard and Ctrl+V. A separate system permission is usually not required.",
},
"permission.paste.helpNotInApplications": {
ru: "Приложение запущено не из Applications: {path}",
en: "The app is not running from Applications: {path}",
},
"permission.paste.helpLinux": {
ru: "Если вставка не сработает, скопированный текст останется в буфере обмена и его можно вставить вручную.",
en: "If insertion fails, the copied text stays on the clipboard and you can paste it manually.",
},
// Header
"permission.header.title": { ru: "Доступы для Talkis", en: "Permissions for Talkis" },
"permission.header.subtitle": {
ru: "Осталось выдать системные разрешения для записи голоса, звука созвона и работы горячей клавиши.",
en: "Just grant the system permissions for recording your voice, call audio and the hotkey.",
},
// Install warning
"permission.installWarning.title": {
ru: "Переместите приложение в Applications",
en: "Move the app to Applications",
},
"permission.installWarning.desc": {
ru: "Текущая сборка запущена из временного места. Переместите Talkis в Applications и откройте оттуда.",
en: "The current build is running from a temporary location. Move Talkis to Applications and open it from there.",
},
// Microphone permission row
"permission.mic.title": { ru: "Микрофон", en: "Microphone" },
"permission.mic.desc": {
ru: "Нужен для записи голоса перед отправкой на распознавание.",
en: "Needed to record your voice before sending it for recognition.",
},
// System audio permission row
"permission.systemAudio.title": { ru: "Звук системы", en: "System audio" },
"permission.systemAudio.desc": {
ru: "Нужен, чтобы слушать звук созвона вместе с микрофоном.",
en: "Needed to capture call audio together with the microphone.",
},
"permission.systemAudio.help": {
ru: "После нажатия macOS может попросить разрешить Talkis запись системного аудио.",
en: "After clicking, macOS may ask you to allow Talkis to record system audio.",
},
// Hint block
"permission.hint.systemAudioDenied": {
ru: "Если доступ был отклонен, откройте Системные настройки -> Конфиденциальность и безопасность -> Запись экрана и системного аудио и включите Talkis.",
en: "If access was denied, open System Settings -> Privacy & security -> Screen & System Audio Recording and enable Talkis.",
},
"permission.hint.reopenAfterMove": {
ru: "После перемещения приложения в Applications откройте его заново.",
en: "After moving the app to Applications, open it again.",
},
"permission.hint.macosDelay": {
ru: "macOS применяет доступ не мгновенно. После изменения настройки вернитесь в приложение.",
en: "macOS does not apply access instantly. After changing the setting, return to the app.",
},
// Footer status line
"permission.footer.launchFromApplications": {
ru: "Сначала запустите из Applications.",
en: "Launch from Applications first.",
},
"permission.footer.allGranted": { ru: "Все доступы выданы.", en: "All permissions granted." },
"permission.footer.grantToContinue": {
ru: "Выдайте разрешения для продолжения.",
en: "Grant the permissions to continue.",
},
"permission.footer.grantBothToContinue": {
ru: "Выдайте оба разрешения для продолжения.",
en: "Grant both permissions to continue.",
},
"permission.footer.grantMicToContinue": {
ru: "Разрешите микрофон для продолжения.",
en: "Allow the microphone to continue.",
},
// Footer button
"permission.button.continue": { ru: "Продолжить", en: "Continue" },
"permission.button.check": { ru: "Проверить", en: "Check" },
// ── TranscriptionStatsPanel ───────────────────────────────
"stats.suffix.thousand": { ru: "к", en: "k" },
"stats.suffix.million": { ru: "М", en: "M" },
"stats.wordsThisMonth": { ru: "Слов за месяц", en: "Words this month" },
"stats.wordsTotal": { ru: "Слов всего", en: "Words total" },
"stats.wordsPerMinute": { ru: "Слов в минуту", en: "Words per minute" },
// ── TitleBar ──────────────────────────────────────────────
"titleBar.close": { ru: "Закрыть", en: "Close" },
"titleBar.minimize": { ru: "Свернуть", en: "Minimize" },
"titleBar.maximize": { ru: "Развернуть", en: "Maximize" },
// ── UserPanel ─────────────────────────────────────────────
"userPanel.subscriptionActive": { ru: "Подписка активна", en: "Subscription active" },
"userPanel.upgradeToPro": { ru: "Перейти на PRO", en: "Upgrade to PRO" },
"userPanel.logout": { ru: "Выйти", en: "Log out" },
"userPanel.cta.title": { ru: "Активируйте Talkis", en: "Activate Talkis" },
"userPanel.cta.feature.unlimited": { ru: "Безлимитное использование", en: "Unlimited usage" },
"userPanel.cta.feature.noVpn": { ru: "Без VPN и Прокси", en: "No VPN or proxy" },
"userPanel.cta.feature.deviceSync": { ru: "Синхронизация устройств", en: "Device sync" },
} as const;

View file

@ -0,0 +1,185 @@
export const libMessages = {
// ── fileTranscription.ts ──────────────────────────────────────────────────
"fileTranscription.interrupted": {
ru: "Обработка остановлена. Можно запустить повторно.",
en: "Processing stopped. You can run it again.",
},
"fileTranscription.fallbackFileName": {
ru: "Файл",
en: "File",
},
"fileTranscription.statusPreparing": {
ru: "Готовим файл",
en: "Preparing file",
},
"fileTranscription.sizeBytes": {
ru: "{value} Б",
en: "{value} B",
},
"fileTranscription.sizeKb": {
ru: "{value} КБ",
en: "{value} KB",
},
"fileTranscription.sizeMb": {
ru: "{value} МБ",
en: "{value} MB",
},
"fileTranscription.errEmptyFile": {
ru: "Пустой файл нельзя транскрибировать.",
en: "An empty file cannot be transcribed.",
},
"fileTranscription.errTooLargeForPrep": {
ru: "Файл слишком большой. Максимум для подготовки в приложении: 200 МБ.",
en: "The file is too large. The maximum for in-app preparation is 200 MB.",
},
"fileTranscription.errNoSpeech": {
ru: "В файле не удалось распознать речь.",
en: "No speech could be recognized in the file.",
},
"fileTranscription.errNoSavedFile": {
ru: "У этой записи нет сохраненного файла для повторной обработки.",
en: "This entry has no saved file to re-process.",
},
"fileTranscription.errProcessFileRetry": {
ru: "Не удалось обработать файл. Попробуйте повторить попытку.",
en: "Could not process the file. Please try again.",
},
"fileTranscription.errMediaConverterUnavailable": {
ru: "Для этого файла нужно извлечь и сжать аудио, но встроенный медиаконвертер недоступен. Попробуйте поддерживаемый файл до 25 МБ или переустановите приложение.",
en: "This file needs its audio extracted and compressed, but the built-in media converter is unavailable. Try a supported file up to 25 MB or reinstall the app.",
},
"fileTranscription.errTooLargeShorter": {
ru: "Файл слишком большой для транскрибации. Попробуйте более короткий фрагмент.",
en: "The file is too large to transcribe. Try a shorter fragment.",
},
"fileTranscription.errTooLargeMax8gb": {
ru: "Файл слишком большой для транскрибации. Максимальный размер: 8 ГБ.",
en: "The file is too large to transcribe. Maximum size: 8 GB.",
},
"fileTranscription.errCannotReadAudio": {
ru: "Не удалось прочитать аудио из этого файла. Попробуйте MP3, WAV, M4A, MP4 или MOV.",
en: "Could not read audio from this file. Try MP3, WAV, M4A, MP4, or MOV.",
},
"fileTranscription.errCloudSessionMissing": {
ru: "Войдите в Talkis Cloud заново, чтобы использовать облачный режим.",
en: "Sign in to Talkis Cloud again to use cloud mode.",
},
"fileTranscription.errCloudDiarizationNotConfigured": {
ru: "Облачная разметка говорящих пока недоступна. Используйте локальную подготовку в блоке транскрибации файла.",
en: "Cloud speaker labeling is not available yet. Use local preparation in the file transcription section.",
},
"fileTranscription.errCloudDiarizationUnavailable": {
ru: "Облачное разделение по говорящим сейчас недоступно. Проверьте активную подписку PRO или переключитесь на локальный режим.",
en: "Cloud speaker separation is currently unavailable. Check your active PRO subscription or switch to local mode.",
},
"fileTranscription.errSubscriptionInactive": {
ru: "Для облачной транскрибации нужна активная подписка Talkis.",
en: "Cloud transcription requires an active Talkis subscription.",
},
"fileTranscription.errCannotPrepareDiarization": {
ru: "Не удалось подготовить аудио для разметки говорящих. Попробуйте другой аудио- или видеофайл.",
en: "Could not prepare audio for speaker labeling. Try a different audio or video file.",
},
"fileTranscription.errNeedTimestampModel": {
ru: "Для разделения по говорящим нужна локальная Whisper-модель с таймкодами.",
en: "Speaker separation requires a local Whisper model with timestamps.",
},
"fileTranscription.errDownloadDiarizationComponents": {
ru: "Для разделения по говорящим скачайте локальные компоненты в блоке транскрибации файла.",
en: "To separate speakers, download the local components in the file transcription section.",
},
"fileTranscription.errRuntimeIncomplete": {
ru: "Runtime для разметки установился не полностью. Нажмите «Скачать» в подготовке разметки, чтобы Talkis восстановил его.",
en: "The labeling runtime did not install completely. Click “Download” in the labeling setup so Talkis can restore it.",
},
"fileTranscription.errNoSpeakerSegments": {
ru: "Не удалось найти отдельные реплики говорящих в этом файле.",
en: "Could not find separate speaker utterances in this file.",
},
"fileTranscription.errLocalModelNotInstalled": {
ru: "Локальная модель распознавания не установлена. Откройте Настройки -> Модели -> Локально и нажмите «Скачать» для выбранной модели.",
en: "The local recognition model is not installed. Open Settings -> Models -> Local and click “Download” for the selected model.",
},
"fileTranscription.errAuthFailed": {
ru: "Не удалось авторизоваться в API. Проверьте ключ доступа.",
en: "Could not authenticate with the API. Check your access key.",
},
"fileTranscription.errRateLimit": {
ru: "Превышен лимит запросов или закончилась квота API. Попробуйте позже.",
en: "The request limit was exceeded or the API quota ran out. Try again later.",
},
"fileTranscription.errCloudDiarizationTimeout": {
ru: "Облако не успело обработать запись с разделением по говорящим — длинные встречи не укладываются в лимит сервера. Отключите «Разделение по говорящим» (файл разобьётся на части и распознается) или возьмите фрагмент покороче.",
en: "The cloud could not process the recording with speaker separation in time — long meetings exceed the server limit. Turn off “Speaker separation” (the file will be split into parts and transcribed) or take a shorter fragment.",
},
"fileTranscription.errNetwork": {
ru: "Не удалось связаться с сервером. Проверьте интернет и попробуйте снова.",
en: "Could not reach the server. Check your internet connection and try again.",
},
"fileTranscription.errGeneric": {
ru: "Не удалось транскрибировать файл. Попробуйте другой формат или более короткий фрагмент.",
en: "Could not transcribe the file. Try a different format or a shorter fragment.",
},
// ── callCapture.ts ────────────────────────────────────────────────────────
"callCapture.interrupted": {
ru: "Обработка остановлена. Можно запустить повторно.",
en: "Processing stopped. You can run it again.",
},
"callCapture.fileName": {
ru: "Созвон",
en: "Call",
},
"callCapture.speakerYou": {
ru: "Вы",
en: "You",
},
"callCapture.speakerCall": {
ru: "Созвон",
en: "Call",
},
"callCapture.speakerGuestN": {
ru: "Гость {index}",
en: "Guest {index}",
},
"callCapture.trackMic": {
ru: "микрофон",
en: "microphone",
},
"callCapture.errDiarizationNoSegments": {
ru: "Разделение говорящих не вернуло сегменты.",
en: "Speaker separation returned no segments.",
},
"callCapture.errNoSpeech": {
ru: "В созвоне не удалось распознать речь.",
en: "No speech could be recognized in the call.",
},
"callCapture.errProcessRetry": {
ru: "Не удалось обработать запись. Попробуйте повторить попытку.",
en: "Could not process the recording. Please try again.",
},
"callCapture.errNoSavedTracks": {
ru: "У этой записи нет сохраненных дорожек созвона для повторной обработки.",
en: "This entry has no saved call tracks to re-process.",
},
// ── summarize.ts ──────────────────────────────────────────────────────────
"summarize.errNoText": {
ru: "Нет текста для обработки",
en: "No text to process",
},
"summarize.errEmptyPrompt": {
ru: "Промпт пустой",
en: "The prompt is empty",
},
"summarize.errNoModel": {
ru: "Нет доступной модели для summary: войдите в Talkis Cloud или укажите текстовую модель во вкладке «Модели».",
en: "No model available for summary: sign in to Talkis Cloud or set a text model in the “Models” tab.",
},
// ── processingControl.ts ──────────────────────────────────────────────────
"processing.interrupted": {
ru: "Обработка остановлена. Можно запустить повторно.",
en: "Processing stopped. You can run it again.",
},
} as const;

View file

@ -0,0 +1,80 @@
// Strings for the General settings tab (windows/settings/tabs/SettingsTab.tsx).
export const settingsGeneral = {
// Interface language selector (the new feature)
"settings.uiLanguage.title": { ru: "Язык интерфейса", en: "Interface language" },
"settings.uiLanguage.desc": {
ru: "Язык меню, кнопок и подписей приложения.",
en: "Language of the app's menus, buttons and labels.",
},
"settings.uiLanguage.ru": { ru: "Русский", en: "Russian" },
"settings.uiLanguage.en": { ru: "Английский", en: "English" },
// Theme
"settings.theme.title": { ru: "Тема", en: "Theme" },
"settings.theme.system": { ru: "Системная", en: "System" },
"settings.theme.light": { ru: "Светлая", en: "Light" },
"settings.theme.dark": { ru: "Темная", en: "Dark" },
"settings.theme.desc": {
ru: "Системная тема следует настройке macOS.",
en: "The system theme follows your macOS setting.",
},
// Recognition (speech) language
"settings.recognitionLang.title": { ru: "Язык распознавания", en: "Recognition language" },
"settings.recognitionLang.desc": { ru: "Язык, на котором вы говорите.", en: "The language you speak." },
"settings.recognitionLang.searchPlaceholder": { ru: "Поиск языка...", en: "Search language..." },
// Microphone
"settings.mic.title": { ru: "Микрофон", en: "Microphone" },
"settings.mic.desc": { ru: "Устройство для записи голоса.", en: "Device used to record your voice." },
"settings.mic.systemDefault": { ru: "Системный микрофон по умолчанию", en: "System default microphone" },
// Hotkey
"settings.hotkey.title": { ru: "Горячая клавиша", en: "Hotkey" },
"settings.hotkey.desc": {
ru: "Нажмите на поле справа и введите новую комбинацию. Если сочетание занято, оставим предыдущую клавишу.",
en: "Click the field on the right and press a new combination. If it's taken, the previous one is kept.",
},
"settings.hotkey.change": { ru: "Изменить", en: "Change" },
"settings.hotkey.recording": { ru: "Запись", en: "Recording" },
"settings.hotkey.checking": { ru: "Проверка", en: "Checking" },
"settings.hotkey.press": { ru: "Нажмите сочетание", en: "Press a combination" },
"settings.hotkey.current": { ru: "Текущая: {hotkey}", en: "Current: {hotkey}" },
// Widget size
"settings.widgetSize.title": { ru: "Размер виджета", en: "Widget size" },
"settings.widgetSize.desc": { ru: "Масштаб плавающего виджета.", en: "Scale of the floating widget." },
"settings.widgetSize.aria": { ru: "Размер виджета", en: "Widget size" },
// Autostart
"settings.autostart.title": { ru: "Автозапуск", en: "Autostart" },
"settings.autostart.on": { ru: "Включен", en: "On" },
"settings.autostart.off": { ru: "Выключен", en: "Off" },
"settings.autostart.desc": {
ru: "Запускать Talkis автоматически при входе в систему.",
en: "Launch Talkis automatically when you log in.",
},
// Models directory
"settings.modelsDir.title": { ru: "Директория моделей", en: "Models directory" },
"settings.modelsDir.desc": {
ru: "Папка для скачанных локальных моделей. Оставьте поле пустым, чтобы использовать директорию по умолчанию.",
en: "Folder for downloaded local models. Leave empty to use the default directory.",
},
"settings.modelsDir.placeholder": { ru: "Директория по умолчанию", en: "Default directory" },
// History storage
"settings.storage.title": { ru: "Хранение истории", en: "History storage" },
"settings.storage.desc": {
ru: "Папка для истории диктовки и записей звонков. Оставьте пустым, чтобы использовать директорию по умолчанию.",
en: "Folder for dictation history and call recordings. Leave empty to use the default directory.",
},
// Support
"settings.support.title": { ru: "Поддержка", en: "Support" },
"settings.support.button": { ru: "Написать в поддержку", en: "Contact support" },
"settings.support.desc": {
ru: "Откроется почтовый клиент с письмом на {email}. Опишите проблему или вопрос мы поможем.",
en: "Your email client will open a message to {email}. Describe the problem or question we'll help.",
},
} as const;

View file

@ -0,0 +1,323 @@
// Strings for the Model & Style settings tabs (windows/settings/tabs/SettingsTabs.tsx).
export const settingsModels = {
// ── Account / subscription cards ──
"models.account.logout": { ru: "Выйти", en: "Log out" },
"models.cta.upgradePro": { ru: "Перейти на PRO", en: "Upgrade to PRO" },
"models.guest.title": { ru: "Подписка Talkis", en: "Talkis subscription" },
"models.guest.benefit1": { ru: "• Безлимитное использование без ограничений", en: "• Unlimited use, no limits" },
"models.guest.benefit2": { ru: "• Без VPN и Прокси", en: "• No VPN or proxy required" },
"models.guest.benefit3": { ru: "• Синхронизация со всеми устройствами", en: "• Sync across all your devices" },
"models.subscription.active": { ru: "Подписка активна", en: "Subscription active" },
"models.subscription.unlimitedUntil": { ru: "Безлимитный доступ до {date}", en: "Unlimited access until {date}" },
// ── Prompt library ──
"models.prompt.nameLabel": { ru: "Название", en: "Name" },
"models.prompt.namePlaceholder": { ru: "Например: Протокол встречи", en: "For example: Meeting minutes" },
"models.prompt.promptLabel": { ru: "Промпт", en: "Prompt" },
"models.prompt.promptPlaceholder": {
ru: "Опиши, какое summary нужно сделать по тексту разговора",
en: "Describe what kind of summary to make from the conversation text",
},
"models.prompt.saving": { ru: "Сохраняем", en: "Saving" },
"models.prompt.save": { ru: "Сохранить", en: "Save" },
"models.prompt.libraryHint": {
ru: "Выберите промпт для summary. Он предлагается по умолчанию на экране результата транскрибации.",
en: "Choose a summary prompt. It is offered by default on the transcription result screen.",
},
"models.prompt.create": { ru: "Создать промпт", en: "Create prompt" },
// ── Text (summary) model card ──
"models.textModel.title": { ru: "Текстовая модель", en: "Text model" },
"models.textModel.desc": {
ru: "OpenAI-совместимый endpoint для summary и обработки текста. Оставьте endpoint пустым, чтобы использовать OpenAI с вашим API-ключом.",
en: "An OpenAI-compatible endpoint for summaries and text processing. Leave the endpoint empty to use OpenAI with your API key.",
},
"models.textModel.apiKeyPlaceholder": { ru: "Необязательно для локального endpoint", en: "Optional for a local endpoint" },
"models.textModel.statusSet": { ru: "Настроена", en: "Configured" },
"models.textModel.statusUnset": { ru: "Не настроена", en: "Not configured" },
"models.modeCommit.label": { ru: "Режим: {mode}", en: "Mode: {mode}" },
"models.modeCommit.select": { ru: "Выбрать", en: "Select" },
"models.modeCommit.active": { ru: "Используется", en: "In use" },
// ── Recognition mode section ──
"models.modeSection.title": { ru: "Режим распознавания", en: "Recognition mode" },
"models.modeSection.desc": {
ru: "Вы можете в любой момент переключаться между облаком, своим API-ключом и локальной моделью.",
en: "You can switch between the cloud, your own API key, and a local model at any time.",
},
"models.mode.cloud": { ru: "Облако", en: "Cloud" },
"models.mode.local": { ru: "Локально", en: "Local" },
// ── Cloud mode ──
"models.cloud.descActive": {
ru: "Запросы на распознавание и обработку текста идут через облако Talkis. Все данные шифруются при передаче, а аудио и текст не сохраняются на сервере после обработки.",
en: "Recognition and text-processing requests go through the Talkis cloud. All data is encrypted in transit, and audio and text are not stored on the server after processing.",
},
"models.cloud.descGuest": {
ru: "Для облачного режима нужна авторизация и активная подписка. После входа плашка и статус подписки обновятся автоматически.",
en: "Cloud mode requires sign-in and an active subscription. After you log in, the banner and subscription status update automatically.",
},
"models.cloud.proReady": { ru: "PRO активен, облако готово к выбору", en: "PRO is active, the cloud is ready to select" },
"models.cloud.needPro": { ru: "Нужна активная подписка PRO", en: "An active PRO subscription is required" },
// ── API adapters section ──
"models.apiSection.title": { ru: "Доступные API-адаптеры", en: "Available API adapters" },
"models.apiSection.desc": {
ru: "Выберите адаптер, раскройте его и укажите ключ вместе с названием модели распознавания.",
en: "Pick an adapter, expand it, and enter your key along with the recognition model name.",
},
"models.adapter.recommendedModel": { ru: "Рекомендуемая модель: {model}.", en: "Recommended model: {model}." },
// Per-adapter descriptions (ru = the original constant text)
"models.adapter.openai.description": {
ru: "Подключение через OpenAI API для распознавания речи.",
en: "Connect via the OpenAI API for speech recognition.",
},
"models.adapter.deepgram.description": {
ru: "Адаптер для облачного распознавания речи через Deepgram.",
en: "Adapter for cloud speech recognition via Deepgram.",
},
"models.adapter.cartesia.description": {
ru: "Адаптер под речевые модели Cartesia.",
en: "Adapter for Cartesia speech models.",
},
"models.adapter.mistral.description": {
ru: "Адаптер под модели распознавания и обработки Mistral AI.",
en: "Adapter for Mistral AI recognition and processing models.",
},
"models.adapter.elevenlabs.description": {
ru: "Адаптер для speech-to-text сценариев через ElevenLabs.",
en: "Adapter for speech-to-text use cases via ElevenLabs.",
},
"models.adapter.fireworks.description": {
ru: "Адаптер под hosted speech-модели Fireworks AI.",
en: "Adapter for Fireworks AI hosted speech models.",
},
"models.adapter.groq.description": {
ru: "Адаптер под быстрые hosted Whisper-модели Groq.",
en: "Adapter for Groq's fast hosted Whisper models.",
},
"models.adapter.assemblyai.description": {
ru: "Адаптер для распознавания речи через AssemblyAI.",
en: "Adapter for speech recognition via AssemblyAI.",
},
"models.adapter.volcengine.description": {
ru: "Адаптер под речевые сервисы Volcengine.",
en: "Adapter for Volcengine speech services.",
},
"models.adapter.xai.description": {
ru: "Адаптер под API xAI для будущих voice/STT сценариев.",
en: "Adapter for the xAI API for future voice/STT use cases.",
},
// ── Adapter status badges ──
"models.adapterStatus.selected": { ru: "Выбран", en: "Selected" },
"models.adapterStatus.needApiKey": { ru: "Нужен API-ключ", en: "API key required" },
"models.adapterStatus.needModel": { ru: "Нужна модель", en: "Model required" },
"models.adapterStatus.ready": { ru: "Готов", en: "Ready" },
"models.adapterStatus.error": { ru: "Ошибка", en: "Error" },
"models.adapterStatus.testing": { ru: "Проверяем", en: "Testing" },
"models.adapterStatus.readyToTest": { ru: "Готов к проверке", en: "Ready to test" },
"models.adapterStatus.readyToSelect": { ru: "Готов к выбору", en: "Ready to select" },
// ── Connection labels / messages ──
"models.connection.noApiKey": { ru: "API-ключ не указан", en: "No API key entered" },
"models.connection.usedForRecognition": { ru: "Используется для распознавания", en: "Used for recognition" },
"models.connection.working": { ru: "Соединение работает", en: "Connection working" },
"models.connection.error": { ru: "Ошибка соединения", en: "Connection error" },
"models.connection.testing": { ru: "Проверяем соединение...", en: "Testing connection..." },
"models.connection.notTested": { ru: "Соединение не проверено", en: "Connection not tested" },
"models.connection.verifiedSaved": { ru: "Соединение проверено и сохранено.", en: "Connection verified and saved." },
"models.connection.keyModelSaved": { ru: "Ключ и модель сохранены", en: "Key and model saved" },
"models.connection.keyModelSavedNamed": { ru: "{name}: ключ и модель сохранены.", en: "{name}: key and model saved." },
"models.connection.readyToSelect": { ru: "Готов к выбору", en: "Ready to select" },
"models.connection.fillKeyModel": { ru: "Заполните ключ и модель", en: "Fill in the key and model" },
// ── Adapter form fields ──
"models.field.apiKey": { ru: "API-ключ", en: "API key" },
"models.field.model": { ru: "Модель", en: "Model" },
"models.field.recommended": { ru: "Рекомендуем: {model}", en: "Recommended: {model}" },
"models.field.host": { ru: "Хост", en: "Host" },
"models.field.hostDefaultPlaceholder": { ru: "По умолчанию: {endpoint}", en: "Default: {endpoint}" },
"models.field.hostPlaceholder": {
ru: "https://api.example.com или http://localhost:8000",
en: "https://api.example.com or http://localhost:8000",
},
// ── Test / save actions ──
"models.test.needKeyAndModel": {
ru: "Укажите API-ключ и название модели перед проверкой.",
en: "Enter an API key and a model name before testing.",
},
"models.test.needKeyAndModelBeforeSelect": {
ru: "Укажите API-ключ и модель перед выбором адаптера.",
en: "Enter an API key and a model before selecting the adapter.",
},
"models.test.savedPendingBackend": {
ru: "{name}: ключ и модель сохранены. Реальная проверка соединения будет доступна после подключения backend-адаптера.",
en: "{name}: key and model saved. A live connection test will be available once the backend adapter is connected.",
},
"models.test.checking": { ru: "Проверяем...", en: "Testing..." },
"models.test.testAndSave": { ru: "Тестировать и сохранить", en: "Test and save" },
"models.test.saveButton": { ru: "Сохранить", en: "Save" },
// ── Local model statuses ──
"models.local.modelNotConnected": { ru: "Модель не подключена", en: "Model not connected" },
"models.local.engineNotConnected": { ru: "Движок не подключен", en: "Engine not connected" },
"models.local.runtimeReadyModelOff": {
ru: "{runtime} runtime работает, но эта модель ещё не включена.",
en: "The {runtime} runtime is running, but this model is not enabled yet.",
},
"models.local.runtimeSlotPrepared": {
ru: "{runtime} runtime-слот подготовлен, но движок еще не встроен в сборку.",
en: "The {runtime} runtime slot is prepared, but the engine is not yet built into the app.",
},
"models.local.macOnly": {
ru: "Эта локальная модель пока доступна только на macOS. Для Windows и Linux оставлен Whisper runtime.",
en: "This local model is currently available on macOS only. The Whisper runtime is kept for Windows and Linux.",
},
"models.local.sidecarPending": {
ru: "{runtime} sidecar запускается отдельно от Whisper, но скачивание и распознавание для этой линейки будут включены после подключения локального engine.",
en: "The {runtime} sidecar runs separately from Whisper, but downloading and recognition for this line will be enabled once the local engine is connected.",
},
"models.local.deleting": { ru: "Удаляется", en: "Deleting" },
"models.local.downloading": { ru: "Скачивается", en: "Downloading" },
"models.local.selected": { ru: "Выбрана", en: "Selected" },
"models.local.ready": { ru: "Готова", en: "Ready" },
"models.local.prepareFailed": { ru: "Не удалось подготовить модель", en: "Failed to prepare the model" },
"models.local.notDownloaded": { ru: "Не скачана", en: "Not downloaded" },
// ── Local models section ──
"models.local.tabTranscription": { ru: "Транскрибация", en: "Transcription" },
"models.local.tabText": { ru: "Текстовая", en: "Text" },
"models.local.sectionTitle": { ru: "Локальные модели", en: "Local models" },
"models.local.sectionDesc": {
ru: "Выберите модель, скачайте ее через локальный STT runtime и используйте без облака.",
en: "Pick a model, download it through the local STT runtime, and use it without the cloud.",
},
"models.local.recommendedBadge": { ru: "Рекомендуем", en: "Recommended" },
"models.local.runtimeSuffix": { ru: "Runtime: {runtime}.", en: "Runtime: {runtime}." },
// Per-model descriptions (ru = the original constant text)
"models.local.whisper-large-v3-turbo.description": {
ru: "Рекомендуемый Whisper-вариант: быстрый, качественный и хорошо подходит для диктовки.",
en: "The recommended Whisper variant: fast, accurate, and well suited for dictation.",
},
"models.local.whisper-large-v3-turbo-quantized.description": {
ru: "Более легкий вариант Turbo для локальной работы с меньшим расходом памяти.",
en: "A lighter Turbo variant for local use with lower memory consumption.",
},
"models.local.whisper-small.description": {
ru: "Баланс скорости и качества для слабых машин и быстрых коротких диктовок.",
en: "A balance of speed and quality for weaker machines and quick short dictations.",
},
"models.local.whisper-large-v3.description": {
ru: "Максимальное качество Whisper, но выше требования к памяти и времени обработки.",
en: "Maximum Whisper quality, but with higher memory and processing-time requirements.",
},
"models.local.whisper-large-v2.description": {
ru: "Предыдущая large-версия Whisper для совместимости с существующими локальными установками.",
en: "The previous large Whisper version, for compatibility with existing local setups.",
},
"models.local.whisper-medium.description": {
ru: "Промежуточный вариант между Small и Large: заметно качественнее Small, но тяжелее.",
en: "An option between Small and Large: noticeably better than Small, but heavier.",
},
"models.local.whisper-base.description": {
ru: "Быстрая и легкая модель для простых сценариев и слабых машин.",
en: "A fast, lightweight model for simple scenarios and weaker machines.",
},
"models.local.whisper-tiny.description": {
ru: "Минимальный размер и максимальная скорость, качество ниже остальных Whisper-моделей.",
en: "Minimal size and maximum speed, with lower quality than the other Whisper models.",
},
"models.local.parakeet-tdt-06b-v3.description": {
ru: "Быстрая локальная ASR-модель Parakeet через MLX runtime для Apple Silicon.",
en: "A fast local Parakeet ASR model via the MLX runtime for Apple Silicon.",
},
"models.local.parakeet-tdt-06b-v2.description": {
ru: "Стабильная английская Parakeet TDT-модель через MLX runtime для Apple Silicon.",
en: "A stable English Parakeet TDT model via the MLX runtime for Apple Silicon.",
},
"models.local.qwen3-asr-06b.description": {
ru: "Компактная ASR-модель Qwen для локального распознавания через совместимый runtime.",
en: "A compact Qwen ASR model for local recognition via a compatible runtime.",
},
// ── Speed / accuracy value labels ──
"models.speedValue.veryFast": { ru: "очень быстро", en: "very fast" },
"models.speedValue.fast": { ru: "быстро", en: "fast" },
"models.speedValue.medium": { ru: "средне", en: "medium" },
"models.accuracyValue.maximum": { ru: "максимальная", en: "maximum" },
"models.accuracyValue.high": { ru: "высокая", en: "high" },
"models.accuracyValue.medium": { ru: "средняя", en: "medium" },
"models.accuracyValue.mediumPlus": { ru: "средняя+", en: "medium+" },
"models.accuracyValue.basic": { ru: "базовая", en: "basic" },
"models.accuracyValue.lowPlus": { ru: "низкая+", en: "low+" },
"models.accuracyValue.utility": { ru: "служебная", en: "utility" },
// ── Stat labels / titles ──
"models.stat.speed": { ru: "Скорость", en: "Speed" },
"models.stat.accuracy": { ru: "Точность", en: "Accuracy" },
"models.stat.speedTitle": { ru: "Скорость: {value}", en: "Speed: {value}" },
"models.stat.accuracyTitle": { ru: "Точность: {value}", en: "Accuracy: {value}" },
"models.stat.downloadSizeTitle": { ru: "Размер загрузки: {value}", en: "Download size: {value}" },
// ── Download size formatting ──
"models.size.zero": { ru: "0 Б", en: "0 B" },
"models.size.gb": { ru: "{value} ГБ", en: "{value} GB" },
"models.size.mb": { ru: "{value} МБ", en: "{value} MB" },
"models.size.unknown": { ru: "Неизвестно", en: "Unknown" },
"models.size.notConnected": { ru: "Не подключено", en: "Not connected" },
// ── Install / download progress ──
"models.install.preparingRuntime": { ru: "Готовим локальный STT runtime.", en: "Preparing the local STT runtime." },
"models.delete.removingFile": { ru: "Удаляем локальный файл модели.", en: "Removing the local model file." },
"models.download.progress": { ru: "Скачиваем модель: {percent}%", en: "Downloading model: {percent}%" },
"models.download.inProgress": { ru: "Скачиваем модель.", en: "Downloading model." },
"models.download.done": { ru: "Модель скачана.", en: "Model downloaded." },
"models.download.loading": { ru: "Загрузка модели", en: "Downloading model" },
"models.download.preparing": { ru: "Подготовка", en: "Preparing" },
"models.download.of": { ru: "из {total}", en: "of {total}" },
// ── Delete confirmation dialog ──
"models.deleteDialog.title": { ru: "Вы действительно хотите удалить?", en: "Are you sure you want to delete?" },
"models.deleteDialog.body": {
ru: "{name} будет удалена с диска. При необходимости модель можно скачать заново.",
en: "{name} will be removed from disk. You can download the model again if needed.",
},
// ── Common buttons / labels ──
"models.common.cancel": { ru: "Отмена", en: "Cancel" },
"models.common.edit": { ru: "Изменить", en: "Edit" },
"models.common.delete": { ru: "Удалить", en: "Delete" },
"models.common.select": { ru: "Выбрать", en: "Select" },
"models.common.selected": { ru: "Выбрано", en: "Selected" },
"models.common.reset": { ru: "Сбросить", en: "Reset" },
"models.common.optional": { ru: "Необязательно", en: "Optional" },
"models.common.download": { ru: "Скачать", en: "Download" },
"models.common.unavailable": { ru: "Недоступно", en: "Unavailable" },
"models.common.or": { ru: "или", en: "or" },
// ── Style / text-processing tab ──
"models.styleTab.style": { ru: "Стиль", en: "Style" },
"models.styleTab.prompts": { ru: "Промпты", en: "Prompts" },
"models.textProcessing.title": { ru: "Обработка текста", en: "Text processing" },
"models.textProcessing.desc": {
ru: "Стиль очистки расшифровки и Промпты для summary.",
en: "Transcript cleanup style and summary prompts.",
},
// ── Prompt preview (dev) ──
"models.preview.title": { ru: "Prompt Preview", en: "Prompt Preview" },
"models.preview.desc": {
ru: "Итоговый prompt для текущего языка и стиля обработки.",
en: "The final prompt for the current language and processing style.",
},
"models.preview.buildError": { ru: "Не удалось собрать preview prompt: {error}", en: "Failed to build the preview prompt: {error}" },
"models.preview.profile": { ru: "Профиль", en: "Profile" },
"models.preview.layers": { ru: "Слои", en: "Layers" },
"models.preview.promptText": { ru: "Текст prompt", en: "Prompt text" },
"models.preview.building": { ru: "Собираем preview...", en: "Building preview..." },
} as const;

View file

@ -0,0 +1,268 @@
export const settingsRest = {
// ===== MainTab (windows/settings/tabs/MainTab.tsx) =====
"mainTab.filter.all": { ru: "Все", en: "All" },
"mainTab.filter.voice": { ru: "Голос", en: "Voice" },
"mainTab.filter.file": { ru: "Файл", en: "File" },
"mainTab.filter.call": { ru: "Созвон", en: "Call" },
"mainTab.source.file": { ru: "Файл", en: "File" },
"mainTab.source.call": { ru: "Созвон", en: "Call" },
"mainTab.source.voice": { ru: "Голос", en: "Voice" },
"mainTab.speakerNameAria": { ru: "Имя {name}", en: "Name {name}" },
"mainTab.collapse": { ru: "Скрыть", en: "Collapse" },
"mainTab.expand": { ru: "Раскрыть", en: "Expand" },
"mainTab.day.today": { ru: "Сегодня", en: "Today" },
"mainTab.day.yesterday": { ru: "Вчера", en: "Yesterday" },
"mainTab.retryFailed": {
ru: "Не удалось повторно отправить запись.",
en: "Failed to resubmit the recording.",
},
"mainTab.processingStopped": {
ru: "Обработка остановлена. Можно запустить повторно.",
en: "Processing stopped. You can run it again.",
},
"mainTab.howToStart": { ru: "Как начать запись", en: "How to start recording" },
"mainTab.howItWorks": { ru: "Как это работает", en: "How it works" },
"mainTab.howToStartHint": {
ru: "Удерживайте горячую клавишу, говорите и отпустите ее, когда закончите. После обработки текст вставится автоматически.",
en: "Hold the hotkey, speak and release it when you're done. After processing the text is pasted automatically.",
},
"mainTab.combination": { ru: "Комбинация", en: "Combination" },
"mainTab.historyTitle": { ru: "История записей", en: "Recording history" },
"mainTab.historyDescFilled": {
ru: "Последние записи доступны для копирования и удаления.",
en: "Recent recordings can be copied and deleted.",
},
"mainTab.historyDescEmpty": {
ru: "Записей пока нет. Удерживайте {hotkey} для записи.",
en: "No recordings yet. Hold {hotkey} to record.",
},
"mainTab.clearAllConfirmTitle": {
ru: "Нажмите еще раз, чтобы очистить всю историю",
en: "Click again to clear all history",
},
"mainTab.clearAllTitle": { ru: "Очистить всю историю", en: "Clear all history" },
"mainTab.confirm": { ru: "Подтвердить", en: "Confirm" },
"mainTab.clear": { ru: "Очистить", en: "Clear" },
"mainTab.emptyTitle": { ru: "История пуста", en: "History is empty" },
"mainTab.emptyHintBefore": {
ru: "Записей пока нет. Удерживайте",
en: "No recordings yet. Hold",
},
"mainTab.emptyHintAfter": { ru: "для записи.", en: "to record." },
"mainTab.filterEmpty": {
ru: "В этом фильтре записей пока нет.",
en: "No recordings in this filter yet.",
},
"mainTab.colTime": { ru: "Время", en: "Time" },
"mainTab.colText": { ru: "Текст", en: "Text" },
"mainTab.durationMs": { ru: "{value}мс", en: "{value}ms" },
"mainTab.durationS": { ru: "{value}с", en: "{value}s" },
"mainTab.processing": { ru: "Идёт обработка…", en: "Processing…" },
"mainTab.statusInterrupted": { ru: "Обработка прервана", en: "Processing interrupted" },
"mainTab.statusFailed": { ru: "Обработка не завершилась", en: "Processing did not finish" },
"mainTab.audioSavedLocally": {
ru: "Аудио сохранено локально. Можно отправить повторно.",
en: "Audio saved locally. You can resubmit it.",
},
"mainTab.stopProcessing": { ru: "Остановить обработку", en: "Stop processing" },
"mainTab.retryProcess": { ru: "Обработать заново", en: "Process again" },
"mainTab.success": { ru: "Успешно", en: "Done" },
"mainTab.copy": { ru: "Скопировать", en: "Copy" },
"mainTab.edit": { ru: "Редактировать", en: "Edit" },
"mainTab.delete": { ru: "Удалить", en: "Delete" },
// ===== SettingsApp (windows/settings/SettingsApp.tsx) =====
"settingsApp.tab.main": { ru: "Главная", en: "Home" },
"settingsApp.tab.file": { ru: "Транскрибация", en: "Transcription" },
"settingsApp.tab.interpreter": { ru: "Переводчик (бета)", en: "Interpreter (beta)" },
"settingsApp.tab.settings": { ru: "Настройки", en: "Settings" },
"settingsApp.tab.model": { ru: "Модели", en: "Models" },
"settingsApp.tab.style": { ru: "Стиль и Промпты", en: "Style and Prompts" },
"settingsApp.installing": { ru: "Устанавливаем...", en: "Installing..." },
"settingsApp.installUpdate": {
ru: "Установить обновление {version}",
en: "Install update {version}",
},
"settingsApp.updateFailed": {
ru: "Не удалось установить обновление",
en: "Failed to install the update",
},
"settingsApp.versionAria": {
ru: "Версия приложения {version}. Открыть проект на GitHub",
en: "App version {version}. Open the project on GitHub",
},
"settingsApp.githubTitle": { ru: "Проект на GitHub", en: "Project on GitHub" },
"settingsApp.loadError": {
ru: "Не удалось загрузить состояние приложения. Некоторые данные могут быть недоступны.",
en: "Failed to load the app state. Some data may be unavailable.",
},
"settingsApp.loading": { ru: "Загружаем настройки…", en: "Loading settings…" },
// ===== LocalLlmModels (components/LocalLlmModels.tsx) =====
"localLlm.title": { ru: "Текстовые модели", en: "Text models" },
"localLlm.desc": {
ru: "Скачайте локальную модель и используйте ее для summary без облака.",
en: "Download a local model and use it for summaries without the cloud.",
},
"localLlm.status.deleting": { ru: "Удаляется", en: "Deleting" },
"localLlm.status.downloading": { ru: "Скачивается", en: "Downloading" },
"localLlm.status.selected": { ru: "Выбрана", en: "Selected" },
"localLlm.status.ready": { ru: "Готова", en: "Ready" },
"localLlm.status.notDownloaded": { ru: "Не скачана", en: "Not downloaded" },
"localLlm.diskSize": { ru: "Размер на диске: {size}", en: "Size on disk: {size}" },
"localLlm.ramRequired": { ru: "Требуется ОЗУ: от {ram} ГБ", en: "Required RAM: {ram} GB or more" },
"localLlm.ramShort": { ru: "≥ {ram} ГБ", en: "≥ {ram} GB" },
"localLlm.downloadingModel": { ru: "Загрузка модели", en: "Downloading model" },
"localLlm.preparing": { ru: "Подготовка", en: "Preparing" },
"localLlm.cancel": { ru: "Отмена", en: "Cancel" },
"localLlm.download": { ru: "Скачать", en: "Download" },
"localLlm.required": {
ru: "Выберите текстовую модель — без неё саммаризация недоступна (распознавание работает и так).",
en: "Select a text model — summarization is unavailable without one (transcription still works).",
},
"localLlm.select": { ru: "Выбрать", en: "Select" },
"localLlm.delete": { ru: "Удалить", en: "Delete" },
// ===== SettingsTab remaining literals (windows/settings/tabs/SettingsTab.tsx) =====
// Microphone status/feedback
"settingsGeneralExtra.mic.checking": {
ru: "Проверяем доступные микрофоны...",
en: "Checking available microphones...",
},
"settingsGeneralExtra.mic.unavailableEnv": {
ru: "Список микрофонов недоступен в этой среде.",
en: "The microphone list is unavailable in this environment.",
},
"settingsGeneralExtra.mic.missingSelected": {
ru: "Ранее выбранный микрофон недоступен. Во время записи будет использован системный по умолчанию.",
en: "The previously selected microphone is unavailable. The system default will be used while recording.",
},
"settingsGeneralExtra.mic.permissionNeeded": {
ru: "Список микрофонов появится после разрешения доступа к микрофону.",
en: "The microphone list will appear after you grant microphone access.",
},
"settingsGeneralExtra.mic.noneFound": {
ru: "Не удалось найти доступные микрофоны. Подключите устройство или проверьте системные настройки.",
en: "No available microphones found. Connect a device or check your system settings.",
},
"settingsGeneralExtra.mic.inUse": {
ru: "Сейчас используется: {label}",
en: "Currently in use: {label}",
},
"settingsGeneralExtra.mic.enumFailed": {
ru: "Не удалось получить список микрофонов. Попробуйте открыть настройки ещё раз.",
en: "Failed to get the microphone list. Try opening settings again.",
},
"settingsGeneralExtra.mic.fallbackName": {
ru: "Микрофон {index}",
en: "Microphone {index}",
},
// Hotkey feedback
"settingsGeneralExtra.hotkey.initial": {
ru: "Нажмите на поле и введите новую комбинацию. Esc отменяет ввод.",
en: "Click the field and enter a new combination. Esc cancels input.",
},
"settingsGeneralExtra.hotkey.applyFailed": {
ru: "Не удалось применить новую комбинацию.",
en: "Failed to apply the new combination.",
},
"settingsGeneralExtra.hotkey.saved": {
ru: "Новая горячая клавиша сохранена и уже работает.",
en: "The new hotkey is saved and already working.",
},
"settingsGeneralExtra.hotkey.changeAgain": {
ru: "Нажмите на поле, чтобы изменить комбинацию снова.",
en: "Click the field to change the combination again.",
},
"settingsGeneralExtra.hotkey.pressNew": {
ru: "Нажмите новую комбинацию.",
en: "Press a new combination.",
},
"settingsGeneralExtra.hotkey.releaseToApply": {
ru: "Отпустите комбинацию, чтобы применить.",
en: "Release the combination to apply it.",
},
"settingsGeneralExtra.hotkey.inputCancelled": {
ru: "Ввод отменен.",
en: "Input cancelled.",
},
"settingsGeneralExtra.hotkey.cancelledKept": {
ru: "Ввод отменен. Текущая комбинация сохранена.",
en: "Input cancelled. The current combination is kept.",
},
"settingsGeneralExtra.hotkey.needMainKey": {
ru: "Нажмите сочетание с основной клавишей.",
en: "Press a combination with a main key.",
},
"settingsGeneralExtra.hotkey.invalid": {
ru: "Неверная комбинация.",
en: "Invalid combination.",
},
"settingsGeneralExtra.hotkey.recognizeFailed": {
ru: "Не удалось распознать комбинацию.",
en: "Could not recognize the combination.",
},
"settingsGeneralExtra.hotkey.checkingFree": {
ru: "Проверяем, свободна ли эта комбинация...",
en: "Checking whether this combination is free...",
},
"settingsGeneralExtra.hotkey.sendFailed": {
ru: "Не удалось отправить новую комбинацию на проверку.",
en: "Failed to send the new combination for verification.",
},
"settingsGeneralExtra.hotkey.startingCapture": {
ru: "Запускаем запись новой комбинации...",
en: "Starting capture of a new combination...",
},
"settingsGeneralExtra.hotkey.pressNewCombo": {
ru: "Нажмите новое сочетание.",
en: "Press a new combination.",
},
"settingsGeneralExtra.hotkey.captureStartFailed": {
ru: "Не удалось запустить запись горячей клавиши.",
en: "Failed to start hotkey capture.",
},
// Support
"settingsGeneralExtra.support.mailSubject": {
ru: "Talkis — обращение в поддержку",
en: "Talkis — support request",
},
"settingsGeneralExtra.support.mailBody": {
ru: "Опишите проблему или вопрос:\n\n\n",
en: "Describe the problem or question:\n\n\n",
},
"settingsGeneralExtra.support.mailCopied": {
ru: "Не удалось открыть почтовый клиент. Адрес скопирован: {email}",
en: "Failed to open the email client. Address copied: {email}",
},
"settingsGeneralExtra.support.writeUs": {
ru: "Напишите нам на {email}",
en: "Write to us at {email}",
},
// Dialog titles
"settingsGeneralExtra.dialog.chooseModelsDir": {
ru: "Выберите директорию моделей",
en: "Choose the models directory",
},
"settingsGeneralExtra.dialog.chooseStorageDir": {
ru: "Выберите папку хранения истории",
en: "Choose the history storage folder",
},
// Storage feedback
"settingsGeneralExtra.storage.moved": {
ru: "История перенесена в выбранную папку.",
en: "History moved to the selected folder.",
},
"settingsGeneralExtra.storage.moveFailed": {
ru: "Не удалось сохранить историю в выбранную папку. Текущая папка не изменилась.",
en: "Failed to save history to the selected folder. The current folder is unchanged.",
},
"settingsGeneralExtra.storage.resetDone": {
ru: "История возвращена в директорию по умолчанию.",
en: "History restored to the default directory.",
},
"settingsGeneralExtra.storage.resetFailed": {
ru: "Не удалось вернуть директорию по умолчанию. Текущая папка не изменилась.",
en: "Failed to restore the default directory. The current folder is unchanged.",
},
} as const;

View file

@ -0,0 +1,330 @@
// Strings for the File Transcription and Realtime Interpreter settings tabs
// (windows/settings/tabs/FileTranscriptionTab.tsx + RealtimeInterpreterTab.tsx).
export const settingsTabsMisc = {
// ---- FileTranscriptionTab ----
// Processing status labels (statusLabel)
"fileTab.status.reading": { ru: "Читаем файл", en: "Reading file" },
"fileTab.status.converting": {
ru: "Извлекаем и сжимаем аудио",
en: "Extracting and compressing audio",
},
"fileTab.status.uploading": {
ru: "Отправляем на транскрибацию",
en: "Uploading for transcription",
},
"fileTab.status.preparing": { ru: "Готовим файл", en: "Preparing file" },
"fileTab.status.diarizing": {
ru: "Разделяем говорящих",
en: "Separating speakers",
},
"fileTab.status.transcribingChunk": {
ru: "Распознаём фрагмент {current} из {total}",
en: "Recognizing chunk {current} of {total}",
},
"fileTab.status.transcribing": {
ru: "Распознаём фрагменты",
en: "Recognizing chunks",
},
"fileTab.status.assembling": {
ru: "Собираем протокол",
en: "Assembling transcript",
},
"fileTab.status.done": { ru: "Готово", en: "Done" },
"fileTab.status.error": { ru: "Ошибка", en: "Error" },
"fileTab.status.idle": { ru: "Ожидаем файл", en: "Waiting for a file" },
// Result source label (resultSourceLabel) — also reused as the file-name fallback
"fileTab.source.call": { ru: "Созвон", en: "Call" },
"fileTab.source.file": { ru: "Файл", en: "File" },
"fileTab.source.voice": { ru: "Голос", en: "Voice" },
// Speaker-setup error messages (toSpeakerSetupErrorMessage)
"fileTab.setupError.rejectedKey": {
ru: "Не удалось подготовить локальные компоненты: STT runtime отклонил API-ключ.",
en: "Could not prepare local components: the STT runtime rejected the API key.",
},
"fileTab.setupError.forbidden": {
ru: "Не удалось подготовить локальные компоненты: STT runtime запретил установку модели.",
en: "Could not prepare local components: the STT runtime forbade installing the model.",
},
"fileTab.setupError.timeout": {
ru: "Не удалось подготовить локальные компоненты: локальный STT runtime не ответил вовремя.",
en: "Could not prepare local components: the local STT runtime did not respond in time.",
},
"fileTab.setupError.noVenv": {
ru: "Не удалось подготовить локальные компоненты: в системе нет Python venv/pip. Установите пакет python3.12-venv и повторите скачивание.",
en: "Could not prepare local components: Python venv/pip is missing on the system. Install the python3.12-venv package and retry the download.",
},
"fileTab.setupError.genericWithDetail": {
ru: "Не удалось подготовить локальные компоненты для разделения по говорящим: {detail}",
en: "Could not prepare local components for speaker separation: {detail}",
},
"fileTab.setupError.generic": {
ru: "Не удалось подготовить локальные компоненты для разделения по говорящим.",
en: "Could not prepare local components for speaker separation.",
},
// processFile / convertedInfo
"fileTab.error.speakerNeedsDialog": {
ru: "Разделение по говорящим доступно для файлов, выбранных через системный диалог или перетаскиванием в окно Talkis.",
en: "Speaker separation is available for files chosen via the system dialog or dropped onto the Talkis window.",
},
"fileTab.convertedInfo": {
ru: "Отправлено как {name}, {size}",
en: "Sent as {name}, {size}",
},
// Speaker setup progress messages (installSpeakerSetup)
"fileTab.setup.preparing": {
ru: "Готовим локальные компоненты...",
en: "Preparing local components...",
},
"fileTab.setup.downloading": {
ru: "Скачиваем {name}...",
en: "Downloading {name}...",
},
"fileTab.setup.repairing": {
ru: "Восстанавливаем runtime для разметки...",
en: "Repairing the diarization runtime...",
},
"fileTab.setup.downloadingDiarization": {
ru: "Скачиваем компоненты для разметки говорящих...",
en: "Downloading speaker diarization components...",
},
"fileTab.setup.done": { ru: "Готово.", en: "Done." },
"fileTab.setup.doneContinue": {
ru: "Готово. Продолжаем обработку файла...",
en: "Done. Continuing to process the file...",
},
// Header
"fileTab.heading": { ru: "Транскрибация", en: "Transcription" },
"fileTab.subheading": {
ru: "Голый текст без дополнительного форматирования.",
en: "Plain text without extra formatting.",
},
// File open dialog filter
"fileTab.dialog.audioVideoFilter": {
ru: "Аудио и видео",
en: "Audio and video",
},
// Dropzone
"fileTab.dropzone.title": {
ru: "Перетащите аудио или видео",
en: "Drag in audio or video",
},
"fileTab.dropzone.hint": {
ru: "Нажмите на область или перетащите файл. MP3, WAV, M4A, MP4, MOV, WEBM и другие форматы",
en: "Click the area or drop a file. MP3, WAV, M4A, MP4, MOV, WEBM and other formats",
},
// Speaker diarization toggle
"fileTab.speaker.toggleTitle": {
ru: "Разделить по говорящим",
en: "Separate by speaker",
},
"fileTab.speaker.toggleDesc": {
ru: "Протокол с таймкодами и метками Гость 1, Гость 2.",
en: "Transcript with timestamps and Guest 1, Guest 2 labels.",
},
"fileTab.speaker.nameAria": { ru: "Имя {name}", en: "Name {name}" },
// Result area
"fileTab.result.title": { ru: "Результат", en: "Result" },
"fileTab.result.copied": { ru: "Скопировано", en: "Copied" },
"fileTab.result.copy": { ru: "Скопировать", en: "Copy" },
"fileTab.result.clear": { ru: "Очистить", en: "Clear" },
"fileTab.result.collapse": { ru: "Скрыть", en: "Collapse" },
"fileTab.result.expand": { ru: "Раскрыть", en: "Expand" },
"fileTab.result.empty": {
ru: "После обработки здесь появится текст.",
en: "The text will appear here after processing.",
},
// Result table
"fileTab.table.time": { ru: "Время", en: "Time" },
"fileTab.table.text": { ru: "Текст", en: "Text" },
// Speaker setup modal
"fileTab.modal.title": {
ru: "Нужна локальная подготовка",
en: "Local setup required",
},
"fileTab.modal.descDownloaded": {
ru: "Для разделения по говорящим Talkis использует {name} для распознавания с таймкодами и подготовит локальные компоненты для разметки говорящих.",
en: "For speaker separation Talkis uses {name} for timestamped recognition and will prepare local components for speaker diarization.",
},
"fileTab.modal.descNeedDownload": {
ru: "Для разделения по говорящим Talkis подготовит {name} для распознавания с таймкодами и локальные компоненты для разметки говорящих.",
en: "For speaker separation Talkis will prepare {name} for timestamped recognition and local components for speaker diarization.",
},
"fileTab.modal.whisperReady": {
ru: "{name} готова для разметки",
en: "{name} is ready for diarization",
},
"fileTab.modal.whisperWillDownload": {
ru: "{name} будет скачан",
en: "{name} will be downloaded",
},
"fileTab.modal.diarizationReady": {
ru: "Компоненты для разметки",
en: "Diarization components",
},
"fileTab.modal.diarizationWillDownload": {
ru: "Компоненты для разметки говорящих будут скачаны",
en: "Speaker diarization components will be downloaded",
},
// ---- RealtimeInterpreterTab ----
// Status labels (statusLabel)
"realtime.status.checking": { ru: "Проверяем", en: "Checking" },
"realtime.status.running": { ru: "Работает", en: "Running" },
"realtime.status.starting": { ru: "Запускается", en: "Starting" },
"realtime.status.stopping": { ru: "Останавливается", en: "Stopping" },
"realtime.status.error": { ru: "Ошибка", en: "Error" },
"realtime.status.off": { ru: "Выключен", en: "Off" },
// Transcript speaker labels (directionSpeakerLabel)
"realtime.speaker.you": { ru: "Вы", en: "You" },
"realtime.speaker.remote": { ru: "Собеседник", en: "Other party" },
"realtime.transcript.translationLabel": {
ru: "Перевод ({lang})",
en: "Translation ({lang})",
},
// Warnings
"realtime.warning.needLogin": {
ru: "Для облачного режима войдите в аккаунт Talkis.",
en: "Sign in to your Talkis account for cloud mode.",
},
"realtime.warning.needHeadphones": {
ru: "Для локального перевода нужны наушники или отдельный output.",
en: "Local translation requires headphones or a separate output.",
},
// Test output
"realtime.test.signalSent": {
ru: "Тестовый сигнал отправлен в virtual mic output.",
en: "A test signal was sent to the virtual mic output.",
},
// Saved history entry name
"realtime.history.fileName": { ru: "Перевод звонка", en: "Call translation" },
// Loading
"realtime.loading": { ru: "Загружаем переводчик…", en: "Loading interpreter…" },
// Header
"realtime.title": {
ru: "Синхронный переводчик",
en: "Simultaneous interpreter",
},
"realtime.metric.status": { ru: "Статус", en: "Status" },
"realtime.metric.languages": { ru: "Языки", en: "Languages" },
"realtime.metric.route": { ru: "Маршрут", en: "Route" },
"realtime.statusMessage.off": {
ru: "Realtime Interpreter выключен.",
en: "Realtime Interpreter is off.",
},
// Settings fields
"realtime.field.realMic.label": {
ru: "Настоящий микрофон",
en: "Real microphone",
},
"realtime.field.realMic.note": {
ru: "Голос пользователя для RU → {lang}.",
en: "User's voice for RU → {lang}.",
},
"realtime.field.realMic.empty": {
ru: "Системный микрофон",
en: "System microphone",
},
"realtime.field.languagePair.label": { ru: "Пара языков", en: "Language pair" },
"realtime.field.languagePair.note": {
ru: "Русский остается локальным языком пользователя в v1.",
en: "Russian stays the user's local language in v1.",
},
"realtime.field.virtualMic.note": {
ru: "{driver} для приложения звонка.",
en: "{driver} for the call app.",
},
"realtime.field.virtualMic.empty": {
ru: "Установите {driver}",
en: "Install {driver}",
},
"realtime.field.localPlayback.label": {
ru: "Локальное прослушивание",
en: "Local monitoring",
},
"realtime.field.localPlayback.note": {
ru: "Русский перевод собеседника.",
en: "Russian translation of the other party.",
},
"realtime.field.localPlayback.empty": {
ru: "Системный output",
en: "System output",
},
"realtime.field.access.label": { ru: "Доступ", en: "Access" },
"realtime.field.access.note": {
ru: "V1 работает только через Talkis Cloud.",
en: "V1 works only via Talkis Cloud.",
},
"realtime.access.connected": {
ru: "Аккаунт подключен",
en: "Account connected",
},
"realtime.access.needLogin": { ru: "Нужен вход", en: "Sign-in required" },
// Headphones confirmation
"realtime.headphonesConfirm": {
ru: "Локальный перевод идет в наушники или отдельный output",
en: "Local translation goes to headphones or a separate output",
},
// Check and run section
"realtime.checkAndRun": { ru: "Проверка и запуск", en: "Check and start" },
"realtime.button.refresh.title": {
ru: "Обновить устройства",
en: "Refresh devices",
},
"realtime.button.refresh": { ru: "Обновить", en: "Refresh" },
"realtime.button.test.title": {
ru: "Тест virtual mic output",
en: "Test virtual mic output",
},
"realtime.button.test": { ru: "Тест", en: "Test" },
"realtime.button.stop.title": {
ru: "Остановить переводчик",
en: "Stop interpreter",
},
"realtime.button.stop": { ru: "Стоп", en: "Stop" },
"realtime.button.start.title": {
ru: "Запустить переводчик",
en: "Start interpreter",
},
"realtime.button.start": { ru: "Старт", en: "Start" },
// Live transcript panels
"realtime.panel.youToCall": { ru: "Вы → звонок", en: "You → call" },
"realtime.panel.callToYou": { ru: "Звонок → вы", en: "Call → you" },
"realtime.noLiveTranscript": {
ru: "Нет live transcript",
en: "No live transcript",
},
// Routing summary line
"realtime.routingSummary": {
ru: "Virtual mic: {virtualMic} · Playback: {playback}",
en: "Virtual mic: {virtualMic} · Playback: {playback}",
},
"realtime.routing.notSelected": { ru: "не выбран", en: "not selected" },
"realtime.routing.systemOutput": {
ru: "системный output",
en: "system output",
},
} as const;

View file

@ -0,0 +1,49 @@
// Strings for the summary slide-over modal + row action menus.
export const summary = {
"summary.button": { ru: "Саммари", en: "Summary" },
"summary.title": { ru: "Саммари", en: "Summary" },
"summary.close": { ru: "Закрыть", en: "Close" },
"summary.type": { ru: "Тип саммари", en: "Summary type" },
"summary.generate": { ru: "Сделать саммари", en: "Summarize" },
"summary.generating": { ru: "Генерируем…", en: "Generating…" },
"summary.regenerate": { ru: "Новое саммари", en: "New summary" },
"summary.progress.preparing": { ru: "Готовим саммари…", en: "Preparing summary…" },
"summary.progress.map": { ru: "Обрабатываем часть {current} из {total}…", en: "Processing part {current} of {total}…" },
"summary.progress.reduce": { ru: "Объединяем части…", en: "Merging parts…" },
"summary.noModel": {
ru: "Для саммари нужен вход в Talkis Cloud или указанная текстовая модель (вкладка «Модели»).",
en: "Summaries need a Talkis Cloud sign-in or a configured text model (the «Models» tab).",
},
"summary.empty.noText": { ru: "Нет текста для саммари.", en: "No text to summarize." },
"summary.unavailable.tooltip": {
ru: "Саммаризация недоступна — выберите текстовую модель в разделе «Модели»",
en: "Summarization unavailable — select a text model in the «Models» section",
},
"summary.unavailable.note": {
ru: "Саммаризация недоступна: выберите текстовую модель в разделе «Модели».",
en: "Summarization is unavailable: select a text model in the «Models» section.",
},
"summary.history.title": { ru: "История саммари", en: "Summary history" },
"summary.history.empty": { ru: "Здесь появятся сгенерированные саммари.", en: "Generated summaries will appear here." },
"summary.history.duration": { ru: "{seconds} с", en: "{seconds} s" },
"summary.copy": { ru: "Скопировать", en: "Copy" },
"summary.copied": { ru: "Скопировано", en: "Copied" },
"summary.edit": { ru: "Редактировать", en: "Edit" },
"summary.delete": { ru: "Удалить", en: "Delete" },
"summary.save": { ru: "Сохранить", en: "Save" },
"summary.cancel": { ru: "Отмена", en: "Cancel" },
"summary.expand": { ru: "Развернуть", en: "Expand" },
"summary.collapse": { ru: "Свернуть", en: "Collapse" },
"summary.actions": { ru: "Действия", en: "Actions" },
// History-row action menu (MainTab)
"rowMenu.edit": { ru: "Редактировать", en: "Edit" },
"rowMenu.copy": { ru: "Копировать", en: "Copy" },
"rowMenu.summarize": { ru: "Саммари", en: "Summary" },
"rowMenu.actions": { ru: "Действия", en: "Actions" },
} as const;

243
src/lib/i18n/dict/widget.ts Normal file
View file

@ -0,0 +1,243 @@
export const widget = {
// ── Widget.tsx: microphone start errors (call capture) ──────────────────
"widget.mic.permissionDenied": {
ru: "Разрешите микрофон для записи созвона.",
en: "Allow microphone access to record the call.",
},
"widget.mic.busy": {
ru: "Микрофон занят или недоступен. Закройте приложения, которые могут использовать микрофон, и попробуйте снова.",
en: "The microphone is busy or unavailable. Close apps that may be using the microphone and try again.",
},
"widget.mic.unavailable": {
ru: "Выбранный микрофон недоступен. Проверьте микрофон в настройках Talkis.",
en: "The selected microphone is unavailable. Check the microphone in Talkis settings.",
},
"widget.mic.startTimeout": {
ru: "Микрофон не успел запуститься. Попробуйте ещё раз.",
en: "The microphone didn't start in time. Try again.",
},
"widget.mic.accessFailed": {
ru: "Не удалось получить доступ к микрофону для записи созвона.",
en: "Couldn't access the microphone to record the call.",
},
// ── Widget.tsx: call capture start errors ───────────────────────────────
"widget.call.pipewireFailed": {
ru: "Не удалось начать запись системного звука Linux. Убедитесь, что PipeWire запущен и есть активное устройство вывода.",
en: "Couldn't start Linux system audio capture. Make sure PipeWire is running and an output device is active.",
},
"widget.call.systemAudioUnsupported": {
ru: "Запись системного звука не поддерживается на этой платформе.",
en: "System audio capture isn't supported on this platform.",
},
"widget.call.windowsOutputMissing": {
ru: "Не найдено устройство вывода Windows для записи системного звука.",
en: "No Windows output device found for system audio capture.",
},
"widget.call.windowsCaptureFailed": {
ru: "Не удалось начать запись системного звука Windows.",
en: "Couldn't start Windows system audio capture.",
},
"widget.call.systemAudioPermission": {
ru: "Разрешите запись звука системы для созвона.",
en: "Allow system audio capture for the call.",
},
"widget.call.startFailed": {
ru: "Не удалось начать запись созвона. Проверьте разрешения микрофона и звука системы.",
en: "Couldn't start the call recording. Check the microphone and system audio permissions.",
},
"widget.call.processFailed": {
ru: "Не удалось обработать запись. Попробуйте повторить попытку.",
en: "Couldn't process the recording. Please try again.",
},
// ── Widget.tsx: file processing / interrupted ───────────────────────────
"widget.processing.interrupted": {
ru: "Обработка остановлена. Можно запустить повторно.",
en: "Processing stopped. You can run it again.",
},
// ── Widget.tsx: FileDropPill text ───────────────────────────────────────
"widget.fileDrop.transcribingPercent": {
ru: "Транскрибация {percent}%",
en: "Transcribing {percent}%",
},
"widget.fileDrop.transcribing": {
ru: "Транскрибация",
en: "Transcribing",
},
"widget.fileDrop.release": {
ru: "Отпустите файл",
en: "Drop the file",
},
// ── Widget.tsx: CallBubble titles ───────────────────────────────────────
"widget.callBubble.error": {
ru: "Ошибка созвона",
en: "Call error",
},
"widget.callBubble.requestingAccess": {
ru: "Запрашиваем доступы",
en: "Requesting permissions",
},
"widget.callBubble.transcribing": {
ru: "Транскрибируем разговор",
en: "Transcribing the conversation",
},
"widget.callBubble.ready": {
ru: "Созвон готов",
en: "Call ready",
},
"widget.callBubble.stopAndTranscribe": {
ru: "Завершить и транскрибировать",
en: "Finish and transcribe",
},
"widget.callBubble.record": {
ru: "Запись разговора",
en: "Record the conversation",
},
// ── Widget.tsx: IdlePill / RecordingPill buttons ────────────────────────
"widget.idle.startRecording": {
ru: "Начать запись",
en: "Start recording",
},
"widget.idle.copyLatest": {
ru: "Скопировать последнюю запись",
en: "Copy the latest recording",
},
"widget.idle.copied": {
ru: "Скопировано",
en: "Copied",
},
"widget.idle.copy": {
ru: "Скопировать",
en: "Copy",
},
"widget.recording.stopRecording": {
ru: "Закончить запись",
en: "Stop recording",
},
// ── transcriptionPipeline.ts: user-facing transcription errors ──────────
"widget.error.modelNotDownloaded": {
ru: "Локальный runtime запущен, но модель {model} ещё не скачана. Откройте Настройки -> Модели -> Локально и нажмите «Скачать».",
en: "The local runtime is running, but the {model} model hasn't been downloaded yet. Open Settings -> Models -> Local and click “Download”.",
},
"widget.error.localRuntimeStartFailed": {
ru: "Не удалось запустить локальный runtime распознавания. Откройте Настройки -> Модели -> Локально и нажмите «Скачать» для нужной Whisper-модели.",
en: "Couldn't start the local recognition runtime. Open Settings -> Models -> Local and click “Download” for the Whisper model you need.",
},
"widget.error.regionUnsupported": {
ru: "Сервис распознавания сейчас недоступен в вашем регионе. Попробуйте другой endpoint или VPN.",
en: "The recognition service is currently unavailable in your region. Try a different endpoint or a VPN.",
},
"widget.error.subscriptionRequired": {
ru: "Для облачного режима нужна активная подписка Talkis.",
en: "Cloud mode requires an active Talkis subscription.",
},
"widget.error.requestRejected": {
ru: "Сервис отклонил запрос. Проверьте API-ключ, регион доступа или настройки endpoint.",
en: "The service rejected the request. Check the API key, access region, or endpoint settings.",
},
"widget.error.cloudSessionExpired": {
ru: "Сессия Talkis Cloud истекла. Войдите в облако заново.",
en: "Your Talkis Cloud session has expired. Sign in to the cloud again.",
},
"widget.error.cloudSignInRequired": {
ru: "Войдите в Talkis Cloud заново, чтобы использовать облачный режим.",
en: "Sign in to Talkis Cloud again to use cloud mode.",
},
"widget.error.cloudInvalidResponse": {
ru: "Talkis Cloud вернул некорректный ответ. Попробуйте отправить запись ещё раз.",
en: "Talkis Cloud returned an invalid response. Try submitting the recording again.",
},
"widget.error.cloudUnavailable": {
ru: "Talkis Cloud временно недоступен. Попробуйте ещё раз через несколько секунд.",
en: "Talkis Cloud is temporarily unavailable. Try again in a few seconds.",
},
"widget.error.authFailed": {
ru: "Не удалось авторизоваться в API. Проверьте ваш ключ доступа.",
en: "Couldn't authenticate with the API. Check your access key.",
},
"widget.error.rateLimited": {
ru: "Превышен лимит запросов или закончилась квота API. Попробуйте позже.",
en: "The request limit was exceeded or the API quota ran out. Try again later.",
},
"widget.error.networkFailed": {
ru: "Не удалось связаться с сервером. Проверьте интернет и попробуйте снова.",
en: "Couldn't reach the server. Check your internet connection and try again.",
},
"widget.error.serverUnavailable": {
ru: "Сервис временно недоступен. Попробуйте повторить отправку чуть позже.",
en: "The service is temporarily unavailable. Try resubmitting a little later.",
},
"widget.error.processingFailed": {
ru: "Не удалось обработать запись. Попробуйте отправить ее повторно.",
en: "Couldn't process the recording. Try submitting it again.",
},
"widget.error.noSavedAudio": {
ru: "У этой записи нет сохраненного аудио для повторной отправки.",
en: "This entry has no saved audio to resubmit.",
},
"widget.error.speechNotRecognized": {
ru: "Речь не распознана. Попробуйте отправить запись еще раз.",
en: "No speech recognized. Try submitting the recording again.",
},
// ── useWidgetRecording.ts: status / error notices ───────────────────────
"widget.recording.lowMic": {
ru: "Микрофон слышит слишком тихо. Поднесите его ближе или проверьте выбранное устройство.",
en: "The microphone is picking up too quietly. Move it closer or check the selected device.",
},
"widget.recording.settingsNotLoaded": {
ru: "Настройки не загружены. Перезапустите приложение.",
en: "Settings aren't loaded. Restart the app.",
},
"widget.recording.cloudSignInRequired": {
ru: "Войдите в Talkis Cloud заново, чтобы использовать облачный режим.",
en: "Sign in to Talkis Cloud again to use cloud mode.",
},
"widget.recording.noModelConfigured": {
ru: "Сначала установите модель в «Настройки → Модели»: облако, локальная модель или свой API-ключ.",
en: "First set up a model in “Settings → Models”: cloud, a local model, or your own API key.",
},
"widget.recording.micAccessDenied": {
ru: "Нет доступа к микрофону. Разрешите доступ в системных настройках.",
en: "No microphone access. Allow access in your system settings.",
},
"widget.recording.startError": {
ru: "Ошибка запуска записи: {error}",
en: "Recording start error: {error}",
},
"widget.recording.unknownError": {
ru: "Неизвестная ошибка",
en: "Unknown error",
},
"widget.recording.noAudioRecorded": {
ru: "Аудио не записано. Попробуйте еще раз.",
en: "No audio was recorded. Try again.",
},
"widget.recording.speechNotRecognized": {
ru: "Речь не распознана. Попробуйте еще раз.",
en: "No speech recognized. Try again.",
},
"widget.recording.processingError": {
ru: "Ошибка обработки",
en: "Processing error",
},
// ── useWidgetHotkey.ts: hotkey errors ───────────────────────────────────
"widget.hotkey.invalidFormat": {
ru: "Неверный формат горячей клавиши",
en: "Invalid hotkey format",
},
"widget.hotkey.registerFailed": {
ru: "Не удалось зарегистрировать горячую клавишу \"{hotkey}\". Возможно, сочетание занято другим приложением.",
en: "Couldn't register the hotkey \"{hotkey}\". The combination may be in use by another app.",
},
"widget.hotkey.registerFailedGeneric": {
ru: "Не удалось зарегистрировать горячую клавишу.",
en: "Couldn't register the hotkey.",
},
} as const;

117
src/lib/i18n/index.tsx Normal file
View file

@ -0,0 +1,117 @@
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { listen } from "@tauri-apps/api/event";
import { getSettings } from "../store";
import { SETTINGS_UPDATED_EVENT } from "../hotkeyEvents";
import { MESSAGES, type MsgKey } from "./messages";
export type UiLanguage = "ru" | "en";
export type { MsgKey };
const LS_KEY = "talkis.uiLanguage";
/** Best-effort OS language guess from the webview locale (RU is the fallback). */
export function detectOsLanguage(): UiLanguage {
const nav =
(typeof navigator !== "undefined" && navigator.language) || "";
return /^en/i.test(nav) ? "en" : "ru";
}
/** Resolve a stored preference (or undefined) into a concrete UI language. */
export function resolveUiLanguage(stored?: string | null): UiLanguage {
return stored === "en" || stored === "ru" ? stored : detectOsLanguage();
}
type Vars = Record<string, string | number>;
export type TFunc = (key: MsgKey, vars?: Vars) => string;
export function translate(lang: UiLanguage, key: MsgKey, vars?: Vars): string {
const entry = MESSAGES[key] as { ru: string; en: string } | undefined;
let text = entry ? entry[lang] ?? entry.ru : (key as string);
if (vars) {
for (const name of Object.keys(vars)) {
text = text.split(`{${name}}`).join(String(vars[name]));
}
}
return text;
}
// Module-level current language so non-React code (lib/*.ts: status/error
// messages outside the component tree) can translate via `tn()`. The provider
// keeps this in sync with the active UI language.
let currentLang: UiLanguage = resolveUiLanguage(
typeof localStorage !== "undefined" ? localStorage.getItem(LS_KEY) : null,
);
export function getCurrentLanguage(): UiLanguage {
return currentLang;
}
/** Translate-now: for use outside React components (no hook). */
export function tn(key: MsgKey, vars?: Vars): string {
return translate(currentLang, key, vars);
}
interface I18nValue {
lang: UiLanguage;
t: TFunc;
}
const I18nContext = createContext<I18nValue | null>(null);
/**
* Wraps a window root. Reads the UI language from the shared settings store and
* re-reads it on SETTINGS_UPDATED_EVENT, so switching the language in the
* Settings window updates every open window. localStorage is only a per-window
* cache to avoid a flash of the wrong language on first paint.
*/
export function I18nProvider({ children }: { children: ReactNode }) {
const [lang, setLang] = useState<UiLanguage>(() => currentLang);
useEffect(() => {
let active = true;
const apply = async (): Promise<void> => {
try {
const settings = await getSettings({ reload: true });
const next = resolveUiLanguage(settings.uiLanguage);
if (!active) return;
currentLang = next;
setLang(next);
try {
localStorage.setItem(LS_KEY, next);
} catch {
/* localStorage unavailable — ignore */
}
} catch {
/* keep current language on read failure */
}
};
void apply();
const unlisten = listen(SETTINGS_UPDATED_EVENT, () => {
void apply();
});
return () => {
active = false;
void unlisten.then((dispose) => dispose());
};
}, []);
const t: TFunc = (key, vars) => translate(lang, key, vars);
return (
<I18nContext.Provider value={{ lang, t }}>{children}</I18nContext.Provider>
);
}
export function useI18n(): I18nValue {
const ctx = useContext(I18nContext);
if (ctx) return ctx;
// Defensive fallback so a component still renders if used outside a provider.
return { lang: "ru", t: (key, vars) => translate("ru", key, vars) };
}

27
src/lib/i18n/messages.ts Normal file
View file

@ -0,0 +1,27 @@
// Central message registry. Each area lives in its own file under ./dict so that
// translations can be added per-feature without merge conflicts; they are merged
// here into one typed lookup. To add a new area: create ./dict/<area>.ts exporting
// `as const`, import it, and spread it into MESSAGES below.
import { common } from "./dict/common";
import { settingsGeneral } from "./dict/settingsGeneral";
import { settingsModels } from "./dict/settingsModels";
import { settingsTabsMisc } from "./dict/settingsTabsMisc";
import { settingsRest } from "./dict/settingsRest";
import { widget } from "./dict/widget";
import { components } from "./dict/components";
import { libMessages } from "./dict/libMessages";
import { summary } from "./dict/summary";
export const MESSAGES = {
...common,
...settingsGeneral,
...settingsModels,
...settingsTabsMisc,
...settingsRest,
...widget,
...components,
...libMessages,
...summary,
} as const;
export type MsgKey = keyof typeof MESSAGES;

View file

@ -3,13 +3,14 @@ import { emit } from "@tauri-apps/api/event";
import { addHistoryEntry, updateHistoryEntry, type HistoryEntry } from "./store";
import { HISTORY_UPDATED_EVENT } from "./hotkeyEvents";
import { logError, logInfo } from "./logger";
import { tn } from "./i18n";
// Cancellation registry for in-flight transcription jobs. Lives in the widget
// process (where all transcription runs). The history table in the Settings
// window triggers cancellation by emitting PROCESSING_CANCEL_REQUEST_EVENT,
// which the widget forwards to cancelProcessing().
const INTERRUPTED_MESSAGE = "Обработка остановлена. Можно запустить повторно.";
const interruptedMessage = (): string => tn("processing.interrupted");
interface ActiveProcessing {
cancelled: boolean;
@ -102,7 +103,7 @@ export async function cancelProcessing(entryId: string): Promise<boolean> {
try {
await persist(
{ ...item.entry, status: "interrupted", errorMessage: INTERRUPTED_MESSAGE },
{ ...item.entry, status: "interrupted", errorMessage: interruptedMessage() },
"update",
);
} catch (error) {

View file

@ -4,6 +4,19 @@ import { load } from "@tauri-apps/plugin-store";
import { DEFAULT_WIDGET_SCALE, normalizeWidgetScale } from "./widgetScale";
import { recordTranscriptionStats } from "./stats";
export interface SummaryEntry {
id: string;
/** ISO timestamp when this summary was generated. */
createdAt: string;
/** Generation time in milliseconds. */
durationMs: number;
/** Id of the summary prompt preset used. */
promptId: string;
/** Display name of the preset at generation time (so old summaries keep their label). */
promptName: string;
text: string;
}
export interface HistoryEntry {
id: string;
timestamp: string;
@ -34,6 +47,8 @@ export interface HistoryEntry {
speakers?: Speaker[];
segments?: SpeakerTranscriptSegment[];
translation?: RealtimeTranslationHistory;
/** Generated summaries for this record (newest first); persists across restarts. */
summaries?: SummaryEntry[];
}
export interface Speaker {
@ -150,7 +165,10 @@ export interface AppSettings {
/** Floating widget visual scale. 1 = 100%. */
widgetScale: number;
theme: ThemePreference;
/** Transcription/recognition language (not the UI language). */
language: string;
/** UI/interface language. Undefined = auto-detect from OS on first run. */
uiLanguage?: "ru" | "en";
doubleTapTimeout: number;
style: "classic" | "business" | "tech";
/** User-defined prompt presets (built-in presets are merged in at runtime). */
@ -448,7 +466,7 @@ export const BUILTIN_PROMPTS: PromptPreset[] = [
builtin: true,
temperature: 0.3,
prompt:
"Сделай структурированное саммари разговора на русском: основные темы, ключевые решения и важные детали. Используй маркированные списки. Не добавляй то, чего нет в тексте.",
"Сделай саммари разговора на русском в виде маркированного списка: каждый пункт — отдельный ключевой момент, факт или вывод. Будь лаконичен и не выдумывай того, чего нет в тексте.",
},
{
id: "summary-actions",
@ -720,6 +738,10 @@ function normalizeSavedSettings(saved: unknown): Partial<AppSettings> {
: normalizeWidgetScale(raw.widgetScale),
theme: parseTheme(raw.theme),
language: typeof raw.language === "string" ? raw.language : undefined,
uiLanguage:
raw.uiLanguage === "en" || raw.uiLanguage === "ru"
? raw.uiLanguage
: undefined,
doubleTapTimeout:
typeof raw.doubleTapTimeout === "number"
? raw.doubleTapTimeout
@ -1028,6 +1050,63 @@ export async function deleteHistoryEntry(id: string): Promise<void> {
await writeHistory(history.filter((e) => e.id !== id));
}
/** Prepend a generated summary to a history entry and persist. Returns the updated entry. */
export async function addSummaryToEntry(
entryId: string,
summary: SummaryEntry,
): Promise<HistoryEntry | null> {
const history = await getHistory();
let updatedEntry: HistoryEntry | null = null;
const updated = history.map((item) => {
if (item.id !== entryId) return item;
updatedEntry = { ...item, summaries: [summary, ...(item.summaries ?? [])] };
return updatedEntry;
});
if (updatedEntry) await writeHistory(updated);
return updatedEntry;
}
/** Replace the text of one summary on an entry (used by inline edit). */
export async function updateSummaryInEntry(
entryId: string,
summaryId: string,
text: string,
): Promise<HistoryEntry | null> {
const history = await getHistory();
let updatedEntry: HistoryEntry | null = null;
const updated = history.map((item) => {
if (item.id !== entryId) return item;
updatedEntry = {
...item,
summaries: (item.summaries ?? []).map((s) =>
s.id === summaryId ? { ...s, text } : s,
),
};
return updatedEntry;
});
if (updatedEntry) await writeHistory(updated);
return updatedEntry;
}
/** Remove one summary from an entry and persist. */
export async function deleteSummaryFromEntry(
entryId: string,
summaryId: string,
): Promise<HistoryEntry | null> {
const history = await getHistory();
let updatedEntry: HistoryEntry | null = null;
const updated = history.map((item) => {
if (item.id !== entryId) return item;
updatedEntry = {
...item,
summaries: (item.summaries ?? []).filter((s) => s.id !== summaryId),
};
return updatedEntry;
});
if (updatedEntry) await writeHistory(updated);
return updatedEntry;
}
/**
* Any entry still marked "processing" when the app starts is an orphan its
* in-flight job died with the previous process (crash, quit, freeze). Mark such

View file

@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
import { AppSettings, HistoryEntry } from "./store";
import { processTextWithCloudPrompt } from "./cloudTextProcessing";
import { tn } from "./i18n";
/**
* Long transcripts (videos / hour-long calls) don't fit a model's context, so
@ -169,6 +170,16 @@ export function resolveSummaryBackend(
return null;
}
/**
* True when summarization can run with the current settings i.e. a text
* backend is resolvable (cloud sign-in, a custom API, or a local runtime).
* When false, summary triggers should be disabled and the user pointed at the
* «Models» tab to pick a text model.
*/
export function isSummaryAvailable(settings: AppSettings): boolean {
return resolveSummaryBackend(settings) !== null;
}
async function runMapReduce(
text: string,
instruction: string,
@ -230,17 +241,15 @@ export async function summarizeTranscript({
const trimmed = text.trim();
if (!trimmed) {
throw new Error("Нет текста для обработки");
throw new Error(tn("summarize.errNoText"));
}
if (!instruction) {
throw new Error("Промпт пустой");
throw new Error(tn("summarize.errEmptyPrompt"));
}
const backend = resolveSummaryBackend(settings);
if (!backend) {
throw new Error(
"Нет доступной модели для summary: войдите в Talkis Cloud или укажите текстовую модель во вкладке «Модели».",
);
throw new Error(tn("summarize.errNoModel"));
}
return runMapReduce(

View file

@ -4,6 +4,7 @@ import { Widget } from "./windows/widget/Widget";
import { WidgetNoticeOverlay } from "./windows/widget/WidgetNoticeOverlay";
import { SettingsApp } from "./windows/settings/SettingsApp";
import { applySavedTheme, applyThemePreference } from "./lib/theme";
import { I18nProvider } from "./lib/i18n";
// Route based on Tauri window label passed via URL hash or query
// Widget window opens at "/" — Settings window opens at "/settings"
@ -18,5 +19,7 @@ if (isSettings) {
}
ReactDOM.createRoot(document.getElementById("root")!).render(
isSettings ? <SettingsApp /> : isWidgetNotice ? <WidgetNoticeOverlay /> : <Widget />
<I18nProvider>
{isSettings ? <SettingsApp /> : isWidgetNotice ? <WidgetNoticeOverlay /> : <Widget />}
</I18nProvider>
);

View file

@ -46,6 +46,7 @@ import {
subscribeToAppUpdateState,
type AppUpdateState,
} from "../../lib/updater";
import { useI18n, type MsgKey } from "../../lib/i18n";
type Tab = "main" | "file" | "interpreter" | "settings" | "model" | "style";
@ -71,33 +72,33 @@ function resolveInitialTab(): Tab {
return "main";
}
const TABS: { id: Tab; label: string; icon: LucideIcon; note: string }[] = [
{ id: "main", label: "Главная", icon: Home, note: "История записей" },
const TABS: { id: Tab; labelKey: MsgKey; icon: LucideIcon; note: string }[] = [
{ id: "main", labelKey: "settingsApp.tab.main", icon: Home, note: "История записей" },
{
id: "file",
label: "Транскрибация",
labelKey: "settingsApp.tab.file",
icon: FileAudio,
note: "Транскрибация",
},
{
id: "interpreter",
label: "Переводчик (бета)",
labelKey: "settingsApp.tab.interpreter",
icon: Languages,
note: "Realtime Interpreter beta",
},
{
id: "settings",
label: "Настройки",
labelKey: "settingsApp.tab.settings",
icon: Sliders,
note: "Язык, микрофон и горячая клавиша",
},
{
id: "model",
label: "Модели",
labelKey: "settingsApp.tab.model",
icon: Cpu,
note: "Ключи и подключение модели",
},
{ id: "style", label: "Стиль и Промпты", icon: Sparkles, note: "Стиль обработки и Промпты для summary" },
{ id: "style", labelKey: "settingsApp.tab.style", icon: Sparkles, note: "Стиль обработки и Промпты для summary" },
];
function TabButton({
@ -109,6 +110,7 @@ function TabButton({
isActive: boolean;
onClick: () => void;
}) {
const { t } = useI18n();
const Icon = tab.icon;
return (
@ -118,7 +120,7 @@ function TabButton({
style={{ width: "100%", textAlign: "left", font: "inherit" }}
>
<Icon size={18} strokeWidth={isActive ? 2.2 : 1.6} />
<span>{tab.label}</span>
<span>{t(tab.labelKey)}</span>
</button>
);
}
@ -175,6 +177,7 @@ function formatUpdateVersion(version: string): string {
}
function AppUpdateFooter(): ReactElement | null {
const { t } = useI18n();
const [version, setVersion] = useState<string | null>(null);
const [updateState, setUpdateState] = useState<AppUpdateState>({
status: "idle",
@ -277,8 +280,8 @@ function AppUpdateFooter(): ReactElement | null {
)}
<span>
{installing
? "Устанавливаем..."
: `Установить обновление ${updateVersion}`}
? t("settingsApp.installing")
: t("settingsApp.installUpdate", { version: updateVersion })}
</span>
</button>
@ -291,7 +294,7 @@ function AppUpdateFooter(): ReactElement | null {
textAlign: "center",
}}
>
Не удалось установить обновление
{t("settingsApp.updateFailed")}
</div>
)}
</div>
@ -309,8 +312,8 @@ function AppUpdateFooter(): ReactElement | null {
void openUrl(GITHUB_REPO_URL);
}
}}
aria-label={`Версия приложения ${version}. Открыть проект на GitHub`}
title="Проект на GitHub"
aria-label={t("settingsApp.versionAria", { version })}
title={t("settingsApp.githubTitle")}
style={{
display: "flex",
alignItems: "center",
@ -337,6 +340,7 @@ function AppUpdateFooter(): ReactElement | null {
}
export function SettingsApp() {
const { t } = useI18n();
const [activeTab, setActiveTab] = useState<Tab>(resolveInitialTab);
const [focusedFileResultId, setFocusedFileResultId] = useState<string | null>(
() => new URLSearchParams(window.location.search).get("resultId"),
@ -388,9 +392,7 @@ export function SettingsApp() {
);
setInitialHistory([]);
setShowPermissions(false);
setLoadError(
"Не удалось загрузить состояние приложения. Некоторые данные могут быть недоступны.",
);
setLoadError(t("settingsApp.loadError"));
});
}, []);
@ -435,7 +437,7 @@ export function SettingsApp() {
>
<div className="card" style={{ width: 420, textAlign: "center" }}>
<div style={{ fontSize: 14, color: "var(--text-mid)" }}>
Загружаем настройки
{t("settingsApp.loading")}
</div>
</div>
</div>

View file

@ -9,6 +9,7 @@ import {
Check,
Clipboard,
FileAudio,
ListChecks,
Loader2,
X,
} from "lucide-react";
@ -23,10 +24,11 @@ import {
type AppSettings,
type SpeakerTranscriptSegment,
} from "../../../lib/store";
import { HISTORY_UPDATED_EVENT } from "../../../lib/hotkeyEvents";
import { HISTORY_UPDATED_EVENT, SETTINGS_UPDATED_EVENT } from "../../../lib/hotkeyEvents";
import { formatErrorMessage } from "../../../lib/utils";
import { transcriptToText } from "../../../lib/summarize";
import { SummaryPanel } from "../../../components/SummaryPanel";
import { isSummaryAvailable } from "../../../lib/summarize";
import { SummaryModal } from "../../../components/SummaryModal";
import { useI18n, type TFunc } from "../../../lib/i18n";
import {
canUseCloudSpeakerDiarization,
fileNameFromPath,
@ -118,23 +120,27 @@ function getDiarizationWhisperOption(
function statusLabel(
status: ProcessingState,
progress: FileTranscriptionProgress | null,
t: TFunc,
): string {
if (status === "reading") return "Читаем файл";
if (status === "converting") return "Извлекаем и сжимаем аудио";
if (status === "uploading") return "Отправляем на транскрибацию";
if (status === "preparing") return progress?.message || "Готовим файл";
if (status === "diarizing") return progress?.message || "Разделяем говорящих";
if (status === "reading") return t("fileTab.status.reading");
if (status === "converting") return t("fileTab.status.converting");
if (status === "uploading") return t("fileTab.status.uploading");
if (status === "preparing") return progress?.message || t("fileTab.status.preparing");
if (status === "diarizing") return progress?.message || t("fileTab.status.diarizing");
if (status === "transcribing") {
if (progress && progress.totalChunks > 0) {
return `Распознаём фрагмент ${progress.currentChunk} из ${progress.totalChunks}`;
return t("fileTab.status.transcribingChunk", {
current: progress.currentChunk,
total: progress.totalChunks,
});
}
return "Распознаём фрагменты";
return t("fileTab.status.transcribing");
}
if (status === "assembling") return progress?.message || "Собираем протокол";
if (status === "done") return "Готово";
if (status === "error") return "Ошибка";
return "Ожидаем файл";
if (status === "assembling") return progress?.message || t("fileTab.status.assembling");
if (status === "done") return t("fileTab.status.done");
if (status === "error") return t("fileTab.status.error");
return t("fileTab.status.idle");
}
function formatTimestamp(seconds: number): string {
@ -154,10 +160,10 @@ function formatSpeakerTranscript(segments: SpeakerTranscriptSegment[]): string {
.join("\n");
}
function resultSourceLabel(entry: HistoryEntry): string {
if (entry.source === "call") return "Созвон";
if (entry.source === "file") return "Файл";
return "Голос";
function resultSourceLabel(entry: HistoryEntry, t: TFunc): string {
if (entry.source === "call") return t("fileTab.source.call");
if (entry.source === "file") return t("fileTab.source.file");
return t("fileTab.source.voice");
}
function requirementStatusColor(isReady: boolean): string {
@ -180,20 +186,20 @@ function isSpeakerSetupRepairError(error: unknown): boolean {
);
}
function toSpeakerSetupErrorMessage(error: unknown): string {
function toSpeakerSetupErrorMessage(error: unknown, t: TFunc): string {
const raw = formatErrorMessage(error);
const normalized = raw.toLowerCase();
if (normalized.includes("401") || normalized.includes("api-ключ")) {
return "Не удалось подготовить локальные компоненты: STT runtime отклонил API-ключ.";
return t("fileTab.setupError.rejectedKey");
}
if (normalized.includes("403")) {
return "Не удалось подготовить локальные компоненты: STT runtime запретил установку модели.";
return t("fileTab.setupError.forbidden");
}
if (normalized.includes("timeout") || normalized.includes("timed out")) {
return "Не удалось подготовить локальные компоненты: локальный STT runtime не ответил вовремя.";
return t("fileTab.setupError.timeout");
}
if (
@ -202,7 +208,7 @@ function toSpeakerSetupErrorMessage(error: unknown): string {
normalized.includes("python3-venv") ||
normalized.includes("no module named pip")
) {
return "Не удалось подготовить локальные компоненты: в системе нет Python venv/pip. Установите пакет python3.12-venv и повторите скачивание.";
return t("fileTab.setupError.noVenv");
}
if (
@ -214,8 +220,8 @@ function toSpeakerSetupErrorMessage(error: unknown): string {
}
return raw.trim()
? `Не удалось подготовить локальные компоненты для разделения по говорящим: ${raw}`
: "Не удалось подготовить локальные компоненты для разделения по говорящим.";
? t("fileTab.setupError.genericWithDetail", { detail: raw })
: t("fileTab.setupError.generic");
}
async function refreshSpeakerInstalledModels(
@ -291,6 +297,7 @@ async function refreshSpeakerInstalledModels(
export function FileTranscriptionTab({
focusedEntryId = null,
}: FileTranscriptionTabProps = {}): JSX.Element {
const { t } = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
const resultSectionRef = useRef<HTMLElement | null>(null);
const isProcessingRef = useRef(false);
@ -311,6 +318,8 @@ export function FileTranscriptionTab({
const [convertedInfo, setConvertedInfo] = useState("");
const [isDragOver, setIsDragOver] = useState(false);
const [copied, setCopied] = useState(false);
const [summaryOpen, setSummaryOpen] = useState(false);
const [summaryAvailable, setSummaryAvailable] = useState(false);
const [resultExpanded, setResultExpanded] = useState(false);
const [speakerDiarization, setSpeakerDiarization] = useState(false);
const [diarizationInstalled, setDiarizationInstalled] = useState(false);
@ -360,6 +369,7 @@ export function FileTranscriptionTab({
if (!mounted) return;
settingsRef.current = settings;
setSummaryAvailable(isSummaryAvailable(settings));
setSpeakerDiarization(settings.fileSpeakerDiarization);
syncSpeakerSetupState(settings);
@ -373,6 +383,7 @@ export function FileTranscriptionTab({
if (!mounted) return;
settingsRef.current = syncedSettings;
setSummaryAvailable(isSummaryAvailable(syncedSettings));
syncSpeakerSetupState(syncedSettings);
if (speakerDiarizationToggleRunRef.current !== validationRun) {
return;
@ -399,6 +410,21 @@ export function FileTranscriptionTab({
};
}, []);
// Keep the summary availability in sync when the text model is changed from
// the «Models» tab (same window) or another window.
useEffect(() => {
let mounted = true;
const unlisten = listen(SETTINGS_UPDATED_EVENT, () => {
void getSettings({ reload: true }).then((settings) => {
if (mounted) setSummaryAvailable(isSummaryAvailable(settings));
});
});
return () => {
mounted = false;
void unlisten.then((dispose) => dispose());
};
}, []);
const resetResult = (): void => {
setResultEntry(null);
setError("");
@ -413,7 +439,7 @@ export function FileTranscriptionTab({
scrollToResult = false,
): void => {
setSelectedFile({
name: entry.fileName || "Файл",
name: entry.fileName || t("fileTab.source.file"),
size: entry.fileSize ?? null,
});
setError("");
@ -496,9 +522,7 @@ export function FileTranscriptionTab({
try {
if (speakerDiarization) {
throw new Error(
"Разделение по говорящим доступно для файлов, выбранных через системный диалог или перетаскиванием в окно Talkis.",
);
throw new Error(t("fileTab.error.speakerNeedsDialog"));
}
const settings = await getSettings();
const startedAt = Date.now();
@ -526,7 +550,10 @@ export function FileTranscriptionTab({
setResultEntry(entry);
setConvertedInfo(
transcription.converted
? `Отправлено как ${transcription.uploadedFileName}, ${formatFileSize(transcription.uploadedSizeBytes)}`
? t("fileTab.convertedInfo", {
name: transcription.uploadedFileName,
size: formatFileSize(transcription.uploadedSizeBytes),
})
: "",
);
setStatus("done");
@ -658,7 +685,7 @@ export function FileTranscriptionTab({
multiple: false,
filters: [
{
name: "Аудио и видео",
name: t("fileTab.dialog.audioVideoFilter"),
extensions: [
"mp3",
"wav",
@ -722,7 +749,7 @@ export function FileTranscriptionTab({
if (speakerSetupInstalling) return;
setSpeakerSetupInstalling(true);
setSpeakerSetupMessage("Готовим локальные компоненты...");
setSpeakerSetupMessage(t("fileTab.setup.preparing"));
setSpeakerSetupError("");
try {
let settings = await getSettings({ reload: true });
@ -731,7 +758,7 @@ export function FileTranscriptionTab({
if (!getDiarizationWhisperOption(settings)) {
setSpeakerSetupMessage(
`Скачиваем ${RECOMMENDED_LOCAL_WHISPER_LABEL}...`,
t("fileTab.setup.downloading", { name: RECOMMENDED_LOCAL_WHISPER_LABEL }),
);
const whisperResult = await invoke<{
success: boolean;
@ -767,8 +794,8 @@ export function FileTranscriptionTab({
) {
setSpeakerSetupMessage(
speakerSetupForceRepair
? "Восстанавливаем runtime для разметки..."
: "Скачиваем компоненты для разметки говорящих...",
? t("fileTab.setup.repairing")
: t("fileTab.setup.downloadingDiarization"),
);
const speakerResult = await invoke<{
success: boolean;
@ -805,8 +832,8 @@ export function FileTranscriptionTab({
syncSpeakerSetupState(finalSettings);
setSpeakerSetupMessage(
speakerSetupIntent === "toggle"
? "Готово."
: "Готово. Продолжаем обработку файла...",
? t("fileTab.setup.done")
: t("fileTab.setup.doneContinue"),
);
setSpeakerSetupError("");
setError("");
@ -828,7 +855,7 @@ export function FileTranscriptionTab({
}
setSpeakerSetupIntent(null);
} catch (caughtError) {
setSpeakerSetupError(toSpeakerSetupErrorMessage(caughtError));
setSpeakerSetupError(toSpeakerSetupErrorMessage(caughtError, t));
} finally {
setSpeakerSetupInstalling(false);
}
@ -958,7 +985,7 @@ export function FileTranscriptionTab({
shouldCollapseResult && !resultExpanded
? `${resultText.slice(0, RESULT_PREVIEW_LIMIT).trimEnd()}...`
: resultText;
const speakerSetupActionLabel = "Скачать";
const speakerSetupActionLabel = t("common.download");
const closeSpeakerSetupModal = (): void => {
if (speakerSetupInstalling) return;
@ -987,12 +1014,12 @@ export function FileTranscriptionTab({
letterSpacing: "-0.03em",
}}
>
Транскрибация
{t("fileTab.heading")}
</h2>
<div
style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}
>
Голый текст без дополнительного форматирования.
{t("fileTab.subheading")}
</div>
</div>
</section>
@ -1079,7 +1106,7 @@ export function FileTranscriptionTab({
<div
style={{ fontSize: 15, fontWeight: 700, color: "var(--text-hi)" }}
>
{selectedFile ? selectedFile.name : "Перетащите аудио или видео"}
{selectedFile ? selectedFile.name : t("fileTab.dropzone.title")}
</div>
<div
style={{
@ -1089,8 +1116,8 @@ export function FileTranscriptionTab({
}}
>
{selectedFile
? `${selectedFile.size !== null ? `${formatFileSize(selectedFile.size)} · ` : ""}${statusLabel(status, progress)}${isProcessing ? ` · ${progressPercent}%` : ""}`
: "Нажмите на область или перетащите файл. MP3, WAV, M4A, MP4, MOV, WEBM и другие форматы"}
? `${selectedFile.size !== null ? `${formatFileSize(selectedFile.size)} · ` : ""}${statusLabel(status, progress, t)}${isProcessing ? ` · ${progressPercent}%` : ""}`
: t("fileTab.dropzone.hint")}
</div>
{convertedInfo && (
<div
@ -1169,12 +1196,12 @@ export function FileTranscriptionTab({
marginBottom: 3,
}}
>
Разделить по говорящим
{t("fileTab.speaker.toggleTitle")}
</div>
<div
style={{ fontSize: 12, color: "var(--text-mid)", lineHeight: 1.5 }}
>
Протокол с таймкодами и метками Гость 1, Гость 2.
{t("fileTab.speaker.toggleDesc")}
</div>
</div>
<button
@ -1230,7 +1257,7 @@ export function FileTranscriptionTab({
<div
style={{ fontSize: 14, fontWeight: 700, color: "var(--text-hi)" }}
>
Результат
{t("fileTab.result.title")}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
@ -1248,7 +1275,21 @@ export function FileTranscriptionTab({
) : (
<Clipboard size={13} strokeWidth={2} />
)}
{copied ? "Скопировано" : "Скопировать"}
{copied ? t("fileTab.result.copied") : t("fileTab.result.copy")}
</button>
)}
{resultEntry && (
<button
type="button"
className="btn"
onClick={() => setSummaryOpen(true)}
disabled={!summaryAvailable}
title={summaryAvailable ? undefined : t("summary.unavailable.tooltip")}
style={{ minHeight: 32, padding: "0 12px", gap: 6, opacity: summaryAvailable ? 1 : 0.5, cursor: summaryAvailable ? "pointer" : "not-allowed" }}
>
<ListChecks size={13} strokeWidth={2} />
{t("summary.button")}
</button>
)}
@ -1262,7 +1303,7 @@ export function FileTranscriptionTab({
resetResult();
}}
style={{ width: 32, minWidth: 32, minHeight: 32, padding: 0 }}
title="Очистить"
title={t("fileTab.result.clear")}
>
<X size={14} strokeWidth={2} />
</button>
@ -1270,10 +1311,27 @@ export function FileTranscriptionTab({
</div>
</div>
{resultEntry && (
<SummaryPanel
key={resultEntry.id}
text={transcriptToText(resultEntry)}
{resultEntry && !summaryAvailable && (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 8,
fontSize: 12,
lineHeight: 1.55,
color: "var(--text-mid)",
}}
>
<AlertCircle size={14} strokeWidth={2} style={{ flexShrink: 0, marginTop: 1, color: "var(--accent)" }} />
<span>{t("summary.unavailable.note")}</span>
</div>
)}
{summaryOpen && resultEntry && summaryAvailable && (
<SummaryModal
entry={resultEntry}
onClose={() => setSummaryOpen(false)}
onEntryChange={setResultEntry}
/>
)}
@ -1300,7 +1358,7 @@ export function FileTranscriptionTab({
fontSize: 12,
fontWeight: 650,
}}
aria-label={`Имя ${speaker.label}`}
aria-label={t("fileTab.speaker.nameAria", { name: speaker.label })}
/>
))}
</div>
@ -1357,8 +1415,8 @@ export function FileTranscriptionTab({
<table className="b-table" style={{ background: "transparent" }}>
<thead>
<tr>
<th style={{ width: 92 }}>Время</th>
<th style={{ paddingLeft: 8 }}>Текст</th>
<th style={{ width: 92 }}>{t("fileTab.table.time")}</th>
<th style={{ paddingLeft: 8 }}>{t("fileTab.table.text")}</th>
</tr>
</thead>
<tbody>
@ -1393,7 +1451,7 @@ export function FileTranscriptionTab({
letterSpacing: "0.02em",
}}
>
{resultSourceLabel(resultEntry)}
{resultSourceLabel(resultEntry, t)}
</span>
</div>
</td>
@ -1428,7 +1486,7 @@ export function FileTranscriptionTab({
justifySelf: "start",
}}
>
{resultExpanded ? "Скрыть" : "Раскрыть"}
{resultExpanded ? t("fileTab.result.collapse") : t("fileTab.result.expand")}
</button>
)}
</div>
@ -1447,7 +1505,7 @@ export function FileTranscriptionTab({
fontSize: 13,
}}
>
После обработки здесь появится текст.
{t("fileTab.result.empty")}
</div>
)}
</section>
@ -1488,7 +1546,7 @@ export function FileTranscriptionTab({
marginBottom: 8,
}}
>
Нужна локальная подготовка
{t("fileTab.modal.title")}
</div>
<div
style={{
@ -1499,8 +1557,8 @@ export function FileTranscriptionTab({
}}
>
{localWhisperDownloaded
? `Для разделения по говорящим Talkis использует ${localWhisperLabel} для распознавания с таймкодами и подготовит локальные компоненты для разметки говорящих.`
: `Для разделения по говорящим Talkis подготовит ${RECOMMENDED_LOCAL_WHISPER_LABEL} для распознавания с таймкодами и локальные компоненты для разметки говорящих.`}
? t("fileTab.modal.descDownloaded", { name: localWhisperLabel })
: t("fileTab.modal.descNeedDownload", { name: RECOMMENDED_LOCAL_WHISPER_LABEL })}
</div>
<div style={{ display: "grid", gap: 8, marginBottom: 16 }}>
@ -1521,8 +1579,8 @@ export function FileTranscriptionTab({
)}
<span>
{localWhisperDownloaded
? `${localWhisperLabel} готова для разметки`
: `${RECOMMENDED_LOCAL_WHISPER_LABEL} будет скачан`}
? t("fileTab.modal.whisperReady", { name: localWhisperLabel })
: t("fileTab.modal.whisperWillDownload", { name: RECOMMENDED_LOCAL_WHISPER_LABEL })}
</span>
</div>
<div
@ -1542,8 +1600,8 @@ export function FileTranscriptionTab({
)}
<span>
{diarizationInstalled
? "Компоненты для разметки"
: "Компоненты для разметки говорящих будут скачаны"}
? t("fileTab.modal.diarizationReady")
: t("fileTab.modal.diarizationWillDownload")}
</span>
</div>
</div>
@ -1593,7 +1651,7 @@ export function FileTranscriptionTab({
cursor: speakerSetupInstalling ? "not-allowed" : "pointer",
}}
>
Отмена
{t("common.cancel")}
</button>
<button
type="button"

View file

@ -19,6 +19,7 @@ import {
Copy,
HelpCircle,
Loader2,
ListChecks,
Pencil,
RotateCcw,
Square,
@ -34,12 +35,16 @@ import {
type ProcessingCancelRequestPayload,
type WidgetRetryProcessingPayload,
} from "../../../lib/hotkeyEvents";
import { isSummaryAvailable } from "../../../lib/summarize";
import { retryCallCaptureHistoryEntry } from "../../../lib/callCapture";
import { retryFileHistoryEntry } from "../../../lib/fileTranscription";
import { cancelProcessing } from "../../../lib/processingControl";
import { logError } from "../../../lib/logger";
import { TranscriptionStatsPanel } from "../../../components/TranscriptionStatsPanel";
import { SummaryModal } from "../../../components/SummaryModal";
import { RowActionsMenu, type RowActionItem } from "../../../components/RowActionsMenu";
import { retryHistoryEntry } from "../../widget/services/transcriptionPipeline";
import { useI18n, type TFunc, type UiLanguage, type MsgKey } from "../../../lib/i18n";
interface MainTabProps {
initialHistory?: HistoryEntry[];
@ -55,11 +60,11 @@ type HistorySource = "voice" | "file" | "call";
type HistoryFilter = "all" | HistorySource;
const HISTORY_TEXT_PREVIEW_LIMIT = 250;
const HISTORY_FILTER_OPTIONS: { id: HistoryFilter; label: string }[] = [
{ id: "all", label: "Все" },
{ id: "voice", label: "Голос" },
{ id: "file", label: "Файл" },
{ id: "call", label: "Созвон" },
const HISTORY_FILTER_OPTIONS: { id: HistoryFilter; labelKey: MsgKey }[] = [
{ id: "all", labelKey: "mainTab.filter.all" },
{ id: "voice", labelKey: "mainTab.filter.voice" },
{ id: "file", labelKey: "mainTab.filter.file" },
{ id: "call", labelKey: "mainTab.filter.call" },
];
function getHistorySource(entry: HistoryEntry): HistorySource {
@ -70,10 +75,10 @@ function getHistorySource(entry: HistoryEntry): HistorySource {
return "voice";
}
function sourceLabel(source: HistorySource): string {
if (source === "file") return "Файл";
if (source === "call") return "Созвон";
return "Голос";
function sourceLabelKey(source: HistorySource): MsgKey {
if (source === "file") return "mainTab.source.file";
if (source === "call") return "mainTab.source.call";
return "mainTab.source.voice";
}
function formatTimestamp(seconds: number): string {
@ -166,6 +171,7 @@ function ExpandableHistoryText({
onSpeakerRename: (speakerId: string, label: string) => void;
onToggle: () => void;
}): ReactElement {
const { t } = useI18n();
const speakerSegments = segments?.length ? segments : null;
const textTooLong = text.length > HISTORY_TEXT_PREVIEW_LIMIT;
const shouldCollapse = textTooLong || Boolean(speakerSegments);
@ -204,7 +210,7 @@ function ExpandableHistoryText({
fontSize: 12,
fontWeight: 650,
}}
aria-label={`Имя ${speaker.label}`}
aria-label={t("mainTab.speakerNameAria", { name: speaker.label })}
/>
))}
</div>
@ -231,14 +237,14 @@ function ExpandableHistoryText({
justifySelf: "start",
}}
>
{expanded ? "Скрыть" : "Раскрыть"}
{expanded ? t("mainTab.collapse") : t("mainTab.expand")}
</button>
)}
</div>
);
}
function formatDayLabel(timestamp: string): string {
function formatDayLabel(timestamp: string, t: TFunc, lang: UiLanguage): string {
const entryDate = new Date(timestamp);
const today = new Date();
const startOfToday = new Date(
@ -255,16 +261,17 @@ function formatDayLabel(timestamp: string): string {
(startOfToday.getTime() - startOfEntryDay.getTime()) / 86400000,
);
if (diffDays === 0) return "Сегодня";
if (diffDays === 1) return "Вчера";
if (diffDays === 0) return t("mainTab.day.today");
if (diffDays === 1) return t("mainTab.day.yesterday");
return entryDate.toLocaleDateString("ru-RU", {
return entryDate.toLocaleDateString(lang === "en" ? "en-US" : "ru-RU", {
day: "numeric",
month: "long",
weekday: "long",
});
}
export function MainTab({ initialHistory = [] }: MainTabProps) {
const { t, lang } = useI18n();
const [history, setHistory] = useState<HistoryEntry[]>(initialHistory);
const [copied, setCopied] = useState<string | null>(null);
const [retryingId, setRetryingId] = useState<string | null>(null);
@ -279,11 +286,14 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
const [editingSpeakerEntryId, setEditingSpeakerEntryId] = useState<
string | null
>(null);
const [summaryEntry, setSummaryEntry] = useState<HistoryEntry | null>(null);
const [summaryAvailable, setSummaryAvailable] = useState(false);
useEffect(() => {
const syncHotkeyLabel = async (reload = false) => {
const settings = await getSettings({ reload });
setHotkeyLabel(formatHotkeyLabel(settings.hotkey || DEFAULT_HOTKEY));
setSummaryAvailable(isSummaryAvailable(settings));
};
const loadHistory = async (): Promise<void> => {
@ -456,7 +466,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
const message =
error instanceof Error
? error.message
: "Не удалось повторно отправить запись.";
: t("mainTab.retryFailed");
setHistory((current) =>
current.map((item) =>
@ -490,7 +500,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
? {
...item,
status: "interrupted",
errorMessage: "Обработка остановлена. Можно запустить повторно.",
errorMessage: t("mainTab.processingStopped"),
}
: item,
),
@ -515,7 +525,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
const groups: HistoryGroup[] = [];
for (const item of filteredHistory) {
const label = formatDayLabel(item.timestamp);
const label = formatDayLabel(item.timestamp, t, lang);
const existing = groups[groups.length - 1];
if (!existing || existing.label !== label) {
@ -531,7 +541,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
}
return groups;
}, [filteredHistory]);
}, [filteredHistory, t, lang]);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
@ -558,14 +568,14 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
<span
style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)" }}
>
Как начать запись
{t("mainTab.howToStart")}
</span>
<button
type="button"
onClick={() => setHintHelpOpen((value) => !value)}
aria-expanded={hintHelpOpen}
aria-label="Как это работает"
title="Как это работает"
aria-label={t("mainTab.howItWorks")}
title={t("mainTab.howItWorks")}
style={{
display: "inline-flex",
alignItems: "center",
@ -591,8 +601,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
lineHeight: 1.7,
}}
>
Удерживайте горячую клавишу, говорите и отпустите ее, когда
закончите. После обработки текст вставится автоматически.
{t("mainTab.howToStartHint")}
</div>
)}
</div>
@ -610,7 +619,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
}}
>
<span style={{ color: "var(--text-low)", letterSpacing: "0.08em" }}>
Комбинация
{t("mainTab.combination")}
</span>
<span>{hotkeyLabel}</span>
</div>
@ -638,7 +647,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
letterSpacing: "-0.03em",
}}
>
История записей
{t("mainTab.historyTitle")}
</h2>
<div
style={{
@ -648,8 +657,8 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
}}
>
{history.length > 0
? "Последние записи доступны для копирования и удаления."
: `Записей пока нет. Удерживайте ${hotkeyLabel} для записи.`}
? t("mainTab.historyDescFilled")
: t("mainTab.historyDescEmpty", { hotkey: hotkeyLabel })}
</div>
</div>
@ -662,12 +671,12 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
style={{ minHeight: 34, padding: "0 12px" }}
title={
isClearArmed
? "Нажмите еще раз, чтобы очистить всю историю"
: "Очистить всю историю"
? t("mainTab.clearAllConfirmTitle")
: t("mainTab.clearAllTitle")
}
>
<Trash2 size={12} strokeWidth={2} />{" "}
{isClearArmed ? "Подтвердить" : "Очистить"}
{isClearArmed ? t("mainTab.confirm") : t("mainTab.clear")}
</button>
)}
</div>
@ -713,7 +722,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
cursor: "pointer",
}}
>
{option.label}
{t(option.labelKey)}
</button>
);
})}
@ -754,7 +763,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
</span>
</div>
<div className="label" style={{ marginBottom: 10 }}>
История пуста
{t("mainTab.emptyTitle")}
</div>
<p
style={{
@ -764,7 +773,8 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
lineHeight: 1.7,
}}
>
Записей пока нет. Удерживайте <b>{hotkeyLabel}</b> для записи.
{t("mainTab.emptyHintBefore")} <b>{hotkeyLabel}</b>{" "}
{t("mainTab.emptyHintAfter")}
</p>
</div>
) : filteredHistory.length === 0 ? (
@ -777,7 +787,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
color: "var(--text-mid)",
}}
>
В этом фильтре записей пока нет.
{t("mainTab.filterEmpty")}
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
@ -792,8 +802,8 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
>
<thead>
<tr>
<th style={{ width: 92 }}>Время</th>
<th style={{ paddingLeft: 8 }}>Текст</th>
<th style={{ width: 92 }}>{t("mainTab.colTime")}</th>
<th style={{ paddingLeft: 8 }}>{t("mainTab.colText")}</th>
</tr>
</thead>
<tbody>
@ -846,8 +856,8 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
}}
>
{item.processingTime < 1000
? `${item.processingTime}мс`
: `${(item.processingTime / 1000).toFixed(1)}с`}
? t("mainTab.durationMs", { value: item.processingTime })
: t("mainTab.durationS", { value: (item.processingTime / 1000).toFixed(1) })}
</span>
)}
{historyFilter === "all" && (
@ -858,7 +868,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
letterSpacing: "0.02em",
}}
>
{sourceLabel(source)}
{t(sourceLabelKey(source))}
</span>
)}
</div>
@ -903,7 +913,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
size={13}
strokeWidth={2}
/>
<span>Идёт обработка</span>
<span>{t("mainTab.processing")}</span>
</div>
) : item.status === "failed" ||
item.status === "interrupted" ? (
@ -930,8 +940,8 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
/>
<span>
{item.status === "interrupted"
? "Обработка прервана"
: "Обработка не завершилась"}
? t("mainTab.statusInterrupted")
: t("mainTab.statusFailed")}
</span>
</div>
<div
@ -943,7 +953,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
}}
>
{item.errorMessage ||
"Аудио сохранено локально. Можно отправить повторно."}
t("mainTab.audioSavedLocally")}
</div>
</>
) : (
@ -989,7 +999,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
flexShrink: 0,
borderRadius: 8,
}}
title="Остановить обработку"
title={t("mainTab.stopProcessing")}
>
<Square
size={11}
@ -1018,7 +1028,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
flexShrink: 0,
borderRadius: 8,
}}
title="Обработать заново"
title={t("mainTab.retryProcess")}
>
{retryingId === item.id ? (
<Loader2
@ -1031,59 +1041,54 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
)}
</button>
) : (
<button
onClick={() =>
copyText(item.id, item.cleaned)
}
className="btn"
style={{
width: 32,
minWidth: 32,
height: 32,
minHeight: 32,
padding: 0,
flexShrink: 0,
borderRadius: 8,
}}
title={
retrySucceededId === item.id
? "Успешно"
: "Скопировать"
}
>
{copied === item.id ||
retrySucceededId === item.id ? (
<Check size={12} strokeWidth={2.5} />
) : (
<Copy size={12} strokeWidth={2} />
)}
</button>
<RowActionsMenu
label={t("rowMenu.actions")}
items={[
(source === "file" ||
source === "call") && {
key: "edit",
label: t("rowMenu.edit"),
icon: (
<Pencil size={14} strokeWidth={2} />
),
onSelect: () => editEntry(item),
},
{
key: "copy",
label:
copied === item.id ||
retrySucceededId === item.id
? t("mainTab.success")
: t("rowMenu.copy"),
icon:
copied === item.id ||
retrySucceededId === item.id ? (
<Check
size={14}
strokeWidth={2.5}
/>
) : (
<Copy size={14} strokeWidth={2} />
),
onSelect: () =>
copyText(item.id, item.cleaned),
},
{
key: "summarize",
label: t("rowMenu.summarize"),
icon: (
<ListChecks
size={14}
strokeWidth={2}
/>
),
disabled: !summaryAvailable,
hint: t("summary.unavailable.tooltip"),
onSelect: () => setSummaryEntry(item),
},
].filter(Boolean) as RowActionItem[]}
/>
)}
{(!item.status ||
item.status === "completed") &&
(source === "file" ||
source === "call") && (
<button
onClick={() => editEntry(item)}
className="btn"
style={{
width: 32,
minWidth: 32,
height: 32,
minHeight: 32,
padding: 0,
flexShrink: 0,
borderRadius: 8,
background:
editingSpeakerEntryId === item.id
? "var(--dropdown-active)"
: undefined,
}}
title="Редактировать"
>
<Pencil size={12} strokeWidth={2} />
</button>
)}
{item.status !== "processing" && (
<button
onClick={() => deleteEntry(item.id)}
@ -1097,7 +1102,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
flexShrink: 0,
borderRadius: 8,
}}
title="Удалить"
title={t("mainTab.delete")}
>
<Trash2 size={12} strokeWidth={2} />
</button>
@ -1116,6 +1121,17 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
)}
</div>
</section>
{summaryEntry && (
<SummaryModal
entry={summaryEntry}
onClose={() => setSummaryEntry(null)}
onEntryChange={(updated) => {
setSummaryEntry(updated);
void emit(HISTORY_UPDATED_EVENT, updated);
}}
/>
)}
</div>
);
}

View file

@ -28,6 +28,7 @@ import {
SETTINGS_UPDATED_EVENT,
} from "../../../lib/hotkeyEvents";
import { logError } from "../../../lib/logger";
import { useI18n, type TFunc } from "../../../lib/i18n";
import {
getRealtimeInterpreterStatus,
listRealtimeAudioDevices,
@ -82,13 +83,13 @@ interface LiveTextState {
remoteToUser: string;
}
function statusLabel(status: RealtimeInterpreterStatus | null): string {
if (!status) return "Проверяем";
if (status.state === "running") return "Работает";
if (status.state === "starting") return "Запускается";
if (status.state === "stopping") return "Останавливается";
if (status.state === "error") return "Ошибка";
return "Выключен";
function statusLabel(status: RealtimeInterpreterStatus | null, t: TFunc): string {
if (!status) return t("realtime.status.checking");
if (status.state === "running") return t("realtime.status.running");
if (status.state === "starting") return t("realtime.status.starting");
if (status.state === "stopping") return t("realtime.status.stopping");
if (status.state === "error") return t("realtime.status.error");
return t("realtime.status.off");
}
function statusColor(status: RealtimeInterpreterStatus | null): string {
@ -159,28 +160,33 @@ function formatDurationSeconds(startedAt?: string | null): number {
function directionSpeakerLabel(
direction: RealtimeTranslationSegment["direction"],
t: TFunc,
): string {
return direction === "user_to_remote" ? "Вы" : "Собеседник";
return direction === "user_to_remote"
? t("realtime.speaker.you")
: t("realtime.speaker.remote");
}
function formatRealtimeRawTranscript(
segments: RealtimeTranslationSegment[],
t: TFunc,
): string {
return segments
.map(
(segment) =>
`${directionSpeakerLabel(segment.direction)} (${segment.sourceLanguage}): ${segment.sourceText}`,
`${directionSpeakerLabel(segment.direction, t)} (${segment.sourceLanguage}): ${segment.sourceText}`,
)
.join("\n");
}
function formatRealtimeBilingualTranscript(
segments: RealtimeTranslationSegment[],
t: TFunc,
): string {
return segments
.map(
(segment) =>
`${directionSpeakerLabel(segment.direction)}: ${segment.sourceText}\nПеревод (${segment.targetLanguage}): ${segment.translatedText}`,
`${directionSpeakerLabel(segment.direction, t)}: ${segment.sourceText}\n${t("realtime.transcript.translationLabel", { lang: segment.targetLanguage })}: ${segment.translatedText}`,
)
.join("\n\n");
}
@ -335,6 +341,7 @@ function LevelMeter({
}
export function RealtimeInterpreterTab(): ReactElement {
const { t } = useI18n();
const [settings, setSettings] = useState<AppSettings | null>(null);
const [devices, setDevices] = useState<RealtimeAudioDevices | null>(null);
const [status, setStatus] = useState<RealtimeInterpreterStatus | null>(null);
@ -551,15 +558,13 @@ export function RealtimeInterpreterTab(): ReactElement {
const warnings = useMemo(() => {
const result = [...(devices?.warnings || [])];
if (!hasCloudToken) {
result.push("Для облачного режима войдите в аккаунт Talkis.");
result.push(t("realtime.warning.needLogin"));
}
if (!interpreterSettings?.headphonesConfirmed) {
result.push(
"Для локального перевода нужны наушники или отдельный output.",
);
result.push(t("realtime.warning.needHeadphones"));
}
return result;
}, [devices?.warnings, hasCloudToken, interpreterSettings]);
}, [devices?.warnings, hasCloudToken, interpreterSettings, t]);
const canStart = Boolean(
interpreterSettings &&
@ -609,10 +614,10 @@ export function RealtimeInterpreterTab(): ReactElement {
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
duration: formatDurationSeconds(sessionStatus.startedAt),
raw: formatRealtimeRawTranscript(finalSegments),
cleaned: formatRealtimeBilingualTranscript(finalSegments),
raw: formatRealtimeRawTranscript(finalSegments, t),
cleaned: formatRealtimeBilingualTranscript(finalSegments, t),
source: "call",
fileName: "Перевод звонка",
fileName: t("realtime.history.fileName"),
callSessionId: sessionStatus.sessionId,
status: "completed",
translation: {
@ -651,7 +656,7 @@ export function RealtimeInterpreterTab(): ReactElement {
setTestMessage(null);
try {
await testVirtualMicOutput(interpreterSettings.virtualMicOutputDeviceId);
setTestMessage("Тестовый сигнал отправлен в virtual mic output.");
setTestMessage(t("realtime.test.signalSent"));
} catch (testError) {
setError(errorMessage(testError));
} finally {
@ -663,7 +668,7 @@ export function RealtimeInterpreterTab(): ReactElement {
return (
<div className="card" style={CARD_STYLE}>
<div style={{ fontSize: 14, color: "var(--text-mid)" }}>
Загружаем переводчик
{t("realtime.loading")}
</div>
</div>
);
@ -699,7 +704,7 @@ export function RealtimeInterpreterTab(): ReactElement {
color: "var(--text-hi)",
}}
>
Синхронный переводчик
{t("realtime.title")}
</div>
</div>
@ -712,9 +717,9 @@ export function RealtimeInterpreterTab(): ReactElement {
maxWidth: "100%",
}}
>
<Metric label="Статус" value={statusLabel(status)} />
<Metric label="Языки" value={pairDefinition.label} />
<Metric label="Маршрут" value="Talkis Cloud" />
<Metric label={t("realtime.metric.status")} value={statusLabel(status, t)} />
<Metric label={t("realtime.metric.languages")} value={pairDefinition.label} />
<Metric label={t("realtime.metric.route")} value="Talkis Cloud" />
</div>
</div>
@ -729,14 +734,14 @@ export function RealtimeInterpreterTab(): ReactElement {
}}
>
<Radio size={14} strokeWidth={2.2} />
{status?.message || "Realtime Interpreter выключен."}
{status?.message || t("realtime.statusMessage.off")}
</div>
</div>
<div className="card" style={CARD_STYLE}>
<SettingField
label="Настоящий микрофон"
note={`Голос пользователя для RU → ${pairDefinition.userTargetLanguage}.`}
label={t("realtime.field.realMic.label")}
note={t("realtime.field.realMic.note", { lang: pairDefinition.userTargetLanguage })}
>
<DeviceSelect
value={interpreterSettings.realMicDeviceId}
@ -744,14 +749,14 @@ export function RealtimeInterpreterTab(): ReactElement {
void updateInterpreterSettings({ realMicDeviceId: value });
}}
devices={devices?.realMics || []}
emptyLabel="Системный микрофон"
emptyLabel={t("realtime.field.realMic.empty")}
disabled={loadPending}
/>
</SettingField>
<SettingField
label="Пара языков"
note="Русский остается локальным языком пользователя в v1."
label={t("realtime.field.languagePair.label")}
note={t("realtime.field.languagePair.note")}
>
<select
value={interpreterSettings.languagePair}
@ -779,7 +784,7 @@ export function RealtimeInterpreterTab(): ReactElement {
<SettingField
label="Virtual mic output"
note={`${devices?.virtualDriverName || "Virtual driver"} для приложения звонка.`}
note={t("realtime.field.virtualMic.note", { driver: devices?.virtualDriverName || "Virtual driver" })}
>
<DeviceSelect
value={interpreterSettings.virtualMicOutputDeviceId}
@ -789,7 +794,7 @@ export function RealtimeInterpreterTab(): ReactElement {
});
}}
devices={devices?.virtualMicOutputs || []}
emptyLabel={`Установите ${devices?.virtualDriverName || "virtual driver"}`}
emptyLabel={t("realtime.field.virtualMic.empty", { driver: devices?.virtualDriverName || "virtual driver" })}
disabled={
loadPending || (devices?.virtualMicOutputs.length || 0) === 0
}
@ -797,8 +802,8 @@ export function RealtimeInterpreterTab(): ReactElement {
</SettingField>
<SettingField
label="Локальное прослушивание"
note="Русский перевод собеседника."
label={t("realtime.field.localPlayback.label")}
note={t("realtime.field.localPlayback.note")}
>
<DeviceSelect
value={interpreterSettings.localPlaybackDeviceId}
@ -806,14 +811,14 @@ export function RealtimeInterpreterTab(): ReactElement {
void updateInterpreterSettings({ localPlaybackDeviceId: value });
}}
devices={devices?.localPlaybackOutputs || []}
emptyLabel="Системный output"
emptyLabel={t("realtime.field.localPlayback.empty")}
disabled={loadPending}
/>
</SettingField>
<SettingField
label="Доступ"
note="V1 работает только через Talkis Cloud."
label={t("realtime.field.access.label")}
note={t("realtime.field.access.note")}
>
<div
style={{
@ -836,7 +841,7 @@ export function RealtimeInterpreterTab(): ReactElement {
fontWeight: 700,
}}
>
{hasCloudToken ? "Аккаунт подключен" : "Нужен вход"}
{hasCloudToken ? t("realtime.access.connected") : t("realtime.access.needLogin")}
</span>
</div>
</SettingField>
@ -864,7 +869,7 @@ export function RealtimeInterpreterTab(): ReactElement {
style={{ width: 16, height: 16, accentColor: "var(--accent)" }}
/>
<Headphones size={16} strokeWidth={2} />
<span>Локальный перевод идет в наушники или отдельный output</span>
<span>{t("realtime.headphonesConfirm")}</span>
</label>
</div>
@ -883,7 +888,7 @@ export function RealtimeInterpreterTab(): ReactElement {
<div
style={{ fontSize: 15, fontWeight: 700, color: "var(--text-hi)" }}
>
Проверка и запуск
{t("realtime.checkAndRun")}
</div>
</div>
@ -891,7 +896,7 @@ export function RealtimeInterpreterTab(): ReactElement {
<button
type="button"
className="btn"
title="Обновить устройства"
title={t("realtime.button.refresh.title")}
disabled={loadPending || actionPending}
onClick={() => {
void loadState();
@ -899,12 +904,12 @@ export function RealtimeInterpreterTab(): ReactElement {
style={{ minHeight: 36, borderRadius: 10, padding: "0 12px" }}
>
<RefreshCcw size={14} strokeWidth={2} />
Обновить
{t("realtime.button.refresh")}
</button>
<button
type="button"
className="btn"
title="Тест virtual mic output"
title={t("realtime.button.test.title")}
disabled={
actionPending ||
!interpreterSettings.virtualMicOutputDeviceId ||
@ -916,13 +921,13 @@ export function RealtimeInterpreterTab(): ReactElement {
style={{ minHeight: 36, borderRadius: 10, padding: "0 12px" }}
>
<Volume2 size={14} strokeWidth={2} />
Тест
{t("realtime.button.test")}
</button>
{status?.active ? (
<button
type="button"
className="btn btn-danger"
title="Остановить переводчик"
title={t("realtime.button.stop.title")}
disabled={actionPending}
onClick={() => {
void handleStop();
@ -930,13 +935,13 @@ export function RealtimeInterpreterTab(): ReactElement {
style={{ minHeight: 36, borderRadius: 10, padding: "0 12px" }}
>
<Square size={13} strokeWidth={2.4} />
Стоп
{t("realtime.button.stop")}
</button>
) : (
<button
type="button"
className="btn btn-primary"
title="Запустить переводчик"
title={t("realtime.button.start.title")}
disabled={actionPending || !canStart}
onClick={() => {
void handleStart();
@ -944,7 +949,7 @@ export function RealtimeInterpreterTab(): ReactElement {
style={{ minHeight: 36, borderRadius: 10, padding: "0 12px" }}
>
<Play size={13} strokeWidth={2.4} />
Старт
{t("realtime.button.start")}
</button>
)}
</div>
@ -1022,7 +1027,7 @@ export function RealtimeInterpreterTab(): ReactElement {
<div style={{ display: "grid", gap: 10, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Speaker size={15} strokeWidth={2} />
<div style={{ fontSize: 14, fontWeight: 700 }}>Вы звонок</div>
<div style={{ fontSize: 14, fontWeight: 700 }}>{t("realtime.panel.youToCall")}</div>
</div>
<LevelMeter
label={`RU → ${pairDefinition.userTargetLanguage}`}
@ -1042,14 +1047,14 @@ export function RealtimeInterpreterTab(): ReactElement {
lineHeight: 1.5,
}}
>
{liveText.userToRemote || "Нет live transcript"}
{liveText.userToRemote || t("realtime.noLiveTranscript")}
</div>
</div>
<div style={{ display: "grid", gap: 10, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Headphones size={15} strokeWidth={2} />
<div style={{ fontSize: 14, fontWeight: 700 }}>Звонок вы</div>
<div style={{ fontSize: 14, fontWeight: 700 }}>{t("realtime.panel.callToYou")}</div>
</div>
<LevelMeter
label={`${pairDefinition.remoteSourceLanguage} → RU`}
@ -1069,7 +1074,7 @@ export function RealtimeInterpreterTab(): ReactElement {
lineHeight: 1.5,
}}
>
{liveText.remoteToUser || "Нет live transcript"}
{liveText.remoteToUser || t("realtime.noLiveTranscript")}
</div>
</div>
</div>
@ -1077,8 +1082,10 @@ export function RealtimeInterpreterTab(): ReactElement {
<div
style={{ fontSize: 12, color: "var(--text-low)", lineHeight: 1.55 }}
>
Virtual mic: {selectedVirtualOutput?.label || "не выбран"} · Playback:{" "}
{selectedPlayback?.label || "системный output"}
{t("realtime.routingSummary", {
virtualMic: selectedVirtualOutput?.label || t("realtime.routing.notSelected"),
playback: selectedPlayback?.label || t("realtime.routing.systemOutput"),
})}
</div>
</div>
</div>

View file

@ -38,6 +38,7 @@ import {
import { logError, logInfo } from "../../../lib/logger";
import { buildFrontendHotkeyCandidate } from "../../../lib/frontendHotkeyCapture";
import { LANGUAGES } from "../../../config/languages";
import { useI18n } from "../../../lib/i18n";
type HotkeyFeedbackTone = "idle" | "success" | "error";
type StorageFeedbackTone = "idle" | "success" | "error";
@ -55,13 +56,14 @@ const SETTINGS_CARD_STYLE = {
backdropFilter: "none",
WebkitBackdropFilter: "none",
} as const;
const THEME_OPTIONS: Array<{ id: AppSettings["theme"]; label: string; Icon: LucideIcon }> = [
{ id: "system", label: "Системная", Icon: Monitor },
{ id: "light", label: "Светлая", Icon: Sun },
{ id: "dark", label: "Темная", Icon: Moon },
const THEME_OPTIONS: Array<{ id: AppSettings["theme"]; Icon: LucideIcon }> = [
{ id: "system", Icon: Monitor },
{ id: "light", Icon: Sun },
{ id: "dark", Icon: Moon },
];
export function SettingsTab() {
const { lang, t } = useI18n();
const usesNativeHotkeyCapture = isMacPlatform();
const [settings, setSettings] = useState<AppSettings | null>(null);
@ -73,7 +75,7 @@ export function SettingsTab() {
const [microphones, setMicrophones] = useState<MediaDeviceInfo[]>([]);
const [micOpen, setMicOpen] = useState(false);
const [micStatus, setMicStatus] = useState<MicAvailabilityState>("empty");
const [micMessage, setMicMessage] = useState("Проверяем доступные микрофоны...");
const [micMessage, setMicMessage] = useState(t("settingsGeneralExtra.mic.checking"));
const micRef = useRef<HTMLDivElement>(null);
const settingsRef = useRef<AppSettings | null>(null);
@ -84,7 +86,7 @@ export function SettingsTab() {
const [isHotkeyCaptureActive, setIsHotkeyCaptureActive] = useState(false);
const [isHotkeySubmitting, setIsHotkeySubmitting] = useState(false);
const [hotkeyDraft, setHotkeyDraft] = useState<string | null>(null);
const [hotkeyFeedback, setHotkeyFeedback] = useState("Нажмите на поле и введите новую комбинацию. Esc отменяет ввод.");
const [hotkeyFeedback, setHotkeyFeedback] = useState(t("settingsGeneralExtra.hotkey.initial"));
const [hotkeyFeedbackTone, setHotkeyFeedbackTone] = useState<HotkeyFeedbackTone>("idle");
const [autostartEnabled, setAutostartEnabled] = useState(false);
const [autostartLoaded, setAutostartLoaded] = useState(false);
@ -166,7 +168,7 @@ export function SettingsTab() {
if (!payload.success) {
setHotkeyFeedbackTone("error");
setHotkeyFeedback(payload.message || "Не удалось применить новую комбинацию.");
setHotkeyFeedback(payload.message || t("settingsGeneralExtra.hotkey.applyFailed"));
return;
}
@ -174,11 +176,11 @@ export function SettingsTab() {
settingsRef.current = latestSettings;
setSettings(latestSettings);
setHotkeyFeedbackTone("success");
setHotkeyFeedback("Новая горячая клавиша сохранена и уже работает.");
setHotkeyFeedback(t("settingsGeneralExtra.hotkey.saved"));
clearHotkeyFeedbackResetTimer();
hotkeyFeedbackResetTimerRef.current = setTimeout(() => {
setHotkeyFeedbackTone("idle");
setHotkeyFeedback("Нажмите на поле, чтобы изменить комбинацию снова.");
setHotkeyFeedback(t("settingsGeneralExtra.hotkey.changeAgain"));
hotkeyFeedbackResetTimerRef.current = null;
}, 2200);
},
@ -192,14 +194,14 @@ export function SettingsTab() {
setIsHotkeySubmitting(false);
setHotkeyDraft(null);
setHotkeyFeedbackTone("idle");
setHotkeyFeedback(payload.message || "Нажмите новую комбинацию.");
setHotkeyFeedback(payload.message || t("settingsGeneralExtra.hotkey.pressNew"));
return;
}
if (payload.status === "preview") {
setHotkeyDraft(payload.hotkey || null);
setHotkeyFeedbackTone("idle");
setHotkeyFeedback(payload.message || "Отпустите комбинацию, чтобы применить.");
setHotkeyFeedback(payload.message || t("settingsGeneralExtra.hotkey.releaseToApply"));
return;
}
@ -209,7 +211,7 @@ export function SettingsTab() {
setIsHotkeyCaptureActive(false);
setHotkeyDraft(null);
setHotkeyFeedbackTone("idle");
setHotkeyFeedback(payload.message || "Ввод отменен.");
setHotkeyFeedback(payload.message || t("settingsGeneralExtra.hotkey.inputCancelled"));
return;
}
@ -241,7 +243,7 @@ export function SettingsTab() {
event.stopPropagation();
if (event.key === "Escape" && !event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) {
void stopHotkeyCapture("Ввод отменен. Текущая комбинация сохранена.");
void stopHotkeyCapture(t("settingsGeneralExtra.hotkey.cancelledKept"));
return;
}
@ -249,7 +251,7 @@ export function SettingsTab() {
if (!candidate) {
setHotkeyDraft(null);
setHotkeyFeedbackTone("idle");
setHotkeyFeedback("Нажмите сочетание с основной клавишей.");
setHotkeyFeedback(t("settingsGeneralExtra.hotkey.needMainKey"));
return;
}
@ -258,7 +260,7 @@ export function SettingsTab() {
if (!normalized.valid) {
setHotkeyFeedbackTone("error");
setHotkeyFeedback(normalized.error || "Неверная комбинация.");
setHotkeyFeedback(normalized.error || t("settingsGeneralExtra.hotkey.invalid"));
return;
}
@ -281,7 +283,7 @@ export function SettingsTab() {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
void logInfo("SETTINGS", "Media devices API not available");
setMicStatus("empty");
setMicMessage("Список микрофонов недоступен в этой среде.");
setMicMessage(t("settingsGeneralExtra.mic.unavailableEnv"));
return;
}
@ -318,29 +320,29 @@ export function SettingsTab() {
const selectedMic = settings.micId ? uniqueMics.find(m => m.deviceId === settings.micId) : null;
if (settings.micId && !selectedMic) {
setMicStatus("missing-selected");
setMicMessage("Ранее выбранный микрофон недоступен. Во время записи будет использован системный по умолчанию.");
setMicMessage(t("settingsGeneralExtra.mic.missingSelected"));
return;
}
if (uniqueMics.length === 0) {
if (needsPermission) {
setMicStatus("permission-needed");
setMicMessage("Список микрофонов появится после разрешения доступа к микрофону.");
setMicMessage(t("settingsGeneralExtra.mic.permissionNeeded"));
return;
}
setMicStatus("empty");
setMicMessage("Не удалось найти доступные микрофоны. Подключите устройство или проверьте системные настройки.");
setMicMessage(t("settingsGeneralExtra.mic.noneFound"));
return;
}
const activeLabel = selectedMic ? getMicrophoneLabel(selectedMic, uniqueMics.indexOf(selectedMic)) : "Системный микрофон по умолчанию";
const activeLabel = selectedMic ? getMicrophoneLabel(selectedMic, uniqueMics.indexOf(selectedMic)) : t("settings.mic.systemDefault");
setMicStatus("ready");
setMicMessage(selectedMic ? `Сейчас используется: ${activeLabel}` : `Сейчас используется: ${activeLabel}`);
setMicMessage(t("settingsGeneralExtra.mic.inUse", { label: activeLabel }));
} catch (err) {
void logError("SETTINGS", `Mic enumeration error: ${err instanceof Error ? err.message : String(err)}`);
setMicStatus("empty");
setMicMessage("Не удалось получить список микрофонов. Попробуйте открыть настройки ещё раз.");
setMicMessage(t("settingsGeneralExtra.mic.enumFailed"));
}
};
@ -358,7 +360,7 @@ export function SettingsTab() {
if (langRef.current && !langRef.current.contains(e.target as Node)) setLangOpen(false);
if (micRef.current && !micRef.current.contains(e.target as Node)) setMicOpen(false);
if (isHotkeyCaptureActive && hotkeyButtonRef.current && !hotkeyButtonRef.current.contains(e.target as Node)) {
void stopHotkeyCapture("Ввод отменен. Текущая комбинация сохранена.");
void stopHotkeyCapture(t("settingsGeneralExtra.hotkey.cancelledKept"));
}
};
document.addEventListener("mousedown", handler);
@ -394,7 +396,7 @@ export function SettingsTab() {
setIsHotkeyCaptureActive(false);
setHotkeyDraft(null);
setHotkeyFeedbackTone("error");
setHotkeyFeedback("Не удалось распознать комбинацию.");
setHotkeyFeedback(t("settingsGeneralExtra.hotkey.recognizeFailed"));
return;
}
@ -403,7 +405,7 @@ export function SettingsTab() {
setIsHotkeyCaptureActive(false);
setHotkeyDraft(null);
setHotkeyFeedbackTone("error");
setHotkeyFeedback(normalized.error || "Неверная комбинация.");
setHotkeyFeedback(normalized.error || t("settingsGeneralExtra.hotkey.invalid"));
return;
}
@ -412,14 +414,14 @@ export function SettingsTab() {
setIsHotkeySubmitting(true);
setHotkeyDraft(normalized.normalized);
setHotkeyFeedbackTone("idle");
setHotkeyFeedback("Проверяем, свободна ли эта комбинация...");
setHotkeyFeedback(t("settingsGeneralExtra.hotkey.checkingFree"));
emit(HOTKEY_CHANGE_REQUEST_EVENT, { hotkey: normalized.normalized }).catch((error) => {
pendingHotkeyRef.current = null;
setIsHotkeySubmitting(false);
setHotkeyDraft(null);
setHotkeyFeedbackTone("error");
setHotkeyFeedback("Не удалось отправить новую комбинацию на проверку.");
setHotkeyFeedback(t("settingsGeneralExtra.hotkey.sendFailed"));
void logError("SETTINGS", `Failed to emit hotkey change request: ${error instanceof Error ? error.message : String(error)}`);
});
};
@ -434,7 +436,7 @@ export function SettingsTab() {
setIsHotkeyCaptureActive(true);
setHotkeyDraft(null);
setHotkeyFeedbackTone("idle");
setHotkeyFeedback(usesNativeHotkeyCapture ? "Запускаем запись новой комбинации..." : "Нажмите новое сочетание.");
setHotkeyFeedback(usesNativeHotkeyCapture ? t("settingsGeneralExtra.hotkey.startingCapture") : t("settingsGeneralExtra.hotkey.pressNewCombo"));
try {
await emit(HOTKEY_CAPTURE_STATE_EVENT, { active: true });
@ -448,7 +450,7 @@ export function SettingsTab() {
setIsHotkeyCaptureActive(false);
setHotkeyDraft(null);
setHotkeyFeedbackTone("error");
setHotkeyFeedback("Не удалось запустить запись горячей клавиши.");
setHotkeyFeedback(t("settingsGeneralExtra.hotkey.captureStartFailed"));
void logError("SETTINGS", `Failed to start native hotkey capture: ${error instanceof Error ? error.message : String(error)}`);
}
};
@ -498,9 +500,9 @@ export function SettingsTab() {
};
const contactSupport = async (): Promise<void> => {
const subject = encodeURIComponent("Talkis — обращение в поддержку");
const subject = encodeURIComponent(t("settingsGeneralExtra.support.mailSubject"));
const body = encodeURIComponent(
"Опишите проблему или вопрос:\n\n\n",
t("settingsGeneralExtra.support.mailBody"),
);
const mailto = `mailto:${SUPPORT_EMAIL}?subject=${subject}&body=${body}`;
@ -512,9 +514,9 @@ export function SettingsTab() {
// Fallback: put the address on the clipboard so support is still reachable.
try {
await navigator.clipboard.writeText(SUPPORT_EMAIL);
setSupportFeedback(`Не удалось открыть почтовый клиент. Адрес скопирован: ${SUPPORT_EMAIL}`);
setSupportFeedback(t("settingsGeneralExtra.support.mailCopied", { email: SUPPORT_EMAIL }));
} catch {
setSupportFeedback(`Напишите нам на ${SUPPORT_EMAIL}`);
setSupportFeedback(t("settingsGeneralExtra.support.writeUs", { email: SUPPORT_EMAIL }));
}
}
};
@ -548,7 +550,7 @@ export function SettingsTab() {
const changeLocalModelsDir = async (): Promise<void> => {
try {
const selected = await openDialog({
title: "Выберите директорию моделей",
title: t("settingsGeneralExtra.dialog.chooseModelsDir"),
directory: true,
multiple: false,
defaultPath: effectiveLocalModelsDir || defaultLocalModelsDir || undefined,
@ -568,7 +570,7 @@ export function SettingsTab() {
const changeTranscriptionStorageDir = async (): Promise<void> => {
try {
const selected = await openDialog({
title: "Выберите папку хранения истории",
title: t("settingsGeneralExtra.dialog.chooseStorageDir"),
directory: true,
multiple: false,
defaultPath: (settingsRef.current?.transcriptionStorageDir || "").trim() || defaultTranscriptionStorageDir || undefined,
@ -582,13 +584,13 @@ export function SettingsTab() {
await writeHistoryToStorageDir(selected, history);
await update({ transcriptionStorageDir: selected });
setTranscriptionStorageFeedbackTone("success");
setTranscriptionStorageFeedback("История перенесена в выбранную папку.");
setTranscriptionStorageFeedback(t("settingsGeneralExtra.storage.moved"));
void logInfo("SETTINGS", "Transcription storage directory changed.");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await restorePersistedSettings();
setTranscriptionStorageFeedbackTone("error");
setTranscriptionStorageFeedback("Не удалось сохранить историю в выбранную папку. Текущая папка не изменилась.");
setTranscriptionStorageFeedback(t("settingsGeneralExtra.storage.moveFailed"));
void logError("SETTINGS", `Failed to change transcription storage directory: ${message}`);
}
};
@ -599,20 +601,20 @@ export function SettingsTab() {
await writeHistoryToDefaultStorage(history);
await update({ transcriptionStorageDir: "" });
setTranscriptionStorageFeedbackTone("success");
setTranscriptionStorageFeedback("История возвращена в директорию по умолчанию.");
setTranscriptionStorageFeedback(t("settingsGeneralExtra.storage.resetDone"));
void logInfo("SETTINGS", "Transcription storage directory reset to default.");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await restorePersistedSettings();
setTranscriptionStorageFeedbackTone("error");
setTranscriptionStorageFeedback("Не удалось вернуть директорию по умолчанию. Текущая папка не изменилась.");
setTranscriptionStorageFeedback(t("settingsGeneralExtra.storage.resetFailed"));
void logError("SETTINGS", `Failed to reset transcription storage directory: ${message}`);
}
};
const getMicrophoneLabel = (mic: MediaDeviceInfo, index: number): string => {
const label = mic.label?.trim();
return label ? label : `Микрофон ${index + 1}`;
return label ? label : t("settingsGeneralExtra.mic.fallbackName", { index: index + 1 });
};
if (!settings) return null;
@ -627,12 +629,12 @@ export function SettingsTab() {
const visibleMicrophoneLabel = selectedMicrophone
? getMicrophoneLabel(selectedMicrophone, microphones.indexOf(selectedMicrophone))
: settings.micId
? "Системный микрофон по умолчанию"
: "Системный микрофон по умолчанию";
? t("settings.mic.systemDefault")
: t("settings.mic.systemDefault");
const hotkeyDisplayValue = hotkeyDraft
? formatHotkeyLabel(hotkeyDraft)
: isHotkeyCaptureActive
? "Нажмите сочетание"
? t("settings.hotkey.press")
: formatHotkeyLabel(settings.hotkey || DEFAULT_HOTKEY);
const hotkeyFeedbackColor = hotkeyFeedbackTone === "error"
? "var(--danger)"
@ -655,10 +657,54 @@ export function SettingsTab() {
<div className="card" style={SETTINGS_CARD_STYLE}>
<div style={{ display: "grid", gridTemplateColumns: SETTING_ROW_COLUMNS, alignItems: "center", gap: SETTING_ROW_GAP }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>Тема</div>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>{t("settings.uiLanguage.title")}</div>
</div>
<div style={{ display: "flex", background: "var(--control-track)", borderRadius: 10, padding: 3, gap: 2, width: "100%", justifySelf: "end" }}>
{THEME_OPTIONS.map(({ id, label, Icon }) => {
{(["ru", "en"] as const).map((code) => {
const active = lang === code;
return (
<button
key={code}
type="button"
onClick={() => { void update({ uiLanguage: code }); }}
style={{
flex: 1,
minWidth: 0,
minHeight: CONTROL_HEIGHT - 6,
padding: "0 4px",
borderRadius: CONTROL_RADIUS,
border: "none",
fontSize: CONTROL_FONT_SIZE,
fontWeight: active ? 700 : 500,
background: active ? "var(--dropdown-active)" : "transparent",
color: active ? "var(--text-hi)" : "var(--text-mid)",
cursor: "pointer",
transition: "background 0.15s ease, color 0.15s ease",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 4,
}}
>
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{t(code === "ru" ? "settings.uiLanguage.ru" : "settings.uiLanguage.en")}
</span>
</button>
);
})}
</div>
</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>{t("settings.uiLanguage.desc")}</div>
</div>
<div className="card" style={SETTINGS_CARD_STYLE}>
<div style={{ display: "grid", gridTemplateColumns: SETTING_ROW_COLUMNS, alignItems: "center", gap: SETTING_ROW_GAP }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>{t("settings.theme.title")}</div>
</div>
<div style={{ display: "flex", background: "var(--control-track)", borderRadius: 10, padding: 3, gap: 2, width: "100%", justifySelf: "end" }}>
{THEME_OPTIONS.map(({ id, Icon }) => {
const active = settings.theme === id;
return (
@ -689,19 +735,19 @@ export function SettingsTab() {
}}
>
<Icon size={13} strokeWidth={active ? 2.2 : 1.7} style={{ flexShrink: 0 }} />
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t(`settings.theme.${id}`)}</span>
</button>
);
})}
</div>
</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>Системная тема следует настройке macOS.</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>{t("settings.theme.desc")}</div>
</div>
<div className="card" style={{ ...SETTINGS_CARD_STYLE, zIndex: langOpen ? 20 : 1 }}>
<div style={{ display: "grid", gridTemplateColumns: SETTING_ROW_COLUMNS, alignItems: "center", gap: SETTING_ROW_GAP }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>Язык распознавания</div>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>{t("settings.recognitionLang.title")}</div>
</div>
<div ref={langRef} style={{ position: "relative", width: "100%", justifySelf: "end" }}>
<button onClick={() => setLangOpen((o) => !o)} className="btn" style={{ width: "100%", justifyContent: "space-between", gap: 8, minHeight: CONTROL_HEIGHT, padding: "0 10px", borderRadius: CONTROL_RADIUS, fontSize: CONTROL_FONT_SIZE }}>
@ -714,11 +760,11 @@ export function SettingsTab() {
<div style={{ position: "absolute", top: "calc(100% + 8px)", right: 0, width: 320, maxHeight: 320, background: "var(--dropdown-bg)", border: "1px solid var(--border)", borderRadius: 24, boxShadow: "var(--shadow-panel)", zIndex: 100, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ padding: 12, borderBottom: "1px solid var(--border-subtle)", display: "flex", alignItems: "center", gap: 8 }}>
<Search size={13} style={{ color: "var(--text-low)", flexShrink: 0 }} />
<input autoFocus value={langSearch} onChange={(e) => setLangSearch(e.target.value)} placeholder="Поиск языка..." style={{ border: "none", outline: "none", background: "transparent", fontSize: 12, color: "var(--text-hi)", flex: 1 }} />
<input autoFocus value={langSearch} onChange={(e) => setLangSearch(e.target.value)} placeholder={t("settings.recognitionLang.searchPlaceholder")} style={{ border: "none", outline: "none", background: "transparent", fontSize: 12, color: "var(--text-hi)", flex: 1 }} />
</div>
<div style={{ overflow: "auto", flex: 1 }}>
{filteredLangs.length === 0 ? (
<div style={{ padding: "14px 16px", fontSize: 12, color: "var(--text-low)" }}>Не найдено</div>
<div style={{ padding: "14px 16px", fontSize: 12, color: "var(--text-low)" }}>{t("common.notFound")}</div>
) : filteredLangs.map((lang) => (
<button
key={lang.code}
@ -751,13 +797,13 @@ export function SettingsTab() {
)}
</div>
</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>Язык, на котором вы говорите.</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>{t("settings.recognitionLang.desc")}</div>
</div>
<div className="card" style={{ ...SETTINGS_CARD_STYLE, zIndex: micOpen ? 20 : 1 }}>
<div style={{ display: "grid", gridTemplateColumns: SETTING_ROW_COLUMNS, alignItems: "center", gap: SETTING_ROW_GAP }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>Микрофон</div>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>{t("settings.mic.title")}</div>
</div>
<div ref={micRef} style={{ position: "relative", width: "100%", justifySelf: "end" }}>
<button
@ -769,7 +815,7 @@ export function SettingsTab() {
style={{ width: "100%", justifyContent: "space-between", gap: 8, minHeight: CONTROL_HEIGHT, padding: "0 10px", borderRadius: CONTROL_RADIUS, fontSize: CONTROL_FONT_SIZE, opacity: microphones.length === 0 || micStatus === "permission-needed" ? 0.7 : 1, cursor: microphones.length === 0 || micStatus === "permission-needed" ? "not-allowed" : "pointer" }}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{microphones.length === 0 ? "Системный микрофон по умолчанию" : visibleMicrophoneLabel}
{microphones.length === 0 ? t("settings.mic.systemDefault") : visibleMicrophoneLabel}
</span>
<ChevronDown size={13} strokeWidth={2} style={{ flexShrink: 0, transform: micOpen ? "rotate(180deg)" : "none", transition: "transform 0.15s" }} />
</button>
@ -783,7 +829,7 @@ export function SettingsTab() {
onMouseEnter={(e) => e.currentTarget.style.background = "var(--dropdown-hover)"}
onMouseLeave={(e) => e.currentTarget.style.background = settings.micId === "" ? "var(--dropdown-active)" : "transparent"}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>Системный микрофон по умолчанию</span>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t("settings.mic.systemDefault")}</span>
{settings.micId === "" && <Check size={12} strokeWidth={2.5} style={{ color: "var(--text-hi)", flexShrink: 0 }} />}
</button>
{microphones.map((m, i) => (
@ -803,14 +849,14 @@ export function SettingsTab() {
)}
</div>
</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>Устройство для записи голоса.</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>{t("settings.mic.desc")}</div>
<div style={{ fontSize: 13, color: "var(--text-low)", lineHeight: 1.6 }}>{micMessage}</div>
</div>
<div className="card" style={SETTINGS_CARD_STYLE}>
<div style={{ display: "grid", gridTemplateColumns: SETTING_ROW_COLUMNS, alignItems: "center", gap: SETTING_ROW_GAP }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>Горячая клавиша</div>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>{t("settings.hotkey.title")}</div>
</div>
<div
ref={hotkeyButtonRef}
@ -838,17 +884,17 @@ export function SettingsTab() {
{hotkeyDisplayValue}
</span>
<span style={{ color: "var(--text-low)", fontSize: 11, letterSpacing: "0.08em", textTransform: "uppercase", flexShrink: 0 }}>
{isHotkeySubmitting ? "Проверка" : isHotkeyCaptureActive ? "Запись" : "Изменить"}
{isHotkeySubmitting ? t("settings.hotkey.checking") : isHotkeyCaptureActive ? t("settings.hotkey.recording") : t("settings.hotkey.change")}
</span>
</div>
</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.65 }}>
Нажмите на поле справа и введите новую комбинацию. Если сочетание занято, оставим предыдущую клавишу.
{t("settings.hotkey.desc")}
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
<div style={{ fontSize: 13, color: hotkeyFeedbackColor, lineHeight: 1.6 }}>{hotkeyFeedback}</div>
<div style={{ fontSize: 12, color: "var(--text-low)", whiteSpace: "nowrap" }}>
Текущая: {formatHotkeyLabel(settings.hotkey || DEFAULT_HOTKEY)}
{t("settings.hotkey.current", { hotkey: formatHotkeyLabel(settings.hotkey || DEFAULT_HOTKEY) })}
</div>
</div>
</div>
@ -856,7 +902,7 @@ export function SettingsTab() {
<div className="card" style={SETTINGS_CARD_STYLE}>
<div style={{ display: "grid", gridTemplateColumns: SETTING_ROW_COLUMNS, alignItems: "center", gap: SETTING_ROW_GAP }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>Размер виджета</div>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>{t("settings.widgetSize.title")}</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) 56px", alignItems: "center", gap: 12, justifySelf: "end", width: "100%" }}>
<input
@ -868,7 +914,7 @@ export function SettingsTab() {
onChange={(event) => {
void update({ widgetScale: normalizeWidgetScale(Number(event.currentTarget.value)) });
}}
aria-label="Размер виджета"
aria-label={t("settings.widgetSize.aria")}
style={{
width: "100%",
accentColor: "var(--accent)",
@ -894,14 +940,14 @@ export function SettingsTab() {
</div>
</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.65 }}>
Масштаб плавающего виджета.
{t("settings.widgetSize.desc")}
</div>
</div>
<div className="card" style={SETTINGS_CARD_STYLE}>
<div style={{ display: "grid", gridTemplateColumns: SETTING_ROW_COLUMNS, alignItems: "center", gap: SETTING_ROW_GAP }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>Автозапуск</div>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>{t("settings.autostart.title")}</div>
</div>
<button
type="button"
@ -926,7 +972,7 @@ export function SettingsTab() {
}}
>
<span style={{ color: "var(--text-hi)", fontSize: CONTROL_FONT_SIZE, fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", minWidth: 0 }}>
{autostartEnabled ? "Включен" : "Выключен"}
{autostartEnabled ? t("settings.autostart.on") : t("settings.autostart.off")}
</span>
<span
aria-hidden="true"
@ -959,14 +1005,14 @@ export function SettingsTab() {
</button>
</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.65 }}>
Запускать Talkis автоматически при входе в систему.
{t("settings.autostart.desc")}
</div>
</div>
<div className="card" style={SETTINGS_CARD_STYLE}>
<div style={{ display: "grid", gridTemplateColumns: SETTING_ROW_COLUMNS, alignItems: "center", gap: SETTING_ROW_GAP }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>Директория моделей</div>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>{t("settings.modelsDir.title")}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, justifySelf: "end", width: "100%" }}>
<button
@ -975,7 +1021,7 @@ export function SettingsTab() {
className="btn"
style={{ minHeight: CONTROL_HEIGHT, flex: 1, justifyContent: "center", padding: "0 10px", borderRadius: CONTROL_RADIUS, fontSize: CONTROL_FONT_SIZE }}
>
Изменить
{t("common.change")}
</button>
{localModelsDir && (
<button
@ -984,7 +1030,7 @@ export function SettingsTab() {
className="btn"
style={{ minHeight: CONTROL_HEIGHT, flex: 1, justifyContent: "center", padding: "0 10px", borderRadius: CONTROL_RADIUS, fontSize: CONTROL_FONT_SIZE }}
>
По умолчанию
{t("common.default")}
</button>
)}
</div>
@ -994,18 +1040,18 @@ export function SettingsTab() {
value={settings.localModelsDir}
onChange={(event) => { void update({ localModelsDir: event.target.value }); }}
className="input"
placeholder={defaultLocalModelsDir || "Директория по умолчанию"}
placeholder={defaultLocalModelsDir || t("settings.modelsDir.placeholder")}
style={{ height: 40, padding: "8px 10px", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", fontSize: 11 }}
/>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.65 }}>
Папка для скачанных локальных моделей. Оставьте поле пустым, чтобы использовать директорию по умолчанию.
{t("settings.modelsDir.desc")}
</div>
</div>
<div className="card" style={SETTINGS_CARD_STYLE}>
<div style={{ display: "grid", gridTemplateColumns: SETTING_ROW_COLUMNS, alignItems: "center", gap: SETTING_ROW_GAP }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>Хранение истории</div>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>{t("settings.storage.title")}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, justifySelf: "end", width: "100%" }}>
<button
@ -1014,7 +1060,7 @@ export function SettingsTab() {
className="btn"
style={{ minHeight: CONTROL_HEIGHT, flex: 1, justifyContent: "center", padding: "0 10px", borderRadius: CONTROL_RADIUS, fontSize: CONTROL_FONT_SIZE }}
>
Изменить
{t("common.change")}
</button>
{transcriptionStorageDir && (
<button
@ -1023,7 +1069,7 @@ export function SettingsTab() {
className="btn"
style={{ minHeight: CONTROL_HEIGHT, flex: 1, justifyContent: "center", padding: "0 10px", borderRadius: CONTROL_RADIUS, fontSize: CONTROL_FONT_SIZE }}
>
По умолчанию
{t("common.default")}
</button>
)}
</div>
@ -1033,11 +1079,11 @@ export function SettingsTab() {
value={settings.transcriptionStorageDir}
readOnly
className="input"
placeholder={defaultTranscriptionStorageDir || "Директория по умолчанию"}
placeholder={defaultTranscriptionStorageDir || t("settings.modelsDir.placeholder")}
style={{ height: 40, padding: "8px 10px", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", fontSize: 11 }}
/>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.65 }}>
Папка для истории диктовки и записей звонков. Оставьте пустым, чтобы использовать директорию по умолчанию.
{t("settings.storage.desc")}
</div>
{transcriptionStorageFeedback && (
<div style={{ fontSize: 13, color: transcriptionStorageFeedbackColor, lineHeight: 1.6 }}>
@ -1049,7 +1095,7 @@ export function SettingsTab() {
<div className="card" style={SETTINGS_CARD_STYLE}>
<div style={{ display: "grid", gridTemplateColumns: SETTING_ROW_COLUMNS, alignItems: "center", gap: SETTING_ROW_GAP }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>Поддержка</div>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>{t("settings.support.title")}</div>
</div>
<button
type="button"
@ -1058,11 +1104,11 @@ export function SettingsTab() {
style={{ minHeight: CONTROL_HEIGHT, width: "100%", justifySelf: "end", justifyContent: "center", gap: 8, padding: "0 10px", borderRadius: CONTROL_RADIUS, fontSize: CONTROL_FONT_SIZE }}
>
<Mail size={14} strokeWidth={2} />
Написать в поддержку
{t("settings.support.button")}
</button>
</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.65 }}>
Откроется почтовый клиент с письмом на {SUPPORT_EMAIL}. Опишите проблему или вопрос мы поможем.
{t("settings.support.desc", { email: SUPPORT_EMAIL })}
</div>
{supportFeedback && (
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>

File diff suppressed because it is too large Load diff

View file

@ -39,6 +39,7 @@ import {
transcribeFilePathOnly,
} from "../../lib/fileTranscription";
import { logError, logInfo } from "../../lib/logger";
import { tn, useI18n } from "../../lib/i18n";
import {
requestSystemAudioPermission,
requiresSystemAudioPermission,
@ -170,22 +171,22 @@ function isSelectedMicUnavailableError(error: unknown): boolean {
function microphoneStartErrorMessage(error: unknown): string {
if (isPermissionDeniedError(error)) {
return "Разрешите микрофон для записи созвона.";
return tn("widget.mic.permissionDenied");
}
if (error instanceof DOMException && error.name === "NotReadableError") {
return "Микрофон занят или недоступен. Закройте приложения, которые могут использовать микрофон, и попробуйте снова.";
return tn("widget.mic.busy");
}
if (isSelectedMicUnavailableError(error)) {
return "Выбранный микрофон недоступен. Проверьте микрофон в настройках Talkis.";
return tn("widget.mic.unavailable");
}
if (error instanceof DOMException && error.name === "AbortError") {
return "Микрофон не успел запуститься. Попробуйте ещё раз.";
return tn("widget.mic.startTimeout");
}
return "Не удалось получить доступ к микрофону для записи созвона.";
return tn("widget.mic.accessFailed");
}
function formatCallCaptureRawError(error: unknown): string {
@ -256,15 +257,15 @@ function callCaptureStartErrorMessage(error: unknown): string {
const normalized = rawMessage.toLowerCase();
if (normalized.includes("pipewire")) {
return "Не удалось начать запись системного звука Linux. Убедитесь, что PipeWire запущен и есть активное устройство вывода.";
return tn("widget.call.pipewireFailed");
}
if (normalized.includes("не поддерживается")) {
return "Запись системного звука не поддерживается на этой платформе.";
return tn("widget.call.systemAudioUnsupported");
}
if (normalized.includes("устройство вывода windows")) {
return "Не найдено устройство вывода Windows для записи системного звука.";
return tn("widget.call.windowsOutputMissing");
}
if (
@ -272,7 +273,7 @@ function callCaptureStartErrorMessage(error: unknown): string {
normalized.includes("windows loopback") ||
normalized.includes("запись системного звука windows")
) {
return "Не удалось начать запись системного звука Windows.";
return tn("widget.call.windowsCaptureFailed");
}
if (
@ -281,10 +282,10 @@ function callCaptureStartErrorMessage(error: unknown): string {
normalized.includes("звука системы") ||
normalized.includes("system audio")
) {
return "Разрешите запись звука системы для созвона.";
return tn("widget.call.systemAudioPermission");
}
return "Не удалось начать запись созвона. Проверьте разрешения микрофона и звука системы.";
return tn("widget.call.startFailed");
}
function isCallCapturePermissionError(error: unknown): boolean {
@ -307,6 +308,7 @@ function isCallCapturePermissionError(error: unknown): boolean {
}
export function Widget() {
const { t } = useI18n();
const widgetWindow = getCurrentWindow();
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const dragTriggeredRef = useRef(false);
@ -616,7 +618,7 @@ export function Widget() {
if (!granted) {
throw new CallCaptureStartError(
"Разрешите запись звука системы для созвона.",
t("widget.call.systemAudioPermission"),
true,
);
}
@ -762,8 +764,7 @@ export function Widget() {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logError("CALL_CAPTURE", `Call capture stop/process failed: ${message}`);
const userFacingMessage =
"Не удалось обработать запись. Попробуйте повторить попытку.";
const userFacingMessage = t("widget.call.processFailed");
try {
await saveFailedCallCaptureEntry({
@ -839,7 +840,7 @@ export function Widget() {
await finishProcessing({
...baseEntry,
status: "interrupted",
errorMessage: "Обработка остановлена. Можно запустить повторно.",
errorMessage: t("widget.processing.interrupted"),
});
return;
}
@ -870,7 +871,7 @@ export function Widget() {
await finishProcessing({
...baseEntry,
status: "interrupted",
errorMessage: "Обработка остановлена. Можно запустить повторно.",
errorMessage: t("widget.processing.interrupted"),
});
return;
}
@ -908,7 +909,7 @@ export function Widget() {
fileDragDepthRef.current += 1;
clearFileResetTimer();
setFileDropState("drag-over");
setFileDropName("Отпустите файл");
setFileDropName(t("widget.fileDrop.release"));
void resizeWidgetForFileDrop(true);
return;
}
@ -919,7 +920,7 @@ export function Widget() {
clearFileCloseTimer();
fileDragDepthRef.current = Math.max(1, fileDragDepthRef.current);
setFileDropState("drag-over");
setFileDropName("Отпустите файл");
setFileDropName(t("widget.fileDrop.release"));
return;
}
@ -1239,6 +1240,7 @@ function FileDropPill({
progress: FileTranscriptionProgress | null;
onOpenResult: () => void;
}) {
const { t } = useI18n();
const isProcessing = state === "processing";
const isSuccess = state === "success";
const isError = state === "error";
@ -1323,7 +1325,11 @@ function FileDropPill({
color: isError ? "#ffb4b4" : "rgba(255,255,255,0.94)",
}}
>
{showPercent ? `Транскрибация ${progressPercent}%` : "Транскрибация"}
{showPercent
? t("widget.fileDrop.transcribingPercent", {
percent: progressPercent,
})
: t("widget.fileDrop.transcribing")}
</span>
</div>
</ActiveWidgetShell>
@ -1345,6 +1351,7 @@ function CallBubble({
disabled: boolean;
onClick: () => void;
}) {
const { t } = useI18n();
const isStarting = state === "starting";
const isRecording = state === "recording";
const isProcessing = state === "processing";
@ -1352,16 +1359,16 @@ function CallBubble({
const isError = state === "error";
const copyIconColor = "rgba(255,255,255,0.72)";
const title = isError
? error || "Ошибка созвона"
? error || t("widget.callBubble.error")
: isStarting
? "Запрашиваем доступы"
? t("widget.callBubble.requestingAccess")
: isProcessing
? "Транскрибируем разговор"
? t("widget.callBubble.transcribing")
: isSuccess
? "Созвон готов"
? t("widget.callBubble.ready")
: isRecording
? "Завершить и транскрибировать"
: "Запись разговора";
? t("widget.callBubble.stopAndTranscribe")
: t("widget.callBubble.record");
const iconColor = disabled
? "rgba(255,255,255,0.28)"
: isRecording || isError
@ -1452,6 +1459,7 @@ function IdlePill({
onClick: () => void;
onRememberPasteTarget: () => void;
}) {
const { t } = useI18n();
const widgetWindow = getCurrentWindow();
const [isHovered, setIsHovered] = useState(false);
const [copySucceeded, setCopySucceeded] = useState(false);
@ -1548,8 +1556,8 @@ function IdlePill({
>
<button
type="button"
aria-label="Начать запись"
title="Начать запись"
aria-label={t("widget.idle.startRecording")}
title={t("widget.idle.startRecording")}
onPointerDown={(event) => {
event.stopPropagation();
}}
@ -1594,8 +1602,8 @@ function IdlePill({
{canCopy && (
<button
type="button"
aria-label="Скопировать последнюю запись"
title={copySucceeded ? "Скопировано" : "Скопировать"}
aria-label={t("widget.idle.copyLatest")}
title={copySucceeded ? t("widget.idle.copied") : t("widget.idle.copy")}
onPointerDown={(event) => {
event.stopPropagation();
}}
@ -1859,6 +1867,7 @@ function RecordingPill({
onPointerUp,
onPointerCancel,
}: RecordingPillProps & DragHandlers) {
const { t } = useI18n();
return (
<ActiveWidgetShell
width={IDLE_HOVER_WIDGET_WIDTH}
@ -1880,8 +1889,8 @@ function RecordingPill({
{locked && (
<button
type="button"
aria-label="Закончить запись"
title="Закончить запись"
aria-label={t("widget.recording.stopRecording")}
title={t("widget.recording.stopRecording")}
onPointerDown={(event) => {
event.stopPropagation();
}}

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { ReactElement } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
@ -7,6 +7,8 @@ import { NOTICE_AREA_HEIGHT, NOTICE_WIDGET_WIDTH, WIDGET_NOTICE_EVENT, type Widg
export function WidgetNoticeOverlay(): ReactElement | null {
const [notice, setNotice] = useState<WidgetNoticeState | null>(null);
const [expanded, setExpanded] = useState(false);
const bubbleRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let mounted = true;
@ -16,6 +18,8 @@ export function WidgetNoticeOverlay(): ReactElement | null {
return;
}
// A fresh notice always starts collapsed (Rust re-shows it at the small size).
setExpanded(false);
setNotice(event.payload);
});
@ -25,13 +29,25 @@ export function WidgetNoticeOverlay(): ReactElement | null {
};
}, []);
// Resize the OS window to fit the bubble whenever the expanded state (or the
// message) changes. scrollHeight is measured after React has dropped the
// line-clamp, so it reflects the full text height including padding.
useLayoutEffect(() => {
if (!notice) {
return;
}
const height = expanded && bubbleRef.current
? bubbleRef.current.scrollHeight
: NOTICE_AREA_HEIGHT;
void invoke("expand_widget_notice", { height });
}, [expanded, notice]);
if (!notice) {
return null;
}
const handleNoticeClick = async () => {
await invoke("open_settings_tab", { tab: "main" });
await invoke("hide_widget_notice");
const toggleExpanded = () => {
setExpanded((value) => !value);
};
return (
@ -48,23 +64,24 @@ export function WidgetNoticeOverlay(): ReactElement | null {
}}
>
<div
ref={bubbleRef}
role="button"
tabIndex={0}
onClick={() => {
void handleNoticeClick();
}}
aria-expanded={expanded}
onClick={toggleExpanded}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
void handleNoticeClick();
toggleExpanded();
}}
style={{
position: "relative",
width: NOTICE_WIDGET_WIDTH,
minHeight: NOTICE_AREA_HEIGHT,
maxHeight: expanded ? "100vh" : NOTICE_AREA_HEIGHT,
padding: "10px 14px",
borderRadius: 16,
fontSize: 11,
@ -76,20 +93,25 @@ export function WidgetNoticeOverlay(): ReactElement | null {
backdropFilter: "blur(18px)",
WebkitBackdropFilter: "blur(18px)",
animation: "widget-notice-in 0.22s cubic-bezier(0.22, 1, 0.36, 1)",
overflow: "hidden",
overflowY: expanded ? "auto" : "hidden",
overflowX: "hidden",
pointerEvents: "auto",
cursor: "pointer",
}}
>
<div
style={{
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
textOverflow: "ellipsis",
paddingRight: 4,
}}
style={
expanded
? { paddingRight: 4, whiteSpace: "pre-wrap", wordBreak: "break-word" }
: {
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
textOverflow: "ellipsis",
paddingRight: 4,
}
}
>
{notice.message}
</div>

View file

@ -16,6 +16,7 @@ import {
SETTINGS_UPDATED_EVENT,
} from "../../../lib/hotkeyEvents";
import { logError, logInfo } from "../../../lib/logger";
import { useI18n } from "../../../lib/i18n";
import type { WidgetAction, WidgetMachineState } from "../services/widgetMachine";
interface UseWidgetHotkeyParams {
@ -41,6 +42,7 @@ export function useWidgetHotkey({
clearReleaseStopTimer,
showError,
}: UseWidgetHotkeyParams): void {
const { t } = useI18n();
const attemptHotkeyRegistrationRef = useRef<(rawHotkey: string) => Promise<HotkeyRegistrationResultPayload>>(
attemptHotkeyRegistrationPlaceholder,
);
@ -92,7 +94,7 @@ export function useWidgetHotkey({
success: false,
requestedHotkey: rawHotkey,
activeHotkey: registeredHotkeyRef.current ?? settingsRef.current?.hotkey ?? DEFAULT_HOTKEY,
message: normalized.error || "Неверный формат горячей клавиши",
message: normalized.error || t("widget.hotkey.invalidFormat"),
};
}
@ -132,10 +134,10 @@ export function useWidgetHotkey({
success: false,
requestedHotkey: nextHotkey,
activeHotkey: currentHotkey ?? settingsRef.current?.hotkey ?? DEFAULT_HOTKEY,
message: `Не удалось зарегистрировать горячую клавишу "${nextHotkey}". Возможно, сочетание занято другим приложением.`,
message: t("widget.hotkey.registerFailed", { hotkey: nextHotkey }),
};
}
}, [handleHotkeyPress, registeredHotkeyRef, settingsRef]);
}, [handleHotkeyPress, registeredHotkeyRef, settingsRef, t]);
const registerCurrentHotkey = useCallback(async () => {
const activeSettings = settingsRef.current;
@ -146,9 +148,9 @@ export function useWidgetHotkey({
const result = await attemptHotkeyRegistration(activeSettings.hotkey || DEFAULT_HOTKEY);
if (!result.success) {
showError(result.message || "Не удалось зарегистрировать горячую клавишу.");
showError(result.message || t("widget.hotkey.registerFailedGeneric"));
}
}, [attemptHotkeyRegistration, settingsLoaded, settingsRef, showError]);
}, [attemptHotkeyRegistration, settingsLoaded, settingsRef, showError, t]);
useEffect(() => {
void registerCurrentHotkey();

View file

@ -3,6 +3,7 @@ import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { AppSettings, getSettings } from "../../../lib/store";
import { logError, logInfo } from "../../../lib/logger";
import { useI18n } from "../../../lib/i18n";
import { formatErrorMessage } from "../../../lib/utils";
import {
CALL_STACK_WIDGET_HEIGHT,
@ -118,6 +119,7 @@ export function useWidgetRecording({
onRecordingStart,
onRecordingStartFailed,
}: UseWidgetRecordingParams): UseWidgetRecordingResult {
const { t } = useI18n();
const runtimeRef = useRef(createRecordingRuntimeController());
const lowMicMonitorCleanupRef = useRef<(() => void) | null>(null);
const recordingSettingsRef = useRef<AppSettings | null>(null);
@ -183,7 +185,7 @@ export function useWidgetRecording({
lowStartedAt ??= now;
if (now - lowStartedAt >= LOW_MIC_SUSTAINED_MS) {
noticeShown = true;
showNotice("Микрофон слышит слишком тихо. Поднесите его ближе или проверьте выбранное устройство.", "info");
showNotice(t("widget.recording.lowMic"), "info");
}
}, LOW_MIC_SAMPLE_INTERVAL_MS);
@ -197,7 +199,7 @@ export function useWidgetRecording({
} catch (error) {
logError("RECORDING", `Low mic monitor failed: ${formatErrorMessage(error)}`);
}
}, [showNotice, stopLowMicMonitor]);
}, [showNotice, stopLowMicMonitor, t]);
// ── Start recording ─────────────────────────────────────────────────────
const startRecording = useCallback(async () => {
@ -213,7 +215,7 @@ export function useWidgetRecording({
if (!activeSettings) {
logError("RECORDING", "Settings not loaded");
showError("Настройки не загружены. Перезапустите приложение.");
showError(t("widget.recording.settingsNotLoaded"));
return;
}
@ -232,15 +234,13 @@ export function useWidgetRecording({
if (isCloudMode && !isSubscriptionMode) {
logError("RECORDING", "Cloud mode selected but device token is missing");
showError("Войдите в Talkis Cloud заново, чтобы использовать облачный режим.");
showError(t("widget.recording.cloudSignInRequired"));
return;
}
if (!isSubscriptionMode && !hasKey && !isLocalSttMode) {
logError("RECORDING", "No transcription model configured");
showError(
"Сначала установите модель в «Настройки → Модели»: облако, локальная модель или свой API-ключ.",
);
showError(t("widget.recording.noModelConfigured"));
return;
}
@ -300,7 +300,7 @@ export function useWidgetRecording({
`Mic access denied: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`,
);
onRecordingStartFailed?.();
showError("Нет доступа к микрофону. Разрешите доступ в системных настройках.");
showError(t("widget.recording.micAccessDenied"));
return;
}
}
@ -335,10 +335,15 @@ export function useWidgetRecording({
setStream(null);
logError("RECORDING", `Start error: ${error instanceof Error ? error.message : "unknown"}`);
showError(
`Ошибка запуска записи: ${error instanceof Error ? error.message : "Неизвестная ошибка"}`,
t("widget.recording.startError", {
error:
error instanceof Error
? error.message
: t("widget.recording.unknownError"),
}),
);
}
}, [dispatch, hideNotice, machineRef, onRecordingStart, onRecordingStartFailed, resizeWidget, setStream, settings, showError, startLowMicMonitor, stopLowMicMonitor]);
}, [dispatch, hideNotice, machineRef, onRecordingStart, onRecordingStartFailed, resizeWidget, setStream, settings, showError, startLowMicMonitor, stopLowMicMonitor, t]);
// ── Stop and process ────────────────────────────────────────────────────
const stopAndProcess = useCallback(async () => {
@ -372,7 +377,7 @@ export function useWidgetRecording({
if (!runtimeRef.current.hasAudioChunks()) {
logError("RECORDING", "No audio chunks recorded");
throw new Error("Аудио не записано. Попробуйте еще раз.");
throw new Error(t("widget.recording.noAudioRecorded"));
}
const blob = await runtimeRef.current.getAudioBlob();
@ -400,7 +405,7 @@ export function useWidgetRecording({
if (!pipelineResult.hasTranscription) {
recordingSettingsRef.current = null;
runtimeRef.current.reset();
showNotice("Речь не распознана. Попробуйте еще раз.", "info");
showNotice(t("widget.recording.speechNotRecognized"), "info");
dispatch({ type: "PROCESSING_COMPLETE" });
await resizeWidget(CALL_STACK_WIDGET_WIDTH, CALL_STACK_WIDGET_HEIGHT);
return;
@ -414,13 +419,13 @@ export function useWidgetRecording({
const errorMessage = formatErrorMessage(error);
logError("API", `Processing error: ${errorMessage}`);
const message = errorMessage && errorMessage !== "{}" ? errorMessage : "Ошибка обработки";
const message = errorMessage && errorMessage !== "{}" ? errorMessage : t("widget.recording.processingError");
recordingSettingsRef.current = null;
runtimeRef.current.reset();
showError(message);
}
}, [dispatch, machineRef, onRecordingProcessing, resizeWidget, setStream, settings, showError, showNotice, stopLowMicMonitor]);
}, [dispatch, machineRef, onRecordingProcessing, resizeWidget, setStream, settings, showError, showNotice, stopLowMicMonitor, t]);
// ── Keep stopAndProcessRef current ──────────────────────────────────────
useEffect(() => {

View file

@ -1,7 +1,10 @@
import ReactDOM from "react-dom/client";
import "../../index.css";
import { Widget } from "./Widget";
import { I18nProvider } from "../../lib/i18n";
ReactDOM.createRoot(document.getElementById("root")!).render(
<Widget />
<I18nProvider>
<Widget />
</I18nProvider>
);

View file

@ -3,6 +3,7 @@ import { emit } from "@tauri-apps/api/event";
import { AppSettings, deleteHistoryEntry, HistoryEntry } from "../../../lib/store";
import { logError, logInfo } from "../../../lib/logger";
import { tn } from "../../../lib/i18n";
import { formatErrorMessage } from "../../../lib/utils";
import { HISTORY_UPDATED_EVENT } from "../../../lib/hotkeyEvents";
import { beginProcessing, finishProcessing, isAbortError } from "../../../lib/processingControl";
@ -43,7 +44,7 @@ function toUserFacingErrorMessage(error: unknown): string {
const missingModelMatch = raw.match(/Model ['"]([^'"]+)['"] is not installed locally/i);
if (missingModelMatch) {
const model = missingModelMatch[1];
return `Локальный runtime запущен, но модель ${model} ещё не скачана. Откройте Настройки -> Модели -> Локально и нажмите «Скачать».`;
return tn("widget.error.modelNotDownloaded", { model });
}
if (
@ -54,54 +55,54 @@ function toUserFacingErrorMessage(error: unknown): string {
normalized.includes("os error 61") ||
normalized.includes("os error 111")
) {
return "Не удалось запустить локальный runtime распознавания. Откройте Настройки -> Модели -> Локально и нажмите «Скачать» для нужной Whisper-модели.";
return tn("widget.error.localRuntimeStartFailed");
}
if (normalized.includes("unsupported_country_region_territory") || normalized.includes("country, region, or territory not supported")) {
return "Сервис распознавания сейчас недоступен в вашем регионе. Попробуйте другой endpoint или VPN.";
return tn("widget.error.regionUnsupported");
}
if (normalized.includes("403") || normalized.includes("forbidden")) {
if (normalized.includes("subscription inactive") || normalized.includes("активная подписка") || normalized.includes("cloud mode")) {
return "Для облачного режима нужна активная подписка Talkis.";
return tn("widget.error.subscriptionRequired");
}
return "Сервис отклонил запрос. Проверьте API-ключ, регион доступа или настройки endpoint.";
return tn("widget.error.requestRejected");
}
if (normalized.includes("invalid or expired token") || normalized.includes("token expired") || normalized.includes("token missing user id")) {
return "Сессия Talkis Cloud истекла. Войдите в облако заново.";
return tn("widget.error.cloudSessionExpired");
}
if (normalized.includes("talkis cloud session missing")) {
return "Войдите в Talkis Cloud заново, чтобы использовать облачный режим.";
return tn("widget.error.cloudSignInRequired");
}
if (normalized.includes("talkis cloud returned an invalid response")) {
return "Talkis Cloud вернул некорректный ответ. Попробуйте отправить запись ещё раз.";
return tn("widget.error.cloudInvalidResponse");
}
if (normalized.includes("subscription check failed") || normalized.includes("cloud auth unavailable")) {
return "Talkis Cloud временно недоступен. Попробуйте ещё раз через несколько секунд.";
return tn("widget.error.cloudUnavailable");
}
if (normalized.includes("401") || normalized.includes("unauthorized") || normalized.includes("invalid api key")) {
return "Не удалось авторизоваться в API. Проверьте ваш ключ доступа.";
return tn("widget.error.authFailed");
}
if (normalized.includes("429") || normalized.includes("rate limit") || normalized.includes("quota")) {
return "Превышен лимит запросов или закончилась квота API. Попробуйте позже.";
return tn("widget.error.rateLimited");
}
if (normalized.includes("network") || normalized.includes("fetch") || normalized.includes("failed to fetch") || normalized.includes("timed out")) {
return "Не удалось связаться с сервером. Проверьте интернет и попробуйте снова.";
return tn("widget.error.networkFailed");
}
if (normalized.includes("500") || normalized.includes("502") || normalized.includes("503") || normalized.includes("504") || normalized.includes("server")) {
return "Сервис временно недоступен. Попробуйте повторить отправку чуть позже.";
return tn("widget.error.serverUnavailable");
}
return "Не удалось обработать запись. Попробуйте отправить ее повторно.";
return tn("widget.error.processingFailed");
}
function normalizeTranscriptForPlaceholderCheck(value: string): string {
@ -369,13 +370,11 @@ export async function processRecordingBlob({
}
}
const INTERRUPTED_MESSAGE = "Обработка остановлена. Можно запустить повторно.";
function buildInterruptedEntry(entry: HistoryEntry): HistoryEntry {
return {
...entry,
status: "interrupted",
errorMessage: INTERRUPTED_MESSAGE,
errorMessage: tn("widget.processing.interrupted"),
};
}
@ -385,7 +384,7 @@ export async function retryHistoryEntry(
options?: { shouldPaste?: boolean },
): Promise<RetryHistoryEntryResult> {
if (!entry.audioBase64) {
throw new Error("У этой записи нет сохраненного аудио для повторной отправки.");
throw new Error(tn("widget.error.noSavedAudio"));
}
const retrySettings: AppSettings = {
@ -415,7 +414,7 @@ export async function retryHistoryEntry(
}
if (!hasRecognizedSpeech(result)) {
throw new Error("Речь не распознана. Попробуйте отправить запись еще раз.");
throw new Error(tn("widget.error.speechNotRecognized"));
}
const updatedEntry: HistoryEntry = {