diff --git a/web/src/chat/presentation.ts b/web/src/chat/presentation.ts index 5877bbcf..09b4bf07 100644 --- a/web/src/chat/presentation.ts +++ b/web/src/chat/presentation.ts @@ -45,7 +45,7 @@ export function formatMessageTimestamp(date: Date, now: Date = new Date()): stri export function formatOutlineTimestamp( date: Date, - locale: 'en' | 'zh-CN', + locale: 'en' | 'zh-CN' | 'ru', now: Date = new Date() ): string { const sameDay = date.getFullYear() === now.getFullYear() @@ -67,6 +67,11 @@ export function formatOutlineTimestamp( return `${dateLabel} ${time}` } + if (locale === 'ru') { + const dateLabel = sameYear ? `${day}.${month}` : `${day}.${month}.${year}` + return `${dateLabel} ${time}` + } + const dateLabel = sameYear ? `${month}/${day}` : `${year}/${month}/${day}` return `${dateLabel} ${time}` } diff --git a/web/src/components/LanguageSwitcher.tsx b/web/src/components/LanguageSwitcher.tsx index 45fbbf8e..fa8243c4 100644 --- a/web/src/components/LanguageSwitcher.tsx +++ b/web/src/components/LanguageSwitcher.tsx @@ -4,6 +4,7 @@ import { useTranslation, type Locale } from '@/lib/use-translation' const locales: { value: Locale; label: string }[] = [ { value: 'en', label: 'English' }, { value: 'zh-CN', label: '简体中文' }, + { value: 'ru', label: 'Русский' }, ] function LanguageIcon() { diff --git a/web/src/components/settings/SettingsNav.tsx b/web/src/components/settings/SettingsNav.tsx index 5e4fbea5..d8038d2b 100644 --- a/web/src/components/settings/SettingsNav.tsx +++ b/web/src/components/settings/SettingsNav.tsx @@ -30,7 +30,7 @@ export function SettingsNav(props: { activeId?: string; mobile?: boolean }) { const { composerEnterBehavior } = useComposerEnterBehavior() const summaries: Record = { - general: locale === 'zh-CN' ? '简体中文' : 'English', + general: locale === 'zh-CN' ? '简体中文' : locale === 'ru' ? 'Русский' : 'English', display: `${t(`settings.display.appearance.${appearance}`)} · ${Math.round(fontScale * 100)}%`, chat: t(`settings.chat.enterBehavior.${composerEnterBehavior}`), voice: t('settings.hub.voice.summary'), diff --git a/web/src/hooks/useTelegram.ts b/web/src/hooks/useTelegram.ts index 4d52d088..60c3967e 100644 --- a/web/src/hooks/useTelegram.ts +++ b/web/src/hooks/useTelegram.ts @@ -40,6 +40,7 @@ export type TelegramWebAppUser = { username?: string first_name: string last_name?: string + language_code?: string } export type TelegramWebAppInitDataUnsafe = { diff --git a/web/src/lib/i18n-context.test.ts b/web/src/lib/i18n-context.test.ts new file mode 100644 index 00000000..753b5d2a --- /dev/null +++ b/web/src/lib/i18n-context.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + applyLateTelegramLocale, + detectInitialLocale, + normalizeLocaleTag, + resolveLateTelegramLocale +} from './i18n-context' + +function setTelegramLanguage(code: string | undefined) { + if (code === undefined) { + delete (window as unknown as { Telegram?: unknown }).Telegram + return + } + ;(window as unknown as { Telegram?: unknown }).Telegram = { + WebApp: { initDataUnsafe: { user: { language_code: code } } } + } +} + +beforeEach(() => { + localStorage.clear() + setTelegramLanguage(undefined) +}) + +afterEach(() => { + localStorage.clear() + setTelegramLanguage(undefined) + vi.restoreAllMocks() +}) + +describe('normalizeLocaleTag', () => { + it('maps Russian tags', () => { + expect(normalizeLocaleTag('ru')).toBe('ru') + expect(normalizeLocaleTag('ru-RU')).toBe('ru') + expect(normalizeLocaleTag('RU')).toBe('ru') + expect(normalizeLocaleTag('ru_RU')).toBe('ru') + }) + + it('maps Chinese tags to the simplified locale we ship', () => { + expect(normalizeLocaleTag('zh')).toBe('zh-CN') + expect(normalizeLocaleTag('zh-Hans')).toBe('zh-CN') + expect(normalizeLocaleTag('zh-CN')).toBe('zh-CN') + expect(normalizeLocaleTag('zh_CN')).toBe('zh-CN') + }) + + it('maps English tags', () => { + expect(normalizeLocaleTag('en')).toBe('en') + expect(normalizeLocaleTag('en-US')).toBe('en') + }) + + it('rejects unsupported or empty tags', () => { + expect(normalizeLocaleTag('de')).toBeNull() + expect(normalizeLocaleTag('')).toBeNull() + expect(normalizeLocaleTag(null)).toBeNull() + expect(normalizeLocaleTag(undefined)).toBeNull() + }) +}) + +describe('detectInitialLocale', () => { + it('prefers an explicit stored choice over Telegram', () => { + localStorage.setItem('hapi-lang', 'en') + setTelegramLanguage('ru') + + expect(detectInitialLocale()).toBe('en') + }) + + it('uses the Telegram Mini App language when nothing is stored', () => { + setTelegramLanguage('ru') + + expect(detectInitialLocale()).toBe('ru') + }) + + it('normalizes the Telegram language tag', () => { + setTelegramLanguage('zh-hans') + + expect(detectInitialLocale()).toBe('zh-CN') + }) + + it('falls back to the browser language', () => { + vi.spyOn(navigator, 'language', 'get').mockReturnValue('ru-RU') + + expect(detectInitialLocale()).toBe('ru') + }) + + it('falls back to English for unsupported languages', () => { + setTelegramLanguage('de') + vi.spyOn(navigator, 'language', 'get').mockReturnValue('de-DE') + + expect(detectInitialLocale()).toBe('en') + }) +}) + +describe('resolveLateTelegramLocale', () => { + it('picks up the language when the SDK appears after the timeout', async () => { + vi.useFakeTimers() + setTelegramLanguage(undefined) + + const pending = resolveLateTelegramLocale(500, 5) + await vi.advanceTimersByTimeAsync(1500) + setTelegramLanguage('ru') + await vi.advanceTimersByTimeAsync(500) + + await expect(pending).resolves.toBe('ru') + vi.useRealTimers() + }) + + it('resolves null when the SDK never loads', async () => { + vi.useFakeTimers() + setTelegramLanguage(undefined) + + const pending = resolveLateTelegramLocale(500, 2) + await vi.advanceTimersByTimeAsync(1500) + + await expect(pending).resolves.toBeNull() + vi.useRealTimers() + }) +}) + +describe('applyLateTelegramLocale', () => { + it('applies the detected locale when nothing is stored', () => { + const apply = vi.fn() + + expect(applyLateTelegramLocale('ru', apply)).toBe(true) + expect(apply).toHaveBeenCalledWith('ru') + }) + + it('does not override a manual choice made while the SDK loaded', () => { + localStorage.setItem('hapi-lang', 'en') + const apply = vi.fn() + + expect(applyLateTelegramLocale('ru', apply)).toBe(false) + expect(apply).not.toHaveBeenCalled() + }) + + it('ignores a null detection', () => { + const apply = vi.fn() + + expect(applyLateTelegramLocale(null, apply)).toBe(false) + expect(apply).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/lib/i18n-context.tsx b/web/src/lib/i18n-context.tsx index 99979c56..1f80c7c7 100644 --- a/web/src/lib/i18n-context.tsx +++ b/web/src/lib/i18n-context.tsx @@ -1,7 +1,8 @@ import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react' -import { en, zhCN } from './locales' +import { getTelegramWebApp, isTelegramEnvironment } from '@/hooks/useTelegram' +import { en, zhCN, ru } from './locales' -export type Locale = 'en' | 'zh-CN' +export type Locale = 'en' | 'zh-CN' | 'ru' export type Translations = Record @@ -13,7 +14,54 @@ export type I18nContextValue = { export const I18nContext = createContext(null) -const locales: Record = { en, 'zh-CN': zhCN } +const locales: Record = { en, 'zh-CN': zhCN, ru } + +/** + * Map a BCP-47-ish language tag (`ru`, `ru-RU`, `zh-Hans`, `en-US`) onto a + * supported locale. Returns null for anything we do not ship. + */ +export function normalizeLocaleTag(tag: string | null | undefined): Locale | null { + if (!tag) return null + const lower = tag.trim().toLowerCase().replace(/_/g, '-') + if (!lower) return null + if (lower === 'ru' || lower.startsWith('ru-')) return 'ru' + if (lower === 'zh' || lower.startsWith('zh-')) return 'zh-CN' + if (lower === 'en' || lower.startsWith('en-')) return 'en' + return null +} + +function readStoredLocale(): Locale | null { + try { + return normalizeLocaleTag(localStorage.getItem('hapi-lang')) + } catch { + return null + } +} + +/** + * Pick the locale for a fresh session: an explicit user choice wins, then the + * Telegram Mini App user's language, then the browser language, then English. + * Telegram runs the app in its own web view, so `localStorage` from a normal + * browser visit is not available and the language must be detected here. + */ +export function detectInitialLocale(): Locale { + const stored = readStoredLocale() + if (stored) return stored + + if (typeof window !== 'undefined') { + const telegramLocale = normalizeLocaleTag( + getTelegramWebApp()?.initDataUnsafe?.user?.language_code + ) + if (telegramLocale) return telegramLocale + } + + if (typeof navigator !== 'undefined') { + const browserLocale = normalizeLocaleTag(navigator.language) + if (browserLocale) return browserLocale + } + + return 'en' +} function interpolate(str: string, params?: Record): string { if (!params) return str @@ -23,11 +71,40 @@ function interpolate(str: string, params?: Record): str }) } +/** + * Telegram loads its SDK asynchronously and `loadTelegramSdk` gives up after a + * timeout, so the user's `language_code` can appear after the first render. + * Poll briefly for the WebApp and resolve the language once it shows up. + */ +export async function resolveLateTelegramLocale( + intervalMs = 500, + maxAttempts = 20 +): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const telegramLocale = normalizeLocaleTag( + getTelegramWebApp()?.initDataUnsafe?.user?.language_code + ) + if (telegramLocale) return telegramLocale + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + return null +} + +/** + * Apply a late-detected Telegram locale unless the user has stored an explicit + * choice in the meantime. Returns true when the locale was applied. + */ +export function applyLateTelegramLocale( + detected: Locale | null, + apply: (locale: Locale) => void +): boolean { + if (!detected || readStoredLocale()) return false + apply(detected) + return true +} + export function I18nProvider({ children }: { children: ReactNode }) { - const [locale, setLocaleState] = useState(() => { - const saved = localStorage.getItem('hapi-lang') - return (saved === 'en' || saved === 'zh-CN') ? saved : 'en' - }) + const [locale, setLocaleState] = useState(() => detectInitialLocale()) const setLocale = useCallback((newLocale: Locale) => { setLocaleState(newLocale) @@ -46,6 +123,24 @@ export function I18nProvider({ children }: { children: ReactNode }) { document.documentElement.lang = locale }, [locale]) + // The Telegram SDK can finish loading after `detectInitialLocale` ran, so + // pick up the Telegram language then — unless the user chose one explicitly. + useEffect(() => { + if (typeof window === 'undefined') return + if (readStoredLocale()) return + if (!isTelegramEnvironment()) return + if (normalizeLocaleTag(getTelegramWebApp()?.initDataUnsafe?.user?.language_code)) return + + let cancelled = false + void resolveLateTelegramLocale().then((detected) => { + if (cancelled) return + applyLateTelegramLocale(detected, setLocaleState) + }) + return () => { + cancelled = true + } + }, []) + return ( {children} diff --git a/web/src/lib/locales/index.ts b/web/src/lib/locales/index.ts index e4668824..6e339e93 100644 --- a/web/src/lib/locales/index.ts +++ b/web/src/lib/locales/index.ts @@ -1,2 +1,3 @@ export { default as en } from './en' export { default as zhCN } from './zh-CN' +export { default as ru } from './ru' diff --git a/web/src/lib/locales/ru.ts b/web/src/lib/locales/ru.ts new file mode 100644 index 00000000..c3d03910 --- /dev/null +++ b/web/src/lib/locales/ru.ts @@ -0,0 +1,1284 @@ +export default { + // Состояния загрузки + 'loading': 'Загрузка…', + 'authorizing': 'Авторизация…', + 'loading.session': 'Загрузка сессии…', + 'loading.git': 'Загрузка статуса Git…', + 'loading.file': 'Загрузка файла…', + 'loading.files': 'Загрузка файлов…', + 'loading.messages': 'Загрузка сообщений…', + 'loading.machines': 'Загрузка машин…', + 'message.copy': 'Копировать', + 'message.copied': 'Скопировано', + 'message.info': 'Сведения о сообщении', + 'message.fork': 'Форк', + 'message.rewind': 'Откат', + 'message.fork.confirmTitle': 'Форк диалога', + 'message.fork.confirmDescription': 'Создать новую сессию с этого места?\nТекущая сессия не будет изменена.', + 'message.fork.confirming': 'Создание форка…', + 'message.rewind.confirmTitle': 'Откат диалога', + 'message.rewind.confirmDescription': 'Откатить эту сессию к этому месту?\nИстория диалога после этого момента будет удалена безвозвратно. Файлы изменены не будут.', + 'message.rewind.confirming': 'Откат…', + 'message.rewind.fallbackTitle': 'Откат недоступен', + 'message.rewind.fallbackDescription': 'HAPI не изменил эту сессию, поскольку её нативная граница Codex неоднозначна.\nСоздать новую сессию до этого места? Текущая сессия не будет изменена.', + 'message.rewind.fallbackFork': 'Форк здесь', + 'message.rewind.fallbackForking': 'Создание форка…', + 'message.shareTurn': 'Поделиться ходом как изображением', + 'shareTurn.title': 'Поделиться ходом как изображением', + 'shareTurn.badge': 'Общий ход', + 'shareTurn.generated': 'Создано в HAPI', + 'shareTurn.cancel': 'Отмена', + 'shareTurn.copy': 'Копировать', + 'shareTurn.copying': 'Копирование…', + 'shareTurn.copied': 'Скопировано', + 'shareTurn.share': 'Поделиться', + 'shareTurn.sharing': 'Отправка…', + 'shareTurn.download': 'Скачать', + 'shareTurn.saving': 'Сохранение…', + + // Вход / Аутентификация + 'login.title': 'HAPI', + 'login.subtitle': 'Vibe Coding — в любое время, в любом месте', + 'login.bind.title': 'Привязать Telegram', + 'login.submit': 'Войти', + 'login.bind.submit': 'Привязать', + 'login.submitting': 'Вход…', + 'login.bind.submitting': 'Привязка…', + 'login.placeholder': 'Токен доступа', + 'login.error.enterToken': 'Введите токен доступа', + 'login.error.bindingUnavailable': 'Привязка недоступна.', + 'login.error.loginUnavailable': 'Вход недоступен.', + 'login.error.bindFailed': 'Не удалось привязать', + 'login.error.authFailed': 'Ошибка аутентификации', + 'login.help': 'Нужна помощь?', + 'login.server.default': '(По умолчанию)', + 'login.server.custom': '(Свой)', + 'login.server.title': 'URL хаба', + 'login.server.description': 'Задайте origin хаба HAPI для API и живых обновлений.', + 'login.server.current': 'Текущий:', + 'login.server.origin': 'Origin хаба', + 'login.server.placeholder': 'https://hapi.example.com', + 'login.server.hint': 'Только http(s). Путь игнорируется.', + 'login.server.required': 'Требуется URL хаба. Укажите его перед входом.', + 'login.server.useSameOrigin': 'Использовать тот же origin', + 'login.server.save': 'Сохранить хаб', + 'login.footer': 'Создано с', + 'login.footer.for': 'для Vibe Coding', + 'login.footer.copyright': '©', + + // Страница сессий + 'sessions.new': 'Новая сессия', + 'sessions.empty.title': 'Сессий пока нет', + 'sessions.empty.hint': 'Запустите сессию в любой папке рабочего пространства или сначала просмотрите дерево.', + 'sessions.empty.startSession': 'Запустить сессию', + 'sessions.empty.browse': 'Просмотр рабочего пространства', + 'sessions.search.placeholder': 'Поиск по названию/пути/агенту/машине/ID…', + 'sessions.search.open': 'Поиск сессий (название, путь, агент, имя машины, ID и другое)', + 'sessions.search.clear': 'Очистить поиск', + 'sessions.search.noResults': 'Нет сессий, соответствующих фильтрам.', + 'sessions.unreadFilter.toggle': 'Только непрочитанные', + 'sessions.markAllRead.button': 'Отметить все как прочитанные ({count})', + 'sessions.markAllRead.title': 'Отметить все как прочитанные?', + 'sessions.markAllRead.description': 'Будут отмечены как прочитанные {count} непрочитанных сессий на этом устройстве.', + 'sessions.markAllRead.confirm': 'Подтвердить', + 'sessions.markAllRead.confirming': 'Отмечаем как прочитанные…', + 'sessions.timeFilter.label': 'Фильтр сессий по последней активности', + 'sessions.timeFilter.pickStart': 'Выбрать дату начала', + 'sessions.timeFilter.pickEnd': 'Выбрать дату окончания', + 'sessions.timeFilter.clear': 'Очистить', + 'sessions.timeFilter.close': 'Закрыть выбор даты', + 'sessions.timeFilter.previousMonth': 'Предыдущий месяц', + 'sessions.timeFilter.nextMonth': 'Следующий месяц', + 'sessions.timeFilter.dayWithActivity': '{date}, есть активность сессии', + 'sessions.group.expand': 'Развернуть {n}', + 'sessions.group.collapse': 'Свернуть {n}', + 'sessions.runningSection': 'В работе', + 'sessions.activeSection': 'Активные сессии', + 'sessions.group.new': 'Новая сессия в этой папке', + 'sessions.machineFilter.label': 'Фильтр сессий по машине', + 'sessions.machineFilter.all': 'Все', + 'codexSync.tooltip': 'Импортировать сессии из Codex в Hapi', + 'codexSync.newSessionAction': 'Импорт истории Codex', + 'codexSync.confirm.title': 'Импорт сессий Codex', + 'codexSync.confirm.description': 'Выберите сессии Codex для импорта в Hapi', + 'piImport.inline.title': 'Импорт истории Pi', + 'piImport.inline.description': 'Необязательно: выберите существующую локальную сессию Pi для импорта и продолжения.', + 'piImport.inline.clear': 'Очистить', + 'piImport.inline.choose': 'Выбрать историю', + 'piImport.dialog.title': 'Импорт сессий Pi', + 'piImport.dialog.description': 'Выберите локальные истории Pi. Один выбор продолжается сразу; несколько выборов импортируются в фоне.', + 'piImport.dialog.concurrentWarning': 'Несколько процессов могут открыть одну и ту же сессию Pi, но избегайте записи из устаревших копий. Перед продолжением в HAPI перезагрузите или закройте любой другой процесс Pi, чтобы избежать непреднамеренного ответвления.', + 'piImport.loading': 'Сканирование сессий Pi…', + 'piImport.empty': 'Не найдено сессий Pi для импорта.', + 'piImport.selectedCount': 'Выбрано: {n}', + 'piImport.clearAll': 'Очистить все', + 'piImport.selectAll': 'Выбрать все', + 'piImport.allDirectories': 'Все каталоги', + 'piImport.search': 'Поиск по названию, запросу, пути, модели или ID', + 'piImport.imported': 'Импортировано', + 'piImport.needsAttention': 'Требует внимания', + 'piImport.messages': 'сообщений', + 'piImport.importing': 'Импорт…', + 'piImport.confirm': 'Импортировать', + 'piImport.success.title': 'Сессии Pi импортированы', + 'piImport.success.body': 'Импортировано сессий Pi: {n}.', + 'piImport.failed.title': 'Не удалось импортировать Pi', + 'piImport.failed.body': 'Не удалось импортировать историю Pi.', + 'piImport.failed.partial': 'Не удалось импортировать сессий: {failed}. Причина: {reason}', + 'piImport.error.diverged': 'Локальная ветка Pi изменилась после предыдущего импорта. Проверьте сессию перед продолжением.', + 'piImport.error.active': 'Остановите активную сессию Hapi Pi перед импортом более новой нативной истории.', + 'piImport.manual.success.title': 'Синхронизация истории Pi завершена', + 'piImport.manual.success.body': 'Синхронизировано новых элементов истории Pi: {n}.', + 'piImport.manual.success.noNewMessages': 'История Pi уже актуальна.', + 'piImport.manual.failed.title': 'Не удалось синхронизировать историю Pi', + // Комментарий: ниже — тексты для списка с множественным выбором в диалоге импорта, состояния загрузки и подсказки кнопки перезапуска клиента Codex в правом верхнем углу. + 'codexSync.confirm.selectAll': 'Выбрать все', + 'codexSync.confirm.clearAll': 'Очистить все', + 'codexSync.confirm.selectedCount': 'Выбрано сессий: {n}', + 'codexSync.confirm.empty': 'Локальные сессии Codex не найдены', + 'codexSync.confirm.current': 'Связано', + 'codexSync.confirm.cwd': 'Рабочий каталог', + 'codexSync.confirm.cwdFilter': 'Рабочий каталог', + 'codexSync.confirm.cwdFilterAll': 'Все рабочие каталоги', + 'codexSync.confirm.emptyForWorkdir': 'Нет сессий Codex в этом рабочем каталоге', + 'codexSync.confirm.confirm': 'Импортировать', + 'codexSync.confirm.confirming': 'Импорт…', + 'codexSync.confirm.loading': 'Загрузка локальных сессий Codex…', + 'codexSync.success.title': 'Импорт завершён', + 'codexSync.success.body': 'Импортировано сессий Codex в Hapi: {n}.', + 'codexSync.manual.success.title': 'Синхронизация Codex завершена', + 'codexSync.manual.success.body': 'Синхронизировано сессий Codex: {n}.', + 'codexSync.manual.failed.title': 'Не удалось синхронизировать сессию Codex', + 'codexSync.error.timeout': 'Время операции истекло', + 'codexSync.error.active': 'Эта сессия ещё активна. Дождитесь её завершения и повторите.', + 'codexSync.failed.title': 'Не удалось импортировать сессии Codex', + 'codexSync.failed.body': 'Не удалось импортировать сессии Codex.', + 'codexSync.failed.bodyWithReason': 'Импорт не удался: {reason}', + 'codexSync.restart.tooltip': 'Перезапустить клиент Codex', + 'codexSync.restart.title': 'Перезапустить клиент Codex', + 'codexSync.restart.description': 'Перезапустить клиент Codex сейчас, чтобы обновить сессии?', + 'codexSync.restart.confirm': 'Перезапустить', + 'codexSync.restart.confirming': 'Перезапуск…', + 'codexSync.restart.failed.title': 'Не удалось перезапустить клиент Codex', + 'codexSync.restart.failed.notFound': 'Не удалось перезапустить клиент Codex: не установлен или не найден', + 'codexSync.restart.failed.body': 'Не удалось выполнить скрипт перезапуска.', + 'codexSync.restart.started.title': 'Перезапуск запущен', + 'codexSync.restart.started.body': 'Клиент Codex перезапускается для обновления сессий.', + 'codexSync.duplicates.confirm.title': 'Обнаружены дубликаты сессий', + 'codexSync.duplicates.confirm.description': 'Обнаружены дубликаты сессий. Объединить их?', + 'codexSync.duplicates.confirm.confirm': 'Объединить', + 'codexSync.duplicates.confirm.confirming': 'Объединение…', + 'codexSync.duplicates.detect.failed.title': 'Не удалось обнаружить дубликаты сессий', + 'codexSync.duplicates.detect.failed.body': 'Не удалось обнаружить дубликаты сессий.', + 'codexSync.duplicates.merge.success.title': 'Дубликаты сессий объединены', + 'codexSync.duplicates.merge.success.body': 'Объединены дубликаты выбранных сессий Codex.', + 'codexSync.duplicates.merge.failed.title': 'Не удалось объединить дубликаты сессий', + 'codexSync.duplicates.merge.failed.body': 'Не удалось объединить дубликаты сессий.', + + // Список сессий + 'session.item.path': 'путь', + 'session.item.agent': 'агент', + 'session.item.model': 'модель', + 'session.item.machine': 'машина', + 'session.modelChange.pending': 'Изменение…', + 'session.modelChange.cursorAutoRestart': 'Авто (для переключения обратно нужен перезапуск)', + 'session.modelChange.pendingTooltip': 'Модель изменится, когда текущая задача завершится.', + 'session.item.worktree': 'рабочее дерево', + 'session.item.pending': 'ожидание', + 'session.item.thinking': 'размышление', + 'session.item.running': 'Выполняется', + 'session.item.active': 'Активна', + 'session.item.idle': 'Простаивает (только keepalive)', + 'session.item.permission': 'Требуется разрешение', + 'session.item.needsInput': 'Нужен ввод', + 'session.item.background': 'Выполняются фоновые задачи', + 'session.item.newActivity': 'Новая активность', + 'session.item.scheduledMessage': 'Ожидает отложенное сообщение', + 'session.item.scheduledMessages': 'Ожидает отложенных сообщений: {count}', + 'session.tooltip.permission.body': 'Одобрить:', + 'session.tooltip.input.body': 'Ответить:', + 'session.tooltip.background.count.one': 'Выполняется 1 задача', + 'session.tooltip.background.count.other': 'Выполняется задач: {count}', + 'session.tooltip.scheduled.body': 'Сработает по наступлении срока.', + 'session.tooltip.scheduled.fires': 'Сработает {when}', + 'session.tooltip.scheduled.next': 'Следующее {when} · ещё +{more}', + 'session.tooltip.moreCount': 'ещё +{count}', + 'session.time.justNow': 'только что', + 'session.time.minutesAgo': '{n} мин назад', + 'session.time.hoursAgo': '{n} ч назад', + 'session.time.daysAgo': '{n} дн назад', + 'session.time.inLessThanMinute': 'через <1 мин', + 'session.time.inMinutes': 'через {n} мин', + 'session.time.inHours': 'через {n} ч', + 'session.time.inDays': 'через {n} дн', + 'session.time.soon': 'скоро', + 'session.time.importedFromCodex.justNow': 'только что импортировано из Codex', + 'session.time.importedFromCodex.minutesAgo': 'импортировано из Codex {n} мин назад', + 'session.time.importedFromCodex.hoursAgo': 'импортировано из Codex {n} ч назад', + 'session.time.importedFromCodex.daysAgo': 'импортировано из Codex {n} дн назад', + 'session.cursorMigration.banner.title': 'Обновление сессии Cursor', + 'session.cursorMigration.banner.body': 'Переключение этого устаревшего чата на более безопасный протокол ACP. Для сессий с длинной историей это занимает 15–20 секунд; ваш диалог возобновится автоматически, а любой черновой текст сохранится.', + 'session.cursorMigration.bannerAmbiguous.title': 'Обновление сессии Cursor требует проверки вручную', + 'session.cursorMigration.bannerAmbiguous.body': 'Этот чат существует на диске в нескольких папках рабочего пространства, либо размер на диске не совпадает с синхронизированной историей, поэтому HAPI отказался переносить его автоматически. Проверьте логи хаба на предмет списка кандидатов (поиск по «[migrator] ambiguous legacy store» или «[migrator] size sanity check refused»), удалите устаревшие папки в ~/.cursor/chats/ и откройте сессию заново.', + + // Неактивная сессия + 'session.inactive.autoResume': 'Эта сессия неактивна. Отправьте сообщение, чтобы возобновить.', + 'session.inactive.cannotResume': 'Эта сессия неактивна и не может быть возобновлена.', + + // Шапка сессии + 'session.title': 'Файлы', + 'session.view.returnToChat': 'Вернуться к диалогу', + 'session.more': 'Дополнительные действия', + 'session.outline.open': 'План диалога', + 'session.outline.close': 'Закрыть план', + 'session.outline.title': 'План', + 'session.outline.loadOlder': 'Загрузить более ранние', + 'session.outline.empty': 'В загруженных сообщениях нет элементов плана', + 'session.outline.searchPlaceholder': 'Поиск по плану', + 'session.outline.searchLabel': 'Поиск элементов плана', + 'session.outline.searchResults': '{matched} из {total} элементов', + 'session.outline.noSearchResults': 'Нет подходящих элементов плана', + + // Действия с сессией + 'session.action.rename': 'Переименовать', + 'session.action.pinProject': 'Закрепить в проекте', + 'session.action.unpinProject': 'Открепить от проекта', + 'session.action.pinGlobal': 'Закрепить глобально', + 'session.action.unpinGlobal': 'Открепить глобально', + 'session.action.pinFailed': 'Не удалось изменить закрепление', + 'sessions.pinnedSection': 'Закреплённые сессии', + 'session.action.export': 'Экспортировать диалог', + 'session.action.markUnread': 'Отметить как непрочитанную', + 'session.action.syncCodex': 'Синхронизировать Codex', + 'session.action.syncPi': 'Синхронизировать историю Pi', + 'session.action.archive': 'Архивировать', + 'session.action.reopen': 'Открыть заново', + 'session.action.reopenCursorChecking': 'Проверка доступности данных чата Cursor на записанной машине.', + 'session.action.reopenCursorMissing': 'Данные чата Cursor больше недоступны на записанной машине.', + 'session.action.reopenCursorCheckFailed': 'Не удалось проверить данные чата Cursor на записанной машине.', + 'session.action.reopenCursorUnverified': 'Не удалось проверить данные чата Cursor (раннер может быть устаревшим). Повторное открытие всё ещё доступно — обновите CLI на той машине, если оно не сработает.', + 'runner.skew.updateRequired': 'ТРЕБУЕТСЯ ОБНОВЛЕНИЕ', + 'runner.skew.banner.title': 'Раннер устарел на {host}', + 'runner.skew.banner.body': 'Этому хабу нужна более новая версия HAPI CLI на {host} (сейчас {version}). Обновите CLI на той машине, затем перезапустите раннер (`systemctl --user restart hapi-runner` или `hapi runner start`). Этот баннер будет отображаться, пока раннер не сообщит о наличии требуемых возможностей.', + 'runner.skew.banner.bodyUnknownVersion': 'Этому хабу нужна более новая версия HAPI CLI на {host}. Обновите CLI на той машине, затем перезапустите раннер (`systemctl --user restart hapi-runner` или `hapi runner start`). Этот баннер будет отображаться, пока раннер не сообщит о наличии требуемых возможностей.', + 'runner.skew.banner.summaryTitle': 'Устаревших раннеров: {count}', + 'runner.skew.banner.summaryBody': 'Обновите HAPI CLI на каждом хосте ниже, затем перезапустите соответствующий раннер. Сверните или отложите этот баннер, чтобы добраться до сессий под ним.', + 'runner.skew.banner.minimized': 'Устаревших раннеров: {count} ({hosts}) — нажмите, чтобы развернуть', + 'runner.skew.banner.minimize': 'Свернуть', + 'runner.skew.banner.dismissTemp': 'Отложить на 1 ч', + 'runner.skew.banner.restart': 'Перезапустить раннер', + 'runner.skew.banner.restartUnavailable': 'Сначала обновите CLI', + 'runner.skew.banner.restartNeedsNewerBinary': 'Перезапуск поможет только после установки более новой версии HAPI CLI на этом хосте. Он не скачивает пакеты.', + 'runner.skew.banner.restartNeedsSupervisor': 'Перезапуск работает только когда раннер под наблюдением (HAPI_RUNNER_SUPERVISED=1 / systemd). Остановка без наблюдателя оставит хост офлайн.', + 'runner.skew.banner.restarting': 'Перезапуск…', + 'runner.skew.banner.binaryUpdatedHint': '(на диске уже более новая версия CLI — раннер скоро перезапустится сам)', + 'runner.skew.banner.upgradeCliFirst': '(на диске ещё нет более новой версии CLI — установите/обновите на этом хосте)', + 'runner.skew.banner.handoffHint': 'После обновления CLI на хосте этот раннер обычно перезапускается сам (передача версии). Перезапуск здесь только останавливает раннер под наблюдением, чтобы systemd/pm2 перезапустил его — никогда не используйте на раннере отдельного ноутбука.', + 'runner.skew.restartFailed': 'Не удалось перезапустить раннер', + 'session.action.delete': 'Удалить', + 'session.action.copy': 'Копировать', + 'session.action.copyReference': 'Копировать ссылку', + + // Диалоги + 'dialog.uri.title': 'Открыть эту ссылку?', + 'dialog.uri.description': 'Эта ссылка использует пользовательскую схему URI. Убедитесь, что доверяете этой ссылке, прежде чем открывать её.', + 'dialog.uri.open': 'Открыть', + 'dialog.uri.alwaysAllow': 'Всегда разрешать {scheme}:', + 'dialog.rename.title': 'Переименовать сессию', + 'dialog.rename.placeholder': 'Название сессии', + 'dialog.rename.save': 'Сохранить', + 'dialog.rename.saving': 'Сохранение…', + 'dialog.rename.error': 'Не удалось переименовать. Попробуйте ещё раз.', + 'dialog.rename.generate': 'Сгенерировать', + 'dialog.rename.generating': 'Генерация…', + 'dialog.rename.generateError': 'Не удалось сгенерировать название. Попробуйте ещё раз.', + 'dialog.archive.title': 'Архивировать сессию', + 'dialog.archive.description': 'Вы уверены, что хотите архивировать «{name}»? Активная сессия будет отключена.', + 'dialog.archive.confirm': 'Архивировать', + 'dialog.archive.confirming': 'Архивирование…', + 'dialog.reopen.errorTitle': 'Не удалось открыть сессию заново', + 'dialog.reopen.dismiss': 'Закрыть', + 'dialog.delete.title': 'Удалить сессию', + 'dialog.delete.description': 'Вы уверены, что хотите удалить «{name}»? Это действие необратимо.', + 'dialog.delete.scratchlist.one': 'Также будет удалена 1 запись блокнота.', + 'dialog.delete.scratchlist.other': 'Также будут удалены записи блокнота: {n}.', + 'dialog.delete.confirm': 'Удалить', + 'dialog.delete.confirming': 'Удаление…', + 'dialog.error.default': 'Операция не удалась. Попробуйте ещё раз.', + + // Экспорт сессии + 'session.export.title': 'Экспорт диалога', + 'session.export.description': 'Выберите формат, затем скачайте весь видимый диалог и записи блокнота.', + 'session.export.format.json': 'JSON', + 'session.export.format.json.description': 'Полные данные без потерь: метаданные сессии, сообщения и записи блокнота (только метаданные вложений).', + 'session.export.format.markdown': 'Markdown', + 'session.export.format.markdown.description': 'Читаемое представление, сгенерированное из тех же данных экспорта.', + 'session.export.download': 'Скачать', + 'session.export.downloadAnyway': 'Скачать в любом случае', + 'session.export.downloading': 'Экспорт…', + 'session.export.warning.title': 'Большой экспорт', + 'session.export.warning.description': 'Этот экспорт содержит {count} сообщений и оценивается в {size}. Это превышает рекомендуемый порог в {limit} сообщений.', + 'session.export.error.noApi': 'Нет соединения с сервером', + 'session.export.error.tooLarge': 'Этот экспорт слишком велик для безопасной подготовки ({count} сообщений, {size}; максимум {maxSize}).', + 'session.export.error.default': 'Не удалось экспортировать диалог', + 'session.export.toast.success.title': 'Диалог экспортирован', + 'session.export.toast.success.body': 'Скачан {filename}', + 'session.export.toast.error.title': 'Экспорт не удался', + + // Диаграммы Mermaid + 'mermaid.openFullscreen': 'Открыть диаграмму на весь экран', + 'mermaid.viewerTitle': 'Диаграмма', + 'mermaid.loading': 'Загрузка диаграммы…', + 'mermaid.renderError': 'Не удалось отобразить диаграмму.', + + // Общие кнопки + 'button.cancel': 'Отмена', + 'button.save': 'Сохранить', + 'button.confirm': 'Подтвердить', + 'button.close': 'Закрыть', + 'button.dismiss': 'Закрыть', + 'button.copy': 'Копировать', + 'button.paste': 'Вставить', + + // Форма новой сессии + 'newSession.title': 'Создать сессию', + 'newSession.machine': 'Машина', + 'newSession.directory': 'Каталог', + 'newSession.placeholder': '/путь/к/проекту', + 'newSession.browse': 'Обзор', + 'newSession.recent': 'Недавние пути', + 'newSession.type': 'Тип сессии', + 'newSession.type.simple': 'Обычная', + 'newSession.type.simple.desc': 'Использовать выбранный каталог как есть', + 'newSession.type.worktree': 'Рабочее дерево', + 'newSession.type.worktree.desc': 'Создать новое рабочее дерево рядом с репозиторием (Cursor использует нативные ~/.cursor/worktrees)', + 'newSession.type.worktree.placeholder': 'feature-x (по умолчанию 1228-xxxx)', + 'newSession.agent': 'Агент', + 'newSession.agentAvailabilityLoading': 'Проверка установленных агентов…', + 'newSession.agentAvailabilityFailed': 'Не удалось проверить установленные агенты.', + 'newSession.runnerUpgradeRequired': 'Перед созданием сессий обновите и перезапустите раннер HAPI на этой машине.', + 'newSession.noAvailableAgents': 'На этой машине не установлены поддерживаемые агенты.', + 'newSession.agentUnavailable': 'Выбранный агент больше недоступен на этой машине. Обновите список и выберите другого агента.', + 'newSession.directoryOutsideWorkspaceRoots': 'Каталог должен находиться внутри одного из корней рабочего пространства этой машины.', + 'newSession.model': 'Модель', + 'newSession.effort': 'Усилия', + 'newSession.model.optional': 'необязательно', + 'newSession.model.default': 'По умолчанию', + 'newSession.model.loadFailed': 'Не удалось загрузить модели', + 'newSession.model.selectVariant': 'Выбрать вариант', + 'newSession.model.cursorUnavailable': 'Моделей Cursor пока нет. Запустите сессию Cursor один раз или используйте Auto.', + 'newSession.agyModel.fetchingModels': 'Получение доступных моделей…', + 'newSession.agyModel.authRequired': 'Требуется аутентификация', + 'newSession.agyModel.authHint': 'Выполните `agy` в терминале, чтобы войти через Google.', + 'newSession.agyModel.retry': 'Повторить', + 'newSession.agyModel.notListed': 'больше не в списке', + 'newSession.agyModel.noModels': 'Нет доступных моделей Antigravity', + 'newSession.opencodeModel.loading': 'Поиск моделей OpenCode…', + 'newSession.opencodeModel.loadFailed': 'Не удалось загрузить модели OpenCode', + 'newSession.opencodeModel.retry': 'Повторить', + 'newSession.opencodeModel.empty': 'Для этого каталога не найдено моделей OpenCode', + 'newSession.opencodeModel.default': 'По умолчанию', + 'newSession.permission.managedTitle': 'Управляется агентом', + 'newSession.permission.managedDesc': 'Этот агент управляет своими разрешениями самостоятельно. Режим YOLO недоступен.', + 'newSession.reasoningEffort': 'Уровень рассуждений', + 'newSession.collaborationMode': 'Режим совместной работы', + 'newSession.copilotAgentMode': 'Режим агента', + 'newSession.fastMode': 'Быстрый режим', + 'newSession.yolo': 'Режим YOLO', + 'newSession.yolo.title': 'Обойти подтверждения и песочницу', + 'newSession.yolo.desc': 'Использует опасные флаги агента при запуске.', + 'newSession.yolo.mapsTo': 'Применяет нативный режим {mode}.', + 'newSession.grokAutoUnavailable': 'недоступно', + 'newSession.grokAutoUnavailableDesc': 'Grok не включил автоматические разрешения для этой учётной записи или сборки CLI.', + 'newSession.create': 'Создать', + 'newSession.creating': 'Создание…', + 'button.retry': 'Повторить', + + 'session.directoryMissingSimple': 'Каталог не существует. При создании сессии он будет создан автоматически.', + 'session.directoryMissingSimpleConfirm': 'Каталог не существует. Нажмите ещё раз, чтобы создать его автоматически.', + 'session.directoryMissingWorktree': 'Сессии с рабочим деревом требуют существующего каталога репозитория.', + 'session.codexModelsLoadFailed': 'Не удалось загрузить модели Codex', + 'session.createAndCreateDirectory': 'Создать и создать каталог', + + // Машина + 'machine.unknown': 'Неизвестная платформа', + 'machine.os.windows': 'Windows', + 'machine.os.linux': 'Linux', + 'machine.os.macos': 'macOS', + 'machine.os.unknown': 'Неизвестная ОС', + 'machine.header.sessionCount': 'Сессий: {n}', + 'machine.health.tooltip.title': 'Ёмкость машины', + 'machine.health.status.healthy': 'В норме — есть место для новых агентов', + 'machine.health.status.elevated': 'Повышенная — новые агенты могут работать медленнее', + 'machine.health.status.high': 'Высокая нагрузка — лучше не запускать здесь новые', + 'machine.health.status.unknown': 'Метрики недоступны', + 'machine.health.metric.cpu': 'ЦП', + 'machine.health.metric.ram': 'ОЗУ', + 'machine.health.tooltip.load': 'Очередь выполнения (1 мин): {value}', + 'machine.health.tooltip.loadShort': 'Нагрузка (1м)', + 'machine.health.tooltip.uptimeShort': 'Аптайм', + 'machine.health.uptimeCompact': 'работает {value}', + 'machine.health.tooltip.hint': 'Обновляется примерно каждые 20 с раннером на этой машине', + 'machine.health.aria.cpu': 'ЦП {n} процентов', + 'machine.health.aria.ram': 'ОЗУ {n} процентов', + 'machine.health.aria.unknown': 'Состояние машины недоступно', + + // Чат + 'chat.placeholder': 'Введите сообщение…', + 'chat.send': 'Отправить', + 'chat.abort': 'Прервать', + 'chat.settings': 'Настройки', + 'chat.terminal': 'Терминал', + 'chat.switchRemote': 'Переключиться в удалённый режим', + 'chat.sendError.fallback': 'Не удалось отправить сообщение. Отредактируйте и попробуйте ещё раз.', + 'chat.sendError.sessionInactive': 'Эта сессия заархивирована. Откройте её заново, чтобы отправить сообщение.', + 'chat.sendError.sessionInactive.action': 'Открыть заново', + + // Обзор Codex + 'codexReview.title': 'Обзор Codex', + 'codexReview.findings': 'Находок: {count}', + 'codexReview.confidence': 'Уверенность {value}', + 'codexReview.location.missing': 'Нет местоположения', + + // Терминал + 'terminal.commandName': 'Команда', + 'terminal.commandMessage': 'Сообщение команды', + 'terminal.commandArgs': 'Аргументы команды', + 'terminal.stdout': 'Stdout', + 'terminal.stderr': 'Stderr', + 'terminal.unsupportedWindows': 'Удалённый терминал недоступен на этом хосте.', + 'terminal.retry': 'Повторить', + 'terminal.inputMode.label': 'Режим ввода терминала', + 'terminal.inputMode.command': 'Команда', + 'terminal.inputMode.direct': 'Прямой', + 'terminal.command.placeholder': 'Введите команду…', + 'terminal.command.inputLabel': 'Ввод команды', + 'terminal.command.run': 'Выполнить команду', + 'terminal.command.shortcuts': 'Основные команды', + 'terminal.direct.pages': 'Страницы прямых клавиш', + 'terminal.direct.page.control': 'Управление', + 'terminal.direct.page.navigation': 'Навигация', + 'terminal.direct.page.shell': 'Оболочка', + 'terminal.direct.page.symbols': 'Символы', + 'terminal.paste.fallbackTitle': 'Вставить ввод', + 'terminal.paste.fallbackDescription': 'Чтение из буфера обмена недоступно. Вставьте текст ниже.', + 'terminal.paste.placeholder': 'Вставьте ввод терминала сюда…', + + // Блок кода + 'code.copy': 'Копировать', + 'code.truncated': 'Предпросмотр обрезан — откройте детали для полного вывода', + 'code.wrap.enable': 'Включить перенос строк', + 'code.wrap.disable': 'Отключить перенос строк', + + // Просмотр изменений + 'diff.title': 'Изменения', + 'diff.view': 'Просмотр', + + // Страница файлов + 'files.page.title': 'Файлы', + 'files.page.refresh': 'Обновить', + 'files.page.refreshFilesystem': 'Обновить вид файловой системы', + 'files.page.searchPlaceholder': 'Поиск файлов', + 'files.projectRoot': 'корень проекта', + 'files.branch.detached': 'отделённый HEAD', + 'files.branch.summary': '{staged} индексировано, {unstaged} не индексировано', + 'files.tab.changes': 'Изменения', + 'files.tab.directories': 'Каталоги', + 'files.search.empty': 'Нет файлов, соответствующих поиску.', + 'files.search.error.failed': 'Не удалось выполнить поиск файлов.', + 'files.search.error.failedWithDetail': 'Не удалось выполнить поиск файлов: {error}', + 'files.changes.section.staged': 'Индексированные изменения ({n})', + 'files.changes.section.unstaged': 'Неиндексированные изменения ({n})', + 'files.changes.empty.unavailable': 'Статус Git недоступен. Используйте «Каталоги» для просмотра всех файлов или поиск.', + 'files.changes.empty.none': 'Изменений не обнаружено. Используйте «Каталоги» для просмотра всех файлов или поиск.', + 'files.changes.error.gitStatusUnavailable': 'Статус Git недоступен.', + 'files.changes.error.gitStatusUnavailableWithDetail': 'Статус Git недоступен: {error}', + 'files.changes.error.unstagedDiffUnavailableWithDetail': 'Неиндексированные изменения недоступны: {error}', + 'files.changes.error.stagedDiffUnavailableWithDetail': 'Индексированные изменения недоступны: {error}', + 'files.directories.empty': 'Пустой каталог.', + 'files.directories.error.listFailed': 'Не удалось получить список каталога.', + 'files.directories.error.listFailedWithDetail': 'Не удалось получить список каталога: {error}', + 'files.directories.download': 'Скачать файл', + 'files.directories.downloadNamed': 'Скачать {name}', + 'files.directories.download.error.title': 'Скачивание не удалось', + 'files.directories.download.error.default': 'Не удалось скачать файл.', + 'files.sort.title': 'Сортировка файлов', + 'files.sort.by': 'Сортировать по', + 'files.sort.name': 'Имя', + 'files.sort.modified': 'Время изменения', + 'files.sort.size': 'Размер файла', + 'files.sort.nameAsc': 'От А до Я', + 'files.sort.nameDesc': 'От Я до А', + 'files.sort.oldest': 'Сначала старые', + 'files.sort.newest': 'Сначала новые', + 'files.sort.smallest': 'Сначала маленькие', + 'files.sort.largest': 'Сначала большие', + + // Контекстное меню файла + 'file.menu.title': 'Действия с файлом', + 'file.menu.copyPath': 'Копировать путь', + 'file.menu.copyAbsolutePath': 'Копировать абсолютный путь', + 'file.menu.addToComposer': 'Добавить в редактор', + + // Страница файла + 'file.page.fallbackName': 'Файл', + 'file.page.unknownPath': 'Неизвестный путь', + 'file.page.copyPath': 'Копировать путь', + 'file.page.copyContent': 'Копировать содержимое файла', + 'file.page.download': 'Скачать файл', + 'file.page.tab.diff': 'Изменения', + 'file.page.tab.file': 'Файл', + 'file.page.tab.source': 'Исходник', + 'file.page.tab.preview': 'Предпросмотр', + 'file.page.missingPath': 'Путь к файлу не указан.', + 'file.page.binary': 'Похоже, это бинарный файл. Его нельзя отобразить.', + 'file.page.imagePreviewAlt': 'Предпросмотр изображения {name}', + 'file.page.empty': 'Файл пуст.', + 'file.page.noChanges': 'Нет изменений для отображения.', + 'file.error.readFailed': 'Не удалось прочитать файл.', + 'file.error.readFailedWithDetail': 'Не удалось прочитать файл: {error}', + 'file.error.diffUnavailable': 'Изменения недоступны.', + 'file.error.diffUnavailableWithDetail': 'Изменения недоступны: {error}', + + // Встроенные медиа + 'media.displayed.image': 'Показано изображение', + 'media.displayed.video': 'Показано видео', + 'media.displayed.audio': 'Показано аудио', + 'media.displayed.file': 'Показан файл', + 'media.displayed.header': '{label}: {fileName}', + 'media.displayed.unavailable': '{label} недоступно. {error}', + + // Карточка инструмента + 'tool.askQuestion': 'Другое', + 'tool.edit': 'Правка', + 'tool.multiedit': 'Мультиправка', + 'tool.write': 'Запись', + 'tool.todo': 'Задачи', + 'tool.confirm': 'Подтвердить', + 'tool.deny': 'Отклонить', + 'tool.abort': 'Прервать', + 'tool.allow': 'Разрешить', + 'tool.allowAll': 'Разрешить все правки', + 'tool.denyAll': 'Запретить все правки', + 'tool.approved': 'Одобрено', + 'tool.rejected': 'Отклонено', + 'tool.cancel': 'Отмена', + 'tool.exitPlan': 'Выйти из режима плана', + 'tool.plan.implement': 'Реализовать план', + 'tool.plan.continue': 'Продолжить планирование', + 'tool.patch': 'Патч', + 'tool.input': 'Ввод', + 'tool.label': 'Инструмент', + 'tool.more': '+ещё {count}', + 'tool.trace': 'Трассировка', + 'tool.trace.callsSuffix': 'вызовов', + 'tool.result': 'Результат', + 'tool.duration': 'Длительность', + 'tool.startedAt': 'Начато', + 'tool.completedAt': 'Завершено', + 'tool.semanticTitle.readFile': 'Чтение файла', + 'tool.semanticTitle.runShell': 'Выполнение команды', + 'tool.semanticTitle.search': 'Поиск', + 'tool.semanticTitle.openUrl': 'Открыть URL', + 'tool.semanticTitle.query': 'Запрос', + 'tool.questionsAnswers': 'Вопросы и ответы', + 'tool.submit': 'Отправить', + 'tool.submitting': 'Отправка…', + 'tool.prev': '← Назад', + 'tool.next': 'Далее →', + 'tool.yes': 'Да', + 'tool.yesForSession': 'Да для сессии', + 'tool.allowForSession': 'Разрешить для сессии', + 'tool.abortLabel': 'Прервать', + 'tool.other': 'Другое', + 'tool.otherDescription': 'Введите свой ответ', + 'tool.question': 'Вопрос', + 'tool.selectOption': 'Выберите хотя бы один вариант или введите ответ.', + 'tool.waitingForApproval': 'Ожидание одобрения…', + 'tool.resolved': 'Разрешено в Codex', + 'tool.canceled': 'Отменено', + 'tool.approvedForSession': 'Одобрено для сессии', + 'tool.aborted': 'Прервано', + 'tool.approvedAllowAllEdits': 'Одобрено: разрешить все правки', + 'tool.requestFailed': 'Запрос не удался', + 'tool.askUserQuestion.fallback': 'Данные AskUserQuestion не в ожидаемом формате. Введите ответ:', + 'tool.askUserQuestion.placeholder': 'Введите ответ…', + 'tool.askUserQuestion.otherPlaceholder': 'Или введите свой ответ…', + 'tool.requestUserInput.textPlaceholder': 'Введите ответ…', + 'tool.requestUserInput.noteLabel': 'Дополнительное примечание (необязательно)', + 'tool.requestUserInput.notePlaceholder': 'Добавить примечание…', + 'tool.requestUserInput.otherLabel': 'Ничего из вышеперечисленного', + 'tool.requestUserInput.otherDescription': 'При желании добавьте детали в примечаниях.', + 'tool.requestUserInput.popupBlocked': 'Не удалось открыть страницу входа. Разрешите всплывающие окна и попробуйте ещё раз.', + 'toolGroup.title': 'Активность инструментов', + 'toolGroup.friendly.inspectTarget': 'Проверить {target}', + 'toolGroup.friendly.searchTarget': 'Найти «{target}»', + 'toolGroup.friendly.runTarget': 'Выполнить {target}', + 'toolGroup.friendly.editTarget': 'Изменить {target}', + 'toolGroup.primary.fileTargets': '{target} +{n}', + 'toolGroup.primary.commandTargets': '{target} +{n}', + 'toolGroup.primary.searchTargets': '{target} +{n}', + 'toolGroup.primary.urlTargets': '{target} +{n}', + 'toolGroup.primary.otherTargets': '{target} +{n}', + 'toolGroup.summary.read': 'Чтение {n}', + 'toolGroup.summary.mutation': 'Правка {n}', + 'toolGroup.summary.command': 'Выполнение {n}', + 'toolGroup.summary.search': 'Поиск {n}', + 'toolGroup.summary.web': 'Веб {n}', + 'toolGroup.summary.other': 'Инструмент {n}', + 'toolGroup.friendly.inspectFiles': 'Проверить файлы проекта', + 'toolGroup.friendly.searchContent': 'Найти в содержимом проекта', + 'toolGroup.friendly.runCommands': 'Выполнить команды проекта', + 'toolGroup.friendly.editFiles': 'Изменить файлы проекта', + 'toolGroup.friendly.openWeb': 'Просмотреть веб-содержимое', + 'toolGroup.friendly.genericCommand': 'Выполнить команду', + 'toolGroup.friendly.genericTool': 'Использовать инструмент', + 'toolGroup.badge.running': '{n} выполняется', + 'toolGroup.badge.pending': '{n} в ожидании', + 'toolGroup.badge.error': '{n} ошибка', + 'toolGroup.badge.fileTargets': '{n} файлов', + 'toolGroup.toolCount': '{n} действий', + 'toolGroup.loadingOlderHistory': 'Загрузка более ранней активности инструментов…', + 'toolGroup.historyUnavailable': 'Более ранняя активность инструментов недоступна.', + 'toolGroup.rowStatus.running': 'Выполняется', + 'toolGroup.rowStatus.pending': 'Ожидание', + 'toolGroup.rowStatus.error': 'Ошибка', + 'toolGroup.codex.exploring': 'Изучение', + 'toolGroup.codex.explored': 'Изучено', + 'toolGroup.codex.read': 'Чтение', + 'toolGroup.codex.list': 'Список', + 'toolGroup.codex.search': 'Поиск', + 'toolGroup.codex.searchIn': '{query} в {path}', + + // Кнопки редактора + 'composer.settings': 'Настройки', + 'composer.terminal': 'Терминал', + 'composer.abort': 'Прервать', + 'composer.switchRemote': 'Переключиться в удалённый режим', + 'composer.attach': 'Прикрепить файл', + 'composer.expand': 'Развернуть редактор сообщений', + 'composer.collapse': 'Свернуть редактор сообщений', + 'composer.dropToAttach': 'Перетащите, чтобы прикрепить', + 'composer.send': 'Отправить', + 'composer.stop': 'Остановить', + 'composer.voice': 'Голосовой ассистент', + 'composer.dictate': 'Диктовка (Ctrl/Cmd+Shift+D)', + 'composer.scheduleSend': 'Отложенная отправка', + 'composer.scheduleRelativeTab': 'Относительно', + 'composer.scheduleSpecificTab': 'Точное время', + 'composer.scheduleSpecificHint': 'Максимум 7 дней. Требуется работающий хаб; CLI наверстает отправку при следующем подключении после этого времени.', + 'composer.scheduleErrorPast': 'Время отправки должно быть в будущем.', + 'composer.scheduleErrorTooFar': 'Максимальное время отправки — 7 дней.', + 'queuedMessages.scheduledFor': 'Запланировано на {time}', + 'queuedMessages.editAlreadyInvoked': 'Сообщение уже отправлено — его нельзя отредактировать', + 'queuedMessages.editBusyNotRestored': 'Доставка всё ещё не завершена — черновик не восстановлен (может ещё быть в пути)', + 'queuedMessages.editCurrentDraftKept': 'Отложенное сообщение отменено — текущий черновик и расписание сохранены.', + 'queuedMessages.steer': 'Доставить в текущий ход сейчас', + 'queuedMessages.steerFailed': 'Не удалось направить — сообщение остаётся в очереди', + 'queuedMessages.steerOutcomeUnknown': 'Исход доставки неизвестен — повторите или явно отмените', + 'queuedMessages.retryOutcome': 'Повторить неопределённую доставку', + 'queuedMessages.retryFailed': 'Повтор не удался', + 'queuedMessages.retryMissing': 'Сообщение больше недоступно', + 'queuedMessages.retryUnavailable': 'Агент всё ещё обрабатывает эту доставку', + 'queuedMessages.steeredBadge': '↳ Направлено', + 'queuedMessages.steeredBadgeTitle': 'Направлено в активный ход', + + // Блокнот (рабочее пространство сессии) + 'scratchlist.title': 'Блокнот', + 'scratchlist.heldLabel': 'отложено — не отправлено', + 'scratchlist.empty': 'пусто', + 'scratchlist.count.one': '1 элемент', + 'scratchlist.count.other': 'Элементов: {n}', + 'scratchlist.attachmentOnly': '(вложение)', + 'scratchlist.emptyHint': 'Откладывайте сюда заметки, черновики и идеи. Ничего не отправляется, пока вы не продвинете запись.', + 'scratchlist.drawerHint': 'Печатайте ниже — кнопка «Отправить» добавляет следующее сообщение в блокнот, а не в чат. Нажмите иконку заметки ещё раз, чтобы выйти.', + 'scratchlist.toggleAriaLabel': 'Панель блокнота', + 'scratchlist.toggleTooltip': 'Блокнот — откладывать заметки и черновики (Ctrl/Cmd+Shift+S)', + 'scratchlist.sendToScratchlist': 'Отправить в блокнот', + 'scratchlist.fueTitle': 'Новое: блокнот', + 'scratchlist.fueBody': 'Откладывайте сюда заметки и черновики без отправки. Пока вы копите записи, кнопка «Отправить» светится янтарным; нажмите иконку (или Ctrl/Cmd+Shift+S) ещё раз, чтобы выйти.', + 'richComposer.fueTitle': 'Новое: @упоминание другой сессии', + 'richComposer.fueBody': 'Введите @, чтобы упомянуть другую сессию. Она станет чипом (наведите для деталей в стиле боковой панели). При отправке сообщение включает полный ID этой сессии, чтобы агент мог вызвать inspect_peer или ping_peer.', + 'scratchlist.addPlaceholder': 'Заметка, черновик или идея — Enter для добавления', + 'scratchlist.addAriaLabel': 'Добавить запись в блокнот', + 'scratchlist.add': 'Добавить', + 'scratchlist.atCap': 'Блокнот достиг лимита в {n} записей. Удалите старую запись, чтобы добавить новую.', + 'scratchlist.confirmDelete': 'Удалить эту запись блокнота? Это действие необратимо.', + 'scratchlist.listAriaLabel': 'Записи блокнота', + 'scratchlist.action.moveUp': 'Переместить запись вверх', + 'scratchlist.action.moveDown': 'Переместить запись вниз', + 'scratchlist.action.promoteToComposer': 'Скопировать в редактор', + 'scratchlist.action.promoteToQueue': 'Отправить в очередь', + 'scratchlist.action.copy': 'Скопировать текст в буфер обмена (не изображения)', + 'scratchlist.action.copied': 'Скопировано!', + 'scratchlist.action.delete': 'Удалить запись', + 'scratchlist.entry.lastSaved': 'Сохранено {time}', + 'scratchlist.entry.lastSavedAriaLabel': 'Запись сохранена {time}', + 'scratchlist.migrationBanner.title': 'Блокнот теперь синхронизируется между устройствами', + 'scratchlist.migrationBanner.body': 'Ваши существующие заметки скопированы в хаб — ничего не потеряно. Теперь изменения в этой сессии будут появляться на каждом используемом вами устройстве.', + 'scratchlist.migrationBanner.dismiss': 'Понятно', + 'fue.newFeatureDot': 'Доступна новая функция', + 'fue.gotIt': 'Понятно', + 'fue.closeAriaLabel': 'Закрыть пояснение', + 'composer.codexSlashUnsupported.title': 'Команда Codex недоступна', + 'composer.codexSlashUnsupported.body': 'Удалённый режим HAPI пока не выполняет встроенные слэш-команды Codex, такие как {command}. Используйте обычный язык или выполните её в локальном TUI Codex.', + + // Голосовой ассистент + 'voice.connecting': 'Подключение...', + 'voice.active': 'Активен', + 'voice.muted': 'Выключен звук', + 'voice.error': 'Ошибка', + 'voice.mute': 'Выключить звук', + 'voice.unmute': 'Включить звук', + 'voice.end': 'Завершить', + 'voice.error.micPermission': 'Доступ к микрофону запрещён', + 'voice.error.network': 'Ошибка сети', + 'voice.error.notInitialized': 'Голосовая сессия не инициализирована', + 'voice.error.startFailed': 'Не удалось запустить голосовую сессию', + 'voice.error.notAllowed': 'Голос недоступен', + 'voice.error.connection': 'Ошибка подключения', + 'voice.dismiss': 'Закрыть', + + // Баннеры + 'offline.title': 'Офлайн', + 'offline.message': 'Вы сейчас офлайн. Некоторые функции могут быть ограничены.', + 'syncing.title': 'Синхронизация…', + 'syncing.message': 'Ваши данные синхронизируются.', + 'reconnecting.message': 'Переподключение...', + 'reconnecting.reason.error': 'ошибка потока', + 'reconnecting.reason.closed': 'поток закрыт', + 'reconnecting.reason.heartbeatTimeout': 'таймаут heartbeat', + 'reconnecting.reason.visibilityRecovery': 'возобновление после фона', + 'reconnecting.reason.connectTimeout': 'таймаут подключения', + 'reconnecting.reason.transportError': 'ошибка потока', + 'pwa.update.title': 'Доступна новая версия', + 'pwa.update.body': 'Перезагрузите, чтобы получить последнюю версию HAPI', + 'pwa.update.reload': 'Перезагрузить', + 'pwa.update.whyToggle': 'Почему нельзя закрыть?', + 'pwa.update.whyBody': 'HAPI не будет перезагружать вкладку автоматически, пока у вас может работать агент, ожидаться разрешение или отправляться сообщение. Запуск старой веб-сборки с текущим сервером может вызвать ошибки синхронизации и неудачные действия. Этот баннер остаётся видимым до перезагрузки, чтобы вы случайно не застряли на устаревшей версии — но вы сами решаете, когда нажать «Перезагрузить», закончив текущие дела.', + + // Отправка заблокирована + 'send.blocked.title': 'Не удаётся отправить сообщение', + 'send.blocked.noConnection': 'Нет соединения с сервером', + 'resume.failed.title': 'Не удалось возобновить', + 'toast.ready.title': 'Готово к вводу', + 'toast.ready.body': '{agent} ожидает в {session}', + 'toast.permission.title': 'Запрос разрешения', + 'toast.input.title': '{agent} нужен ваш ввод', + 'toast.task.completed': 'Задача завершена', + 'toast.task.failed': 'Задача не удалась', + + // Предложение установки + 'install.title': 'Установить HAPI', + 'install.description': 'Добавьте HAPI на главный экран для быстрого доступа.', + 'install.button': 'Установить', + + // Переключатель языка + 'language.title': 'Язык', + 'language.english': 'English', + 'language.chinese': '简体中文', + + // Страница настроек + 'common.back': 'Назад', + 'settings.title': 'Настройки', + 'settings.hub.description': 'Выберите категорию, чтобы настроить HAPI под свой рабочий процесс.', + 'settings.hub.voice.summary': 'Голос, язык и поведение', + 'settings.general.title': 'Общие', + 'settings.storage.title': 'Хранилище', + 'settings.storage.summary': 'Использование базы данных хаба', + 'settings.storage.description': 'Текущий размер базы данных SQLite хаба на диске.', + 'settings.storage.loading': 'Загрузка использования хранилища…', + 'settings.storage.error': 'Не удалось загрузить использование хранилища', + 'settings.storage.total': 'Всего', + 'settings.storage.database': 'База данных', + 'settings.storage.wal': 'Журнал предзаписи', + 'settings.storage.shm': 'Разделяемая память', + 'settings.storage.path': 'Путь', + 'settings.storage.refresh': 'Обновить', + 'settings.storage.refreshing': 'Обновление…', + 'settings.storage.chartTitle': 'Относительная доля', + 'settings.storage.chartEmpty': 'Пока нет данных о размере на диске.', + 'settings.usage.title': 'Расход токенов', + 'settings.usage.summary': 'Панель расхода только по токенам', + 'settings.usage.description': 'Токены, обработанные при управлении агентами через HAPI. Ранее импортированные расшифровки не учитываются; стоимость не входит.', + 'settings.usage.range.label': 'Период', + 'settings.usage.range.7d': '7 дней', + 'settings.usage.range.30d': '30 дней', + 'settings.usage.range.all': 'Всё время', + 'settings.usage.loading': 'Загрузка расхода токенов…', + 'settings.usage.error': 'Не удалось загрузить расход токенов', + 'settings.usage.empty': 'За этот период расход токенов не зафиксирован.', + 'settings.usage.total': 'Обработано токенов (с учётом кэша)', + 'settings.usage.uncached': 'Токены без кэша', + 'settings.usage.input': 'Входные токены (включая кэш)', + 'settings.usage.output': 'Выходные токены', + 'settings.usage.cacheRead': 'Чтение кэша (включено во входные)', + 'settings.usage.cacheCreation': 'Создание кэша (включено)', + 'settings.usage.cacheHitRate': 'Доля попаданий в кэш', + 'settings.usage.requests': 'Запросы', + 'settings.usage.daily.title': 'Динамика по дням', + 'settings.usage.agent.title': 'По агентам', + 'settings.usage.model.title': 'По моделям', + 'settings.usage.bucketDetails': '{requests} запросов · {input} вход · {output} выход', + 'settings.usage.sessions': 'Сессий с расходом: {count}', + 'settings.general.description': 'Язык, сопряжение с приложением-компаньоном и общие предпочтения приложения.', + 'settings.general.sessionSummary.title': 'Сводки статуса сессий', + 'settings.general.sessionSummary.description': 'Выберите, будут ли поддерживаемые агенты формировать сводки статуса и отображаться ли они в чате.', + 'settings.general.sessionSummaryContract': 'Формировать сводки статуса', + 'settings.general.sessionSummaryContract.desc': 'По умолчанию выключено. При включении поддерживаемых агентов просят добавлять после каждого хода завершающую строку AGENT_NOTIFY_SUMMARY для уведомлений и записей о фоновой работе. Применяется к новым/возобновлённым сессиям. (Поддерживаются: Claude, Codex, OpenCode, удалённый Grok; пока не поддерживаются: локальный Grok, Cursor)', + 'settings.general.sessionSummaryInChat': 'Показывать сводки статуса в чате', + 'settings.general.sessionSummaryInChat.desc': 'Влияет только на отображение в чате и скопированном содержимом; не влияет на формирование сводок, уведомления или записи о фоновой работе. При включении показывается строка статуса, при выключении она скрыта. Сохранённые сообщения не изменяются.', + 'settings.language.title': 'Язык', + 'settings.language.label': 'Язык', + 'settings.display.title': 'Отображение', + 'settings.display.description': 'Внешний вид, типографика и настройки списка сессий.', + 'settings.display.typography': 'Типографика', + 'settings.display.sessions': 'Список сессий', + 'settings.display.appearance': 'Оформление', + 'settings.display.appearanceMode': 'Режим оформления', + 'settings.display.appearance.system': 'Как в системе', + 'settings.display.appearance.dark': 'Тёмная', + 'settings.display.appearance.oled': 'OLED-чёрный', + 'settings.display.appearance.light': 'Светлая', + 'settings.display.colorTheme': 'Цветовая тема', + 'settings.display.colorTheme.default': 'По умолчанию', + 'settings.display.colorTheme.notion': 'Notion', + 'settings.display.colorTheme.one': 'One', + 'settings.display.colorTheme.proof': 'Proof', + 'settings.display.colorTheme.raycast': 'Raycast', + 'settings.display.colorTheme.rosePine': 'Rose Pine', + 'settings.display.colorTheme.solarized': 'Solarized', + 'settings.display.colorTheme.vercel': 'Vercel', + 'settings.display.colorTheme.vsCodePlus': 'VS Code Plus', + 'settings.display.colorTheme.xcode': 'Xcode', + 'settings.display.colorTheme.linear': 'Linear', + 'settings.display.colorTheme.lobster': 'Lobster', + 'settings.display.colorTheme.material': 'Material', + 'settings.display.colorTheme.matrix': 'Matrix', + 'settings.display.colorTheme.monokai': 'Monokai', + 'settings.display.colorTheme.nightOwl': 'Night Owl', + 'settings.display.colorTheme.nord': 'Nord', + 'settings.display.themeColors.title': 'Свои цвета', + 'settings.display.themeColors.description': 'Применяется к текущему оформлению. Переключите оформление, чтобы настроить каждое отдельно.', + 'settings.display.themeColors.reset': 'Сбросить', + 'settings.display.themeColors.resetAll': 'Сбросить все', + 'settings.display.themeColors.expand': 'Настроить', + 'settings.display.themeColors.key.background': 'Фон', + 'settings.display.themeColors.key.surface': 'Карточки и поверхности', + 'settings.display.themeColors.key.text': 'Текст', + 'settings.display.themeColors.key.hint': 'Приглушённый текст', + 'settings.display.themeColors.key.accent': 'Акцент и ссылки', + 'settings.display.themeColors.key.border': 'Границы', + 'settings.display.themeColors.key.userBubble': 'Пузырь вашего сообщения', + 'settings.display.fontSize': 'Размер шрифта', + 'settings.display.terminalFontSize': 'Размер шрифта терминала', + 'settings.display.sessionPreviewLimit': 'Сессий до сворачивания', + 'settings.display.sessionPreviewLimit.decrease': 'Показывать меньше сессий до сворачивания', + 'settings.display.sessionPreviewLimit.increase': 'Показывать больше сессий до сворачивания', + 'settings.display.activeSessionsOnly': 'Только активные сессии', + 'settings.display.activeSessionsOnly.desc': 'Скрыть неактивные сессии в боковой панели. Открытая сессия остаётся видимой.', + 'settings.display.pinInProgressSessions': 'Закреплять сессии в работе', + 'settings.display.pinInProgressSessions.desc': 'Перемещать незакреплённые подключённые сессии в разделы над папками проекта: сначала выполняющиеся и ожидающие, затем спокойные активные сессии (выполнение завершено, подключение есть). Глобальные закрепления остаются выше них. Выключено — всё остаётся в группах по каталогам.', + 'settings.display.appBadge': 'Значок непрочитанного на панели задач', + 'settings.display.appBadge.desc': 'Показывать число сессий с новой активностью в установленной PWA Edge или Chrome. Внешний вид определяет браузер хоста; в обычной вкладке браузера это не действует.', + 'settings.display.sessionListStatus': 'Подсказки статуса в списке сессий', + 'settings.display.sessionListStatus.standard': 'Базовые', + 'settings.display.sessionListStatus.detailed': 'Расширенные', + 'settings.display.sessionListStatus.detailedDescription': 'Выберите, какие подсказки статуса показывать в списке сессий. Базовые показывают состояние выполнения; расширенные также показывают подсказки о разрешениях, вводе, фоновых задачах, новой активности и отложенных сообщениях (иконка часов).', + 'settings.display.sessionHeader': 'Шапка сессии', + 'settings.display.sessionHeader.description': 'Выберите, какие детали показывать под названием сессии. На мобильных показывается агент плюс самая приоритетная доступная деталь.', + 'settings.display.sessionHeader.showLabels': 'Показывать подписи полей', + 'settings.display.sessionHeader.agent': 'Агент', + 'settings.display.sessionHeader.model': 'Модель', + 'settings.display.sessionHeader.reasoning': 'Уровень рассуждений', + 'settings.display.sessionHeader.fastMode': 'Быстрый режим', + 'settings.display.sessionHeader.machine': 'Машина', + 'settings.display.sessionHeader.lastActive': 'Время активности', + 'settings.display.sessionHeader.createdAt': 'Время создания', + 'settings.display.sessionHeader.updatedAt': 'Время обновления', + 'settings.display.sessionHeader.worktree': 'Ветка рабочего дерева', + 'settings.display.links': 'Ссылки', + 'settings.display.openExternalLinksInNewTab': 'Открывать внешние ссылки в новой вкладке', + 'settings.display.openExternalLinksInNewTab.desc': 'Выключено (по умолчанию) сохраняет текущее поведение. Включено — открывает внешние ссылки в отдельной вкладке браузера.', + 'session.header.createdAt': 'Создана', + 'session.header.updatedAt': 'Обновлена', + 'settings.chat.title': 'Чат', + 'settings.chat.description': 'Ввод сообщений, карточки инструментов и цвета диалога.', + 'settings.chat.input': 'Ввод', + 'settings.chat.tools': 'Карточки инструментов', + 'settings.chat.colors': 'Цвета диалога', + 'settings.chat.enterBehavior': 'Клавиша Enter', + 'settings.chat.enterBehavior.send': 'Отправить сообщение', + 'settings.chat.enterBehavior.newline': 'Вставить перенос строки', + 'settings.chat.terminalToolDisplay': 'Карточки инструментов терминала', + 'settings.chat.terminalToolDisplay.compact': 'Компактные (только команда)', + 'settings.chat.terminalToolDisplay.detailed': 'Подробные (показывать предпросмотр вывода)', + 'settings.chat.codexExplorationCollapsed': 'Сворачивать изученные группы инструментов по умолчанию', + 'settings.chat.codexExplorationCollapsed.desc': 'Держать активность чтения и поиска Codex свёрнутой, пока вы её не откроете.', + 'settings.chat.reasoningCollapsed': 'Сворачивать рассуждения по умолчанию', + 'settings.chat.reasoningCollapsed.desc': 'Держать сообщения с рассуждениями ИИ свёрнутыми, в том числе во время потокового вывода.', + 'settings.chat.groupedToolBackground': 'Фон сгруппированного использования инструментов', + 'settings.chat.userMessageBackground': 'Фон сообщения пользователя', + 'settings.chat.surfaceColor.default': 'Цвет по умолчанию', + 'settings.chat.surfaceColor.softBlue': 'Мягкий синий', + 'settings.chat.surfaceColor.softGreen': 'Мягкий зелёный', + 'settings.chat.surfaceColor.softYellow': 'Мягкий жёлтый', + 'settings.chat.surfaceColor.custom': 'Свой цвет', + 'settings.chat.composerToolbar.title': 'Настройка панели инструментов редактора', + 'settings.chat.composerToolbar.description': 'Выберите, как расположить действия панели под полем ввода сообщения.', + 'settings.chat.composerToolbar.layout': 'Выравнивание', + 'settings.chat.composerToolbar.order': 'Порядок инструментов', + 'settings.chat.composerToolbar.hidden': 'Скрытые инструменты', + 'settings.chat.composerToolbar.emptyHidden': 'Перетащите сюда инструменты, чтобы скрыть их', + 'settings.chat.composerToolbar.hide': 'Скрыть', + 'settings.chat.composerToolbar.show': 'Показать', + 'settings.chat.composerToolbar.layout.left': 'Слева', + 'settings.chat.composerToolbar.layout.center': 'По центру', + 'settings.chat.composerToolbar.layout.right': 'Справа', + 'settings.chat.composerToolbar.layout.split': 'Раздельно', + 'settings.chat.composerToolbar.dragHint': 'Перетаскивайте действия, чтобы изменить порядок. В раздельной раскладке перетаскивайте между двумя группами.', + 'settings.chat.composerToolbar.previewHint': 'Перетаскивайте реальные иконки панели ниже, чтобы изменить порядок, или перемещайте их в «Скрытые инструменты». Изменения применяются сразу; наведите на иконку, чтобы увидеть её действие.', + 'settings.chat.composerToolbar.reset': 'Сбросить', + 'settings.chat.composerToolbar.leftGroup': 'Левая группа', + 'settings.chat.composerToolbar.rightGroup': 'Правая группа', + 'settings.chat.composerToolbar.emptyGroup': 'Перетащите действия сюда', + 'settings.chat.composerToolbar.moveLeft': 'Переместить в левую группу', + 'settings.chat.composerToolbar.moveRight': 'Переместить в правую группу', + 'settings.chat.composerToolbar.moveEarlier': 'Переместить раньше', + 'settings.chat.composerToolbar.moveLater': 'Переместить позже', + 'settings.chat.composerToolbar.item.attachment': 'Добавить вложение', + 'settings.chat.composerToolbar.item.settings': 'Настройки сессии', + 'settings.chat.composerToolbar.item.expand': 'Развернуть редактор сообщений', + 'settings.chat.composerToolbar.item.model': 'Модель', + 'settings.chat.composerToolbar.item.effort': 'Усилия', + 'settings.chat.composerToolbar.item.terminal': 'Терминал', + 'settings.chat.composerToolbar.item.abort': 'Прервать', + 'settings.chat.composerToolbar.item.switch': 'Переключиться на удалённый', + 'settings.chat.composerToolbar.item.voiceMic': 'Микрофон голоса', + 'settings.chat.composerToolbar.item.scratchlist': 'Блокнот', + 'settings.chat.composerToolbar.item.schedule': 'Отложенная отправка', + 'settings.voice.title': 'Голос', + 'settings.voice.description': 'Выберите диктовку или полный голосовой ассистент.', + 'settings.voice.inputMode.title': 'Режим голоса', + 'settings.voice.inputMode.hint': 'Диктовка только вставляет текст в редактор; она никогда не отправляет автоматически.', + 'settings.voice.inputMode.assistant': 'Голосовой ассистент', + 'settings.voice.inputMode.assistant.hint': 'Двусторонний голосовой разговор', + 'settings.voice.inputMode.dictation': 'Диктовка', + 'settings.voice.inputMode.dictation.hint': 'Только ввод речи в текст', + 'settings.voice.transcriptionProvider': 'Провайдер транскрипции', + 'settings.voice.noTranscriptionProvider': 'На хабе пока нет провайдера транскрипции — добавьте OpenAI, ElevenLabs или Groq ниже (или используйте встроенный в браузер, если доступен).', + 'settings.voice.noVoiceBackend': 'На хабе не настроен бэкенд голосового ассистента — добавьте его ниже.', + 'settings.voice.credentials.hint': 'Выберите OpenAI, ElevenLabs или Groq. Ключи хранятся на хабе; браузер никогда не хранит долгоживущие секреты провайдера.', + 'settings.voice.credentials.assistantHint': 'Добавьте ключ ElevenLabs, Gemini Live или Qwen Realtime. Ключи хранятся на хабе.', + 'settings.voice.credentials.provider': 'Провайдер', + 'settings.voice.credentials.openaiCompatible': 'OpenAI-совместимый / локальный', + 'settings.voice.credentials.geminiLive': 'Gemini Live', + 'settings.voice.credentials.qwenRealtime': 'Qwen Realtime', + 'settings.voice.credentials.apiKey': 'API-ключ', + 'settings.voice.credentials.apiKeyPlaceholder': 'Вставьте API-ключ', + 'settings.voice.credentials.apiKeyReplace': 'Вставьте новый ключ для замены', + 'settings.voice.credentials.baseUrl': 'Базовый URL', + 'settings.voice.credentials.model': 'Модель', + 'settings.voice.credentials.save': 'Сохранить на хабе', + 'settings.voice.credentials.clear': 'Очистить', + 'settings.voice.credentials.saved': 'Сохранено. Список провайдеров обновлён.', + 'settings.voice.credentials.cleared': 'Удалено из настроек хаба.', + 'settings.voice.credentials.saveFailed': 'Не удалось сохранить учётные данные.', + 'settings.voice.credentials.configuredAs': 'Настроено через {source}: {hint}', + 'settings.voice.credentials.source.env': 'окружение', + 'settings.voice.credentials.source.settings': 'Настройки', + 'settings.voice.credentials.envLocked': 'Этот ключ задан окружением хаба и не может быть изменён здесь.', + 'settings.voice.credentials.manage': 'Добавить или управлять провайдерами', + 'settings.voice.credentials.manageHint': 'Добавьте OpenAI, ElevenLabs или Groq, чтобы включить диктовку на хабе.', + 'settings.voice.credentials.manageAssistantHint': 'Вставьте API-ключ, чтобы включить ElevenLabs, Gemini Live или Qwen Realtime на хабе.', + 'settings.voice.transcriptionMode': 'Режим транскрипции', + 'settings.voice.transcriptionMode.standard': 'Стандартный', + 'settings.voice.transcriptionMode.standard.hint': 'Записать, затем транскрибировать (по умолчанию)', + 'settings.voice.transcriptionMode.realtime': 'Реального времени', + 'settings.voice.transcriptionMode.realtime.hint': 'Показывать живой текст во время речи; вставить итог после остановки', + 'settings.voice.voices.description': 'Выберите голос, используемый выбранным бэкендом.', + 'settings.voice.sounds.title': 'Как это звучит', + 'settings.voice.responds.title': 'Как отвечает', + 'settings.voice.connection.title': 'Подключение и провайдер', + 'settings.voice.behaves.title': 'Как себя ведёт', + 'settings.voice.persona.title': 'Персона и инструкции', + 'settings.voice.advanced.section.title': 'Дополнительно', + 'settings.voice.responseLength.label': 'Длина ответа', + 'settings.voice.responseLength.brief': 'Краткий', + 'settings.voice.responseLength.balanced': 'Сбалансированный', + 'settings.voice.responseLength.detailed': 'Подробный', + 'settings.voice.responseLength.brief.hint': 'Отвечать в 1–2 предложениях.', + 'settings.voice.responseLength.balanced.hint': 'По умолчанию — ассистент сам определяет длину по контексту.', + 'settings.voice.responseLength.detailed.hint': 'Давать развёрнутые ответы, когда полезна глубина.', + 'settings.voice.opening.label': 'Начало', + 'settings.voice.opening.greet': 'Поздороваться', + 'settings.voice.opening.brief': 'Ввести в курс', + 'settings.voice.opening.greet.hint': 'Ассистент здоровается и ждёт, пока вы заговорите.', + 'settings.voice.opening.brief.hint': 'Ассистент кратко описывает активность агента при подключении.', + 'settings.voice.group.label': 'Настройки голоса', + 'settings.voice.group.hint': 'Настройки применяются к выбранному голосовому провайдеру или провайдеру транскрипции.', + 'settings.voice.backend': 'Бэкенд голоса', + 'settings.voice.language': 'Язык голоса', + 'settings.voice.autoDetect': 'Автоопределение', + 'settings.voice.voice': 'Голос', + 'settings.voice.voiceDefault': 'По умолчанию', + 'settings.voice.advanced.title': 'Расширенные настройки голоса', + 'settings.voice.advanced.hint': 'Идентичность, характер, фикстуры, ползунки. Значения по умолчанию подходят — открывайте только если хотите перенастроить.', + 'settings.voice.advanced.customizedBadge': 'изменено', + 'settings.voice.fixtures.title': 'Правила платформы (только чтение)', + 'settings.voice.fixtures.hint': 'Поставляются с приложением: инструменты, маршрутизация, правила TTS. Обновляются при обновлении HAPI — здесь не редактируются.', + 'settings.voice.identity.title': 'Идентичность и бренд', + 'settings.voice.identity.hint': 'Кто такой голосовой ассистент. HAPI — это приложение; здесь можно переименовать или ребрендировать персону (например, «надзиратель»). Ограничения провайдера всегда применяются отдельно.', + 'settings.voice.identity.reset': 'Сбросить идентичность', + 'settings.voice.identity.default': 'Встроенная идентичность по умолчанию', + 'settings.voice.identity.customized': 'Своя идентичность', + 'settings.voice.character.promptTitle': 'Характер и стиль речи', + 'settings.voice.character.promptHint': 'Тон и стиль. Текст предустановленной подачи объединяется сюда, когда характер пуст. Компонуется с правилами платформы во время выполнения на всех голосовых бэкендах.', + 'settings.voice.character.reset': 'Сбросить характер', + 'settings.voice.character.appendPreset': 'Добавить предустановленный текст подачи', + 'settings.voice.layers.resetAll': 'Сбросить идентичность и характер', + 'settings.voice.layers.composedNote': 'Промпт во время выполнения = правила платформы + ваша идентичность + характер (все бэкенды).', + 'settings.voice.wireBudget.elevenlabs': 'ElevenLabs отправляет собранные инструкции при подключении; история сессии инициализируется, затем передаётся потоком.', + 'settings.voice.wireBudget.gemini': 'Gemini Live использует те же собранные инструкции через прокси хаба.', + 'settings.voice.wireBudget.qwen': 'Qwen Realtime использует те же собранные инструкции в session.update.', + 'settings.voice.wireBudget.storage': 'Лимит локального хранилища (символов):', + 'settings.voice.wireBudget.elevenlabsLimits': 'Ограничения канала: ~{msg} Б на сообщение WebRTC; бюджет собранного промпта ~{prompt} Б.', + 'settings.voice.wireBudget.promptTruncated': 'Ваш собранный промпт был сокращён, чтобы уложиться в лимит канала ElevenLabs. Сократите идентичность/характер, иначе мы ещё обрежем его при подключении.', + 'settings.voice.wireBudget.composedSize': 'Размер последнего собранного промпта', + 'settings.voice.contextNotice': 'Последняя голосовая сессия', + 'settings.voice.character.title': 'Предустановка стиля речи', + 'settings.voice.character.presetSlidersHint': 'Предустановки настраивают ползунки TTS ElevenLabs и необязательную формулировку подачи (слой характера).', + 'settings.voice.character.preset.label': 'Предустановка', + 'settings.voice.character.preset.balanced': 'Сбалансированная', + 'settings.voice.character.preset.balancedHint': 'Тёплая и сосредоточенная — подходит для повседневных задач.', + 'settings.voice.character.preset.warm': 'Тёплая и выразительная', + 'settings.voice.character.preset.warmHint': 'Более живая подача; аудио-теги ElevenLabs, когда доступны.', + 'settings.voice.character.preset.calm': 'Спокойная', + 'settings.voice.character.preset.calmHint': 'Медленный, размеренный тон для долгих или поздних сессий.', + 'settings.voice.character.preset.direct': 'Прямая', + 'settings.voice.character.preset.directHint': 'Кратко и эффективно — минимум лишнего.', + 'settings.voice.character.preset.custom': 'Своя', + 'settings.voice.character.preset.customHint': 'Вы управляете ползунками и системным промптом выше.', + 'settings.voice.tuning.title': 'Акустическая настройка', + 'settings.voice.tuning.elevenlabs': 'ElevenLabs', + 'settings.voice.tuning.gemini': 'Gemini Live', + 'settings.voice.tuning.stability': 'Стабильность', + 'settings.voice.tuning.stabilityHint': 'Ниже = динамичнее; выше = ровнее.', + 'settings.voice.tuning.expressiveness': 'Выразительность', + 'settings.voice.tuning.expressivenessHint': 'Преувеличение стиля (стиль ElevenLabs).', + 'settings.voice.tuning.speakingRate': 'Скорость речи', + 'settings.voice.tuning.speakingRateHint': '0,7–1,2× (API ElevenLabs).', + 'settings.voice.tuning.similarity': 'Усиление схожести', + 'settings.voice.tuning.similarityHint': 'Более точное совпадение с обученным голосом.', + 'settings.voice.tuning.speakerBoost': 'Усиление голоса', + 'settings.voice.tuning.affectiveDialog': 'Аффективный диалог', + 'settings.voice.tuning.affectiveDialogHint': 'Подстраивать подачу под ваш тон (Gemini v1alpha).', + 'settings.voice.tuning.qwenHint': 'У Qwen здесь нет ползунков TTS — отредактируйте голосовой системный промпт выше; эмоции подстраиваются под вашу речь.', + 'settings.voice.tuning.selectBackend': 'Ползунки для каждого бэкенда появляются при выборе голосового бэкенда.', + 'settings.voice.preview.elevenlabsOnly': 'Предпросмотр голоса доступен только для ElevenLabs', + 'settings.voice.preview.unavailable': 'Предпросмотр недоступен без API-ключа ElevenLabs', + 'settings.voice.preview.play': 'Прослушать голос', + 'settings.voice.preview.stop': 'Остановить предпросмотр', + 'settings.voice.staticCatalogHint': 'Откройте список, чтобы увидеть заметки о характере голоса. Аудиопредпросмотр — только ElevenLabs.', + 'settings.voice.session.label': 'Поведение сессии', + 'settings.voice.proactive': 'Начинать голосовую сессию со сводкой', + 'settings.voice.proactive.description': 'При включении голосовая сессия начинается с озвученной сводки текущей активности агента. При выключении ассистент здоровается и ждёт, пока вы заговорите.', + 'settings.companion.title': 'Компаньон', + 'settings.companion.noToken': 'Для сопряжения нужен исходный токен доступа (CLI_API_TOKEN). Похоже, вы вошли через Telegram или другим способом, который не сохранил его — вставьте токен вручную в приложении-компаньоне.', + 'settings.companion.description': 'Отсканируйте из приложения-компаньона HAPI на Android (телефон или Wear OS), чтобы привязать его к этому хабу. Код сопряжения — это ваш токен доступа; относитесь к нему как к паролю.', + 'settings.companion.showQr': 'Показать QR сопряжения', + 'settings.companion.qrAriaLabel': 'QR-код сопряжения компаньона', + 'settings.companion.copyLink': 'Копировать ссылку', + 'settings.companion.copied': 'Скопировано!', + 'settings.companion.hide': 'Скрыть', + 'settings.machines.title': 'Машины', + 'settings.machines.description': 'Дайте своим машинам собственные имена. Отображаются только машины, которые сейчас онлайн.', + 'settings.machines.section': 'Ваши машины', + 'settings.machines.rename': 'Переименовать {name}', + 'settings.machines.namePlaceholder': 'Использовать имя хоста', + 'settings.machines.empty': 'Нет машин онлайн.', + 'settings.machines.error': 'Не удалось переименовать эту машину.', + 'settings.hub.machines.summary': 'Собственные имена для ваших машин', + 'settings.about.title': 'О приложении', + 'settings.about.description': 'Ссылки и информация о версии HAPI.', + 'settings.about.website': 'Сайт', + 'settings.about.appVersion': 'Версия приложения', + 'settings.about.protocolVersion': 'Версия протокола', + + // Обзор / Рабочее пространство + 'browse.title': 'Обзор', + 'browse.goUp': 'Наверх', + 'browse.empty': 'Подкаталоги не найдены', + 'browse.refresh': 'Обновить', + 'browse.showHidden': 'Показать скрытые', + 'browse.startSession': 'Запустить сессию', + 'browse.nav': 'Обзор', + 'browse.noRootTitle': 'Обзор рабочего пространства выключен', + 'browse.noRootHint': 'Обзор включается по желанию. Перезапустите раннер с одним или несколькими корнями рабочего пространства, чтобы включить навигацию по дереву файлов и ограниченный запуск сессий.', + 'browse.noRootFooter': 'Вы по-прежнему можете создавать сессии на странице «Новая сессия».', + 'browse.noMachinesConnected': 'CLI не подключён. Выполните `hapi runner start --workspace-root /путь/а --workspace-root /путь/б` на машине, чтобы начать.', + + // Разное + 'misc.noMachines': 'Нет доступных машин', + 'misc.machine': 'Машина', + 'misc.collaborationMode': 'Режим совместной работы', + 'misc.copilotAgentMode': 'Режим агента', + 'misc.permissionMode': 'Режим разрешений', + 'misc.model': 'Модель', + 'misc.reasoningEffort': 'Уровень рассуждений', + 'misc.effort': 'Усилия', + 'misc.fastMode': 'Быстрый режим', + 'misc.fastModeStandard': 'Стандартный', + 'misc.fastModeFast': 'Быстрый', + 'misc.variant': 'Вариант', + 'misc.changeModel': 'Сменить модель…', + 'misc.backToModelList': '← Модели', + 'misc.loading': 'Загрузка…', + 'misc.newMessage': 'Новых сообщений: {n}', + 'misc.scrollToBottom': 'Прокрутить вниз', + 'misc.loadingMessages': 'Загрузка сообщений…', + 'misc.pullToLoadOlder': 'Тяните, чтобы загрузить более ранние сообщения', + 'misc.releaseToLoadOlder': 'Отпустите, чтобы загрузить более ранние сообщения', + 'misc.typeMessage': "Введите 'continue', чтобы продолжить...", + 'misc.typeAMessage': 'Введите сообщение...', + 'misc.typeAMessageWithMentions': 'Введите, что должен сделать агент, или @упомяните другую сессию для контекста или передачи', + 'misc.offline': 'офлайн', + 'misc.permissionRequired': 'требуется разрешение', + 'misc.online': 'онлайн', + 'misc.contextDetails': 'Сведения о контексте', + 'misc.contextCache': 'Кэш: {value}', + 'misc.contextUsed': 'Использовано: {value} ({percent}%)', + 'misc.contextUsedTokens': 'Использовано: {value}', + 'misc.contextRemaining': 'Осталось: {value} ({percent}%)', + + // Выбор цели Web Share + 'share.title': 'Поделиться в HAPI', + 'share.subtitle': 'Выберите сессию, к которой это прикрепить.', + 'share.recentSessions': 'Недавние активные сессии', + 'share.newSession': 'Новая сессия', + 'share.discard': 'Отменить', + 'share.loading': 'Загрузка общего содержимого…', + 'share.notFound.title': 'Общее содержимое не найдено', + 'share.notFound.body': 'Эта ссылка истекла или была открыта напрямую без передачи.', + 'share.error.ingest': 'Не удалось прочитать общее содержимое. Попробуйте ещё раз из исходного приложения.', + 'share.error.noId': 'Не передан идентификатор. Откройте эту страницу из системного меню «Поделиться».', + 'share.backToSessions': 'Назад к сессиям', + 'share.preview.text': 'Общий текст', + 'share.preview.empty': '(пустой общий элемент)', + 'share.preview.files': '{n} файлов', + 'share.noActiveSessions': 'Нет активных сессий. Выберите «Новая сессия» ниже.', + 'sessions.refresh.pull': 'Потяните для обновления', + 'sessions.refresh.release': 'Отпустите для обновления', + 'sessions.refresh.refreshing': 'Обновление…', + 'sessions.refresh.failed.title': 'Не удалось обновить', + 'codexSync.newSessionInline.title': 'Импорт истории Codex', + 'codexSync.newSessionInline.description': 'Необязательно: выберите одну локальную сессию Codex, затем выберите модель/настройки и нажмите «Создать» для импорта.', + 'codexSync.newSessionInline.clear': 'Очистить выбранную историю', + 'codexSync.newSessionInline.choose': 'Выбрать историю', + 'codexSync.confirm.search': 'Поиск', + 'codexSync.confirm.searchPlaceholder': 'Поиск по названию, сообщению, каталогу, ID сессии', + 'codexSync.confirm.useSelected': 'Использовать выбранное', + 'codexSync.confirm.imported': 'Импортировано', + 'codexSync.confirm.original': 'Исходная', + 'codexSync.confirm.fork': 'Форк', + 'codexSync.confirm.archiveAction': 'Архивировать в Codex', + 'session.action.syncCodexDisabledActive': 'Остановите или заархивируйте выполняющуюся сессию перед синхронизацией истории Codex.', + 'codexSync.manual.success.noNewMessages': 'Нет новых сообщений Codex.', + 'session.status.title': 'Статус сессии', + 'session.status.goal': 'Цель', + 'session.status.tasks': 'Задачи', + 'session.status.subagents': 'Подагенты', + 'session.status.terminals': 'Фоновые терминалы', + 'session.status.terminalsUnavailable': 'Выполняется: {count} · точная команда недоступна', + 'session.status.terminalsPossible': 'Возможные команды', + 'session.status.goal.active': 'Активна', + 'session.status.goal.paused': 'Приостановлена', + 'session.status.goal.budgetLimited': 'Ограничена', + 'session.status.goal.usageLimited': 'Ограничена по расходу', + 'session.status.goal.blocked': 'Заблокирована', + 'session.status.goal.complete': 'Завершена', + 'session.status.subagent.running': 'Выполняется', + 'session.status.subagent.waiting': 'Ожидание', + 'session.status.subagent.error': 'Ошибка', + 'share.searchResults': 'Подходящие сессии', + 'share.noSearchResults': 'Нет сессий, соответствующих поиску.', + 'share.searchForMore': 'Ещё {n} активных сессий — воспользуйтесь поиском, чтобы найти их.', + 'session.summary.label': 'Сводка сессии', + 'session.summary.ariaLabel': 'Сводка статуса сессии', + 'session.summary.status.done': 'Готово', + 'session.summary.status.blocked': 'Заблокирована', + 'session.summary.status.needsReview': 'Требует проверки', + 'session.summary.status.needsDecision': 'Требует решения', + 'session.summary.status.failed': 'Не удалась', + 'session.summary.status.stalled': 'Застопорилась', + + // Install prompt (iOS add-to-home-screen guide) + 'install.close': 'Закрыть', + 'install.ios.step1.before': 'Нажмите', + 'install.ios.step1.after': '«Поделиться» на панели', + 'install.ios.step2.before': 'Прокрутите вниз и нажмите', + 'install.ios.step2.after': 'На экран «Домой»', + 'install.ios.step3.before': 'Нажмите', + 'install.ios.step3.emphasis': 'Добавить', + 'install.ios.step3.after': 'в правом верхнем углу', + + // Queued messages bar + 'queuedMessages.title': 'Сообщения в очереди', + 'queuedMessages.status': 'В очереди', + 'queuedMessages.editLabel': 'Изменить сообщение в очереди', + 'queuedMessages.cancelLabel': 'Отменить сообщение в очереди', + 'queuedMessages.pendingInvocation.one': '{n} сообщение в очереди ожидает вызова', + 'queuedMessages.pendingInvocation.other': '{n} сообщений в очереди ожидают вызова', + + // Image viewer / zoomable lightbox + 'image.clickToZoom': 'Нажмите, чтобы увеличить', + 'image.previous': 'Предыдущее изображение', + 'image.next': 'Следующее изображение', + 'image.zoomIn': 'Увеличить', + 'image.zoomOut': 'Уменьшить', + 'image.resetZoom': 'Сбросить масштаб', + 'image.fitToScreen': 'По размеру экрана', + + // Chat chrome and status + 'chat.contextCompacted': 'Контекст сжат', + 'chat.reasoning': 'Рассуждения', + 'chat.queuedStatus': 'В очереди', + 'chat.sendingStatus': 'Отправка', + 'chat.terminalOutput': 'Вывод терминала', + 'chat.removeAttachment': 'Убрать вложение', + 'chat.uploadFailed': 'Ошибка загрузки', + + // Tool cards + 'tool.output': 'Вывод', + 'tool.prompt': 'Промпт', + 'tool.draft': 'Черновик', + 'tool.rawJson': 'Исходный JSON', + 'tool.fileContent': 'Содержимое файла', + + // Team panel + 'team.members': 'Участники', + 'team.tasks': 'Задачи', + 'team.recentMessages': 'Недавние сообщения', + + // Terminal / app shell + 'terminal.inactiveSession': 'Сессия неактивна. Терминал недоступен.', + 'app.sessionUnavailable': 'Сессия недоступна', + + // Tool result states + 'tool.state.waitingForPermission': 'Ожидание разрешения…', + 'tool.state.running': 'Выполняется…', + 'tool.state.noOutput': '(нет вывода)', + 'tool.result.done': 'Готово', + 'tool.result.agentLaunched': 'Агент запущен', + 'tool.result.timedOut': 'Тайм-аут', + 'tool.result.noStatus': 'Нет статуса', + 'tool.result.skillLoaded': 'Навык загружен', + 'tool.result.skillLoadedNamed': 'Навык «{name}» загружен', + 'tool.result.fieldName': 'Имя', + 'tool.result.fieldTask': 'Задача', + 'tool.result.fieldId': 'ID', + 'tool.result.agentCount': 'Агентов: {count}', + + // Session shell + 'session.notFound.returning': 'Сессия не найдена. Возвращаемся к списку…', +} as const diff --git a/web/src/routes/settings/general.tsx b/web/src/routes/settings/general.tsx index 17f6cbe2..da7c5f6a 100644 --- a/web/src/routes/settings/general.tsx +++ b/web/src/routes/settings/general.tsx @@ -8,6 +8,7 @@ import { queryKeys } from '@/lib/query-keys' const locales: ReadonlyArray<{ value: Locale; label: string }> = [ { value: 'en', label: 'English' }, { value: 'zh-CN', label: '简体中文' }, + { value: 'ru', label: 'Русский' }, ] function getNamespace(token: string | null): string | null {