mirror of
https://github.com/SerTimBerrners-Lee/talkis.git
synced 2026-07-09 17:29:15 +00:00
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:
parent
fe81d2e7e8
commit
2296e9bda8
48 changed files with 3770 additions and 1018 deletions
|
|
@ -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
2
src-tauri/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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, ¬ice_window)?;
|
||||
// Always (re)show collapsed; the overlay expands on click.
|
||||
position_widget_notice_window(&widget_window, ¬ice_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, ¬ice_window, clamped)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn widget_resize(
|
||||
app: AppHandle,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
124
src/components/Dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
115
src/components/RowActionsMenu.tsx
Normal file
115
src/components/RowActionsMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/components/SlideOverModal.tsx
Normal file
109
src/components/SlideOverModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
300
src/components/SummaryModal.tsx
Normal file
300
src/components/SummaryModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" }}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
11
src/lib/i18n/dict/common.ts
Normal file
11
src/lib/i18n/dict/common.ts
Normal 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;
|
||||
148
src/lib/i18n/dict/components.ts
Normal file
148
src/lib/i18n/dict/components.ts
Normal 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;
|
||||
185
src/lib/i18n/dict/libMessages.ts
Normal file
185
src/lib/i18n/dict/libMessages.ts
Normal 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;
|
||||
80
src/lib/i18n/dict/settingsGeneral.ts
Normal file
80
src/lib/i18n/dict/settingsGeneral.ts
Normal 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;
|
||||
323
src/lib/i18n/dict/settingsModels.ts
Normal file
323
src/lib/i18n/dict/settingsModels.ts
Normal 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;
|
||||
268
src/lib/i18n/dict/settingsRest.ts
Normal file
268
src/lib/i18n/dict/settingsRest.ts
Normal 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;
|
||||
330
src/lib/i18n/dict/settingsTabsMisc.ts
Normal file
330
src/lib/i18n/dict/settingsTabsMisc.ts
Normal 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;
|
||||
49
src/lib/i18n/dict/summary.ts
Normal file
49
src/lib/i18n/dict/summary.ts
Normal 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
243
src/lib/i18n/dict/widget.ts
Normal 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
117
src/lib/i18n/index.tsx
Normal 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
27
src/lib/i18n/messages.ts
Normal 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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue