Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
279 changes: 224 additions & 55 deletions apps/desktop/src/app/settings/ru-constants.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,229 @@
// Russian translations for field labels and descriptions.
// Used by ru.ts instead of the English defaults from constants.ts.
import { defineFieldCopy } from './field-copy'

export const RU_FIELD_LABELS: Record<string, string> = {
export const RU_FIELD_LABELS: Record<string, string> = defineFieldCopy({
model: 'Модель по умолчанию',
model_context_length: 'Окно контекста',
fallback_providers: 'Резервные модели',
modelContextLength: 'Окно контекста',
fallbackProviders: 'Резервные модели',
toolsets: 'Включённые наборы инструментов',
timezone: 'Часовой пояс',
'display.personality': 'Личность',
'display.show_reasoning': 'Блоки рассуждений',
'agent.max_turns': 'Макс. шагов агента',
'agent.image_input_mode': 'Вложения изображений',
'terminal.cwd': 'Рабочая директория',
'terminal.backend': 'Бэкенд выполнения',
'terminal.timeout': 'Тайм-аут команды',
'terminal.persistent_shell': 'Постоянная оболочка',
'terminal.env_passthrough': 'Передача переменных окружения',
file_read_max_chars: 'Лимит чтения файла',
'tool_output.max_bytes': 'Лимит вывода терминала',
'tool_output.max_lines': 'Лимит страницы файла',
'tool_output.max_line_length': 'Лимит длины строки',
'code_execution.mode': 'Режим выполнения кода',
'approvals.mode': 'Режим подтверждений',
'approvals.timeout': 'Тайм-аут подтверждения',
'approvals.mcp_reload_confirm': 'Подтверждать перезагрузку MCP',
command_allowlist: 'Белый список команд',
'security.redact_secrets': 'Скрывать секреты',
'security.allow_private_urls': 'Разрешить приватные URL',
'browser.allow_private_urls': 'Приватные URL в браузере'
}
display: {
personality: 'Стиль общения',
showReasoning: 'Блоки рассуждений'
},
agent: {
maxTurns: 'Максимум ходов агента',
imageInputMode: 'Вложения изображений',
apiMaxRetries: 'Повторные запросы к API',
serviceTier: 'Уровень обслуживания',
toolUseEnforcement: 'Обязательное использование инструментов'
},
terminal: {
cwd: 'Рабочая директория',
backend: 'Среда выполнения команд',
timeout: 'Время ожидания команды',
persistentShell: 'Постоянная командная оболочка',
envPassthrough: 'Передача переменных окружения',
dockerImage: 'Образ Docker',
singularityImage: 'Образ Singularity',
modalImage: 'Образ Modal',
daytonaImage: 'Образ Daytona'
},
fileReadMaxChars: 'Ограничение чтения файлов',
toolOutput: {
maxBytes: 'Ограничение вывода терминала',
maxLines: 'Строк на странице вывода',
maxLineLength: 'Ограничение длины строки'
},
codeExecution: {
mode: 'Режим выполнения кода'
},
approvals: {
mode: 'Режим подтверждений',
timeout: 'Время ожидания подтверждения',
mcpReloadConfirm: 'Подтверждать перезагрузку MCP'
},
commandAllowlist: 'Разрешённые команды',
security: {
redactSecrets: 'Скрывать секреты',
allowPrivateUrls: 'Разрешить внутренние URL'
},
browser: {
allowPrivateUrls: 'Внутренние URL в браузере',
autoLocalForPrivateUrls: 'Локальный браузер для внутренних URL'
},
checkpoints: {
enabled: 'Контрольные точки файлов',
maxSnapshots: 'Ограничение контрольных точек'
},
voice: {
recordKey: 'Горячая клавиша голосового ввода',
maxRecordingSeconds: 'Максимальная длительность записи',
autoTts: 'Озвучивать ответы'
},
stt: {
enabled: 'Распознавание речи',
echoTranscripts: 'Публиковать расшифровки',
provider: 'Провайдер распознавания речи',
local: {
model: 'Локальная модель распознавания',
language: 'Язык распознавания'
},
openai: {
model: 'Модель распознавания OpenAI'
},
groq: {
model: 'Модель распознавания Groq'
},
mistral: {
model: 'Модель распознавания Mistral'
},
elevenlabs: {
modelId: 'Модель распознавания ElevenLabs',
languageCode: 'Язык ElevenLabs',
tagAudioEvents: 'Отмечать звуковые события',
diarize: 'Разделять речь по говорящим'
}
},
tts: {
provider: 'Провайдер синтеза речи',
edge: {
voice: 'Голос Edge'
},
openai: {
model: 'Модель синтеза речи OpenAI',
voice: 'Голос OpenAI'
},
elevenlabs: {
voiceId: 'Голос ElevenLabs',
modelId: 'Модель ElevenLabs'
},
xai: {
voiceId: 'Голос xAI (Grok)',
language: 'Язык xAI'
},
minimax: {
model: 'Модель синтеза речи MiniMax',
voiceId: 'Голос MiniMax'
},
mistral: {
model: 'Модель синтеза речи Mistral',
voiceId: 'Голос Mistral'
},
gemini: {
model: 'Модель синтеза речи Gemini',
voice: 'Голос Gemini'
},
neutts: {
model: 'Модель NeuTTS',
device: 'Устройство NeuTTS'
},
kittentts: {
model: 'Модель KittenTTS',
voice: 'Голос KittenTTS'
},
piper: {
voice: 'Голос Piper'
}
},
memory: {
memoryEnabled: 'Долговременная память',
userProfileEnabled: 'Профиль пользователя',
memoryCharLimit: 'Объём памяти',
userCharLimit: 'Объём профиля',
provider: 'Провайдер памяти'
},
context: {
engine: 'Механизм управления контекстом'
},
compression: {
enabled: 'Автоматическое сжатие',
threshold: 'Порог сжатия',
targetRatio: 'Целевая степень сжатия',
protectLastN: 'Недавние сообщения без сжатия'
},
delegation: {
model: 'Модель субагента',
provider: 'Провайдер субагента',
maxIterations: 'Максимум ходов субагента',
maxConcurrentChildren: 'Параллельные субагенты',
childTimeoutSeconds: 'Время ожидания субагента',
reasoningEffort: 'Уровень рассуждений субагента'
},
updates: {
nonInteractiveLocalChanges: 'Локальные изменения при обновлении из приложения'
}
})

export const RU_FIELD_DESCRIPTIONS: Record<string, string> = {
model: 'Основная модель для ответов. Fallback-модели используются при ошибке.',
model_context_length: 'Максимальное количество токенов в окне контекста.',
fallback_providers: 'Дополнительные провайдеры на случай отказа основного.',
toolsets: 'Наборы инструментов, доступные агенту.',
timezone: 'Часовой пояс для отображения времени.',
'display.personality': 'Файл личности (AGENTS.md / SOUL.md).',
'display.show_reasoning': 'Показывать блоки рассуждений модели.',
'agent.max_turns': 'Максимальное количество итераций агента за один запрос.',
'agent.image_input_mode': 'Как обрабатывать вложенные изображения.',
'terminal.cwd': 'Рабочая директория для команд терминала.',
'terminal.backend': 'Где выполнять команды: локально, Docker, SSH.',
'terminal.timeout': 'Сколько секунд ждать завершения команды.',
'terminal.persistent_shell': 'Сохранять состояние оболочки между командами.',
'terminal.env_passthrough': 'Какие переменные окружения передавать в терминал.',
file_read_max_chars: 'Максимум символов при чтении файла.',
'tool_output.max_bytes': 'Максимальный размер вывода терминала в байтах.',
'tool_output.max_lines': 'Максимум строк на страницу вывода.',
'tool_output.max_line_length': 'Обрезать строки длиннее этого значения.',
'code_execution.mode': 'Как выполнять код: безопасно, с подтверждением или без ограничений.',
'approvals.mode': 'Когда запрашивать подтверждение перед выполнением.',
'approvals.timeout': 'Сколько ждать ответа пользователя.',
'approvals.mcp_reload_confirm': 'Спрашивать разрешение перед перезагрузкой MCP-серверов.',
command_allowlist: 'Команды, которые можно выполнять без подтверждения.',
'security.redact_secrets': 'Автоматически скрывать API-ключи и пароли в выводе.',
'security.allow_private_urls': 'Разрешить доступ к localhost и внутренним адресам.',
'browser.allow_private_urls': 'Разрешить браузеру открывать приватные URL.'
}
export const RU_FIELD_DESCRIPTIONS: Record<string, string> = defineFieldCopy({
model: 'Используется в новых чатах, если не выбрать другую модель в поле ввода.',
modelContextLength:
'Укажите 0, чтобы использовать автоматически определённый размер окна контекста выбранной модели.',
fallbackProviders: 'Пары «провайдер:модель», используемые по очереди при сбое основной модели.',
display: {
personality: 'Стиль общения ассистента по умолчанию для новых сессий.',
showReasoning: 'Показывать разделы с рассуждениями, если их передаёт бэкенд.'
},
timezone:
'Используется, когда Hermes требуется местное время. Оставьте поле пустым, чтобы использовать часовой пояс системы.',
agent: {
imageInputMode: 'Определяет, как вложенные изображения передаются модели.',
maxTurns: 'Максимальное количество ходов с вызовами инструментов, после которого Hermes останавливает выполнение.'
},
terminal: {
cwd: 'Папка проекта по умолчанию для работы с инструментами и терминалом.',
persistentShell: 'Сохранять состояние командной оболочки между командами, если это поддерживает среда выполнения.',
envPassthrough: 'Переменные окружения, передаваемые при выполнении инструментов.',
dockerImage: 'Образ контейнера для среды выполнения Docker.',
singularityImage: 'Образ для среды выполнения Singularity.',
modalImage: 'Образ для среды выполнения Modal.',
daytonaImage: 'Образ для среды выполнения Daytona.'
},
codeExecution: {
mode: 'Определяет, насколько строго выполнение кода ограничено текущим проектом.'
},
fileReadMaxChars: 'Максимальное количество символов, которое Hermes может прочитать из файла за один запрос.',
approvals: {
mode: 'Определяет, как Hermes обрабатывает команды, требующие явного подтверждения.',
timeout: 'Время ожидания ответа на запрос подтверждения.'
},
security: {
redactSecrets: 'По возможности скрывать обнаруженные секреты в данных, доступных модели.'
},
checkpoints: {
enabled: 'Создавать точки восстановления перед изменением файлов.'
},
memory: {
memoryEnabled: 'Сохранять долговременные сведения, которые могут пригодиться в будущих сессиях.',
userProfileEnabled: 'Вести компактный профиль предпочтений пользователя.'
},
context: {
engine: 'Стратегия ведения длинных диалогов при приближении к ограничению контекста.'
},
compression: {
enabled: 'Сжимать старую часть контекста при увеличении объёма диалога.'
},
voice: {
autoTts: 'Автоматически озвучивать ответы ассистента.'
},
tts: {
xai: {
voiceId: 'Идентификатор голоса xAI, например eve, или пользовательского голоса.',
language: 'Код языка речи, например en.'
},
neutts: {
device: 'Локальное устройство для инференса NeuTTS.'
}
},
stt: {
enabled: 'Включить локальное или облачное распознавание речи.',
echoTranscripts: 'Отправлять в чат необработанную расшифровку голосового сообщения с отметкой 🎙️.',
elevenlabs: {
languageCode:
'Необязательный код языка ISO 639-3. Оставьте поле пустым для автоматического определения в ElevenLabs.'
}
},
updates: {
nonInteractiveLocalChanges:
'При обновлении Hermes из приложения без запроса в терминале сохранять локальные изменения исходного кода во временном хранилище либо удалять их. При обновлении из терминала Hermes всегда запрашивает действие.'
}
})
45 changes: 45 additions & 0 deletions apps/desktop/src/i18n/context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,51 @@ describe('I18nProvider', () => {
expect(configClient.saveConfig).not.toHaveBeenCalled()
})

it('restores a persisted Russian locale from display.language config', async () => {
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue({ display: { language: 'ru-RU' } }),
saveConfig: vi.fn()
}

render(
<I18nProvider configClient={configClient}>
<LanguageProbe />
</I18nProvider>
)

await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))

expect(screen.getByTestId('locale').textContent).toBe('ru')
// Behavior marker only: the UI left English, without pinning the exact wording.
expect(screen.getByTestId('save').textContent).not.toBe('Save')
expect(configClient.saveConfig).not.toHaveBeenCalled()
})

it('saves the Russian locale to display.language', async () => {
const saveConfig = vi.fn().mockResolvedValue({ ok: true })

const configClient: I18nConfigClient = {
getConfig: vi
.fn()
.mockResolvedValueOnce({ display: { language: 'en' } })
.mockResolvedValueOnce({ display: { language: 'en', skin: 'mono' } }),
saveConfig
}

render(
<I18nProvider configClient={configClient}>
<LanguageProbe target="ru" />
</I18nProvider>
)

await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
fireEvent.click(screen.getByRole('button', { name: 'switch' }))

await waitFor(() => expect(saveConfig).toHaveBeenCalledTimes(1))
expect(saveConfig).toHaveBeenCalledWith({ display: { language: 'ru', skin: 'mono' } })
expect(screen.getByTestId('locale').textContent).toBe('ru')
})

it('does not overwrite unsupported configured languages', async () => {
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue({ display: { language: 'de' } }),
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/i18n/languages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,14 @@ describe('desktop i18n languages', () => {
expect(localeConfigValue('zh-hant')).toBe('zh-hant')
expect(localeConfigValue('ja')).toBe('ja')
})

it('recognizes the Russian locale, its aliases, and config value', () => {
expect(normalizeLocale('ru')).toBe('ru')
expect(normalizeLocale('ru-RU')).toBe('ru')
expect(normalizeLocale('ru_ru')).toBe('ru')
expect(isLocale('ru')).toBe(true)
expect(isSupportedLocaleValue('ru-RU')).toBe(true)
expect(isSupportedLocaleValue('ru_ru')).toBe(true)
expect(localeConfigValue('ru')).toBe('ru')
})
})
28 changes: 28 additions & 0 deletions apps/desktop/src/i18n/ru.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'

import { ru } from './ru'

describe('Russian locale', () => {
it.each([
[0, '0 профилей'],
[1, '1 профиль'],
[2, '2 профиля'],
[5, '5 профилей'],
[11, '11 профилей'],
[21, '21 профиль'],
[22, '22 профиля'],
[25, '25 профилей']
])('declines profile counts for %i', (count, expected) => {
expect(ru.profiles.count(count)).toBe(expected)
})

it('declines counts consistently across independent interface sections', () => {
expect(ru.agents.workers(11)).toBe('11 исполнителей')
expect(ru.agents.workers(21)).toBe('21 исполнитель')
expect(ru.settings.sessions.messages(2)).toBe('2 сообщения')
expect(ru.settings.sessions.messages(21)).toBe('21 сообщение')
expect(ru.cron.count(22)).toBe('22 задачи')
expect(ru.preview.console.sentMessage(1)).toBe('В поле ввода добавлено 1 запись журнала')
expect(ru.preview.console.sentMessage(22)).toBe('В поле ввода добавлено 22 записи журнала')
})
})
Loading