feat: add full Russian (ru-RU) localization — v0.50.95 (PR #713)

Full Russian locale — 389/389 English keys, Slavic plural forms, native Cyrillic. Rebased from PR #605 with rebase artifacts fixed. Login page Russian added to api/routes.py. Credits: @DrMaks22 (translation), @renheqiang (PR #605 author).

Co-authored-by: DrMaks22 <DrMaks22@users.noreply.github.com>
Co-authored-by: renheqiang <renheqiang@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-04-18 23:47:24 -07:00
committed by GitHub
parent e637965388
commit 067d96bb30
9 changed files with 666 additions and 25 deletions

View File

@@ -283,6 +283,15 @@ _LOGIN_LOCALE = {
"invalid_pw": "Ung\u00fcltiges Passwort", "invalid_pw": "Ung\u00fcltiges Passwort",
"conn_failed": "Verbindung fehlgeschlagen", "conn_failed": "Verbindung fehlgeschlagen",
}, },
"ru": {
"lang": "ru-RU",
"title": "\u0412\u043e\u0439\u0442\u0438",
"subtitle": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c, \u0447\u0442\u043e\u0431\u044b \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c",
"placeholder": "\u041f\u0430\u0440\u043e\u043b\u044c",
"btn": "\u0412\u043e\u0439\u0442\u0438",
"invalid_pw": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c",
"conn_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f",
},
"zh": { "zh": {
"lang": "zh-CN", "lang": "zh-CN",
"title": "\u767b\u5f55", "title": "\u767b\u5f55",

View File

@@ -450,6 +450,11 @@ const LOCALES = {
profile_use: 'Use', profile_use: 'Use',
profile_switch_title: 'Switch to this profile', profile_switch_title: 'Switch to this profile',
profile_delete_title: 'Delete this profile', profile_delete_title: 'Delete this profile',
profile_default_label: '(default)',
profile_name_placeholder: 'Profile name (lowercase, a-z 0-9 hyphens)',
profile_clone_label: 'Clone config from active profile',
profile_base_url_placeholder: 'Base URL (optional, e.g. http://localhost:11434)',
profile_api_key_placeholder: 'API key (optional)',
manage_profiles: 'Manage profiles', manage_profiles: 'Manage profiles',
profiles_load_failed: 'Failed to load profiles', profiles_load_failed: 'Failed to load profiles',
profiles_busy_switch: 'Cannot switch profiles while agent is running', profiles_busy_switch: 'Cannot switch profiles while agent is running',
@@ -473,6 +478,465 @@ const LOCALES = {
bg_error_multi: (count) => `${count} sessions have encountered an error`, bg_error_multi: (count) => `${count} sessions have encountered an error`,
}, },
ru: {
_lang: 'ru',
_label: 'Русский',
_speech: 'ru-RU',
cancelling: 'Отменяю…',
cancel_failed: 'Не удалось отменить: ',
mic_denied: 'Доступ к микрофону запрещён. Проверьте разрешения браузера.',
mic_no_speech: 'Речь не распознана. Попробуйте ещё раз.',
mic_network: 'Распознавание речи недоступно.',
mic_error: 'Ошибка ввода речи: ',
session_imported: 'Сеанс импортирован',
import_failed: 'Не удалось импортировать: ',
import_invalid_json: 'Неверный JSON',
image_pasted: 'Изображение вставлено: ',
edit_message: 'Редактировать сообщение',
regenerate: 'Сгенерировать ответ заново',
copy: 'Копировать',
copied: 'Скопировано!',
you: 'Вы',
thinking: 'Думаю',
expand_all: 'Развернуть всё',
collapse_all: 'Свернуть всё',
edit_failed: 'Не удалось отредактировать: ',
regen_failed: 'Не удалось сгенерировать заново: ',
reconnect_active: 'Ответ всё ещё генерируется. Обновить, когда будет готово?',
reconnect_finished: 'Когда вы уходили, ответ ещё генерировался. Сообщения могли обновиться.',
approval_heading: 'Требуется подтверждение',
approval_desc_prefix: 'Обнаружена опасная команда',
approval_btn_once: 'Разрешить один раз',
approval_btn_once_title: 'Разрешить только эту команду (Enter)',
approval_btn_session: 'Разрешить на этот сеанс',
approval_btn_session_title: 'Разрешить для этого сеанса разговора',
approval_btn_always: 'Всегда разрешать',
approval_btn_always_title: 'Всегда разрешать команды по этому шаблону',
approval_btn_deny: 'Запретить',
approval_btn_deny_title: 'Запретить — не выполнять эту команду',
approval_responding: 'Отвечаю…',
untitled: 'Без названия',
n_messages: (n) => `${n} сообщений`,
model_unavailable: ' (недоступна)',
model_unavailable_title: 'Эта модель больше не входит в ваш текущий список провайдеров',
provider_mismatch_warning: (m, p) =>
`"${m}" может не работать с вашим настроенным провайдером (${p}). Всё равно отправить или запустите \`hermes model\` в терминале, чтобы переключиться.`,
provider_mismatch_label: 'Несовпадение провайдера',
model_custom_label: 'Пользовательский ID модели',
model_custom_placeholder: 'например, openai/gpt-5.4',
cmd_help: 'Показать доступные команды',
cmd_clear: 'Очистить сообщения беседы',
cmd_compact: 'Сжать контекст беседы',
cmd_model: 'Переключить модель (например, /model gpt-4o)',
cmd_workspace: 'Переключить рабочее пространство по названию',
cmd_new: 'Начать новую сессию чата',
cmd_usage: 'Показать или скрыть использование токенов',
cmd_theme: 'Переключить тему (dark/light/slate/solarized/monokai/nord/oled)',
cmd_personality: 'Переключить личность агента',
cmd_skills: 'Показать доступные навыки Hermes',
available_commands: 'Доступные команды:',
type_slash: 'Введите /, чтобы увидеть команды',
conversation_cleared: 'Беседа очищена',
model_usage: 'Использование: /model <name>',
no_model_match: 'Нет модели, соответствующей "',
switched_to: 'Переключено на ',
workspace_usage: 'Использование: /workspace <name>',
no_workspace_match: 'Нет рабочего пространства, соответствующего "',
switched_workspace: 'Переключено на рабочее пространство: ',
workspace_switch_failed: 'Не удалось переключить рабочее пространство: ',
new_session: 'Новая сессия создана',
compressing: 'Запрашиваю сжатие контекста...',
token_usage_on: 'Отображение токенов включено',
token_usage_off: 'Отображение токенов выключено',
theme_usage: 'Использование: /theme ',
theme_set: 'Тема: ',
no_active_session: 'Нет активной сессии',
no_personalities: 'Личности не найдены (добавьте их в ~/.hermes/personalities/)',
clarify_heading: 'Требуется уточнение',
clarify_hint: 'Выберите вариант или введите свой ответ ниже.',
clarify_input_placeholder: 'Введите ответ…',
clarify_other: 'Другое',
clarify_responding: 'Отвечаю…',
clarify_send: 'Отправить',
cmd_compact_alias: 'Устаревший псевдоним для /compress',
cmd_compress: 'Сжать контекст беседы (использование: /compress [тема])',
command_label: 'Команда',
compress_complete_label: 'Сжатие завершено',
compress_failed_label: 'Ошибка сжатия',
compress_running_label: 'Сжатие…',
context_compaction_label: 'Сжатие контекста',
focus_label: 'Фокус',
model_search_no_results: 'Модели не найдены',
model_search_placeholder: 'Поиск моделей…',
reference_only_label: 'Только справка',
settings_label_skin: 'Скин',
workspace_empty_dir: 'Это рабочее пространство пусто.',
workspace_empty_no_path: 'Рабочее пространство не выбрано. Настройте его в Настройки → Рабочее пространство.',
available_personalities: 'Доступные личности:',
personality_switch_hint: '\n\nИспользуйте `/personality <name>` для переключения или `/personality none` для сброса.',
personalities_load_failed: 'Не удалось загрузить личности',
personality_cleared: 'Личность очищена',
personality_set: 'Личность: ',
failed_colon: 'Не удалось: ',
no_workspace: 'Нет рабочего пространства',
dialog_confirm_title: 'Подтвердить действие',
dialog_prompt_title: 'Введите значение',
dialog_confirm_btn: 'Подтвердить',
unsaved_confirm: 'У вас есть несохранённые изменения в предпросмотре. Отменить и перейти дальше?',
discard: 'Отменить',
save: 'Сохранить',
edit: 'Редактировать',
clear: 'Очистить',
create: 'Создать',
remove: 'Удалить',
save_title: 'Сохранить изменения',
edit_title: 'Редактировать этот файл',
saved: 'Сохранено',
save_failed: 'Не удалось сохранить: ',
image_load_failed: 'Не удалось загрузить изображение',
file_open_failed: 'Не удалось открыть файл',
downloading: (name) => `Скачиваю ${name}`,
double_click_rename: 'Дважды щёлкните, чтобы переименовать',
renamed_to: 'Переименовано в ',
rename_failed: 'Не удалось переименовать: ',
delete_title: 'Удалить',
delete_confirm: (name) => `Удалить ${name}?`,
deleted: 'Удалено ',
delete_failed: 'Не удалось удалить: ',
new_file_prompt: 'Имя нового файла (например, notes.md):',
project_name_prompt: 'Имя проекта:',
created: 'Создано ',
create_failed: 'Не удалось создать: ',
new_folder_prompt: 'Имя новой папки:',
folder_created: 'Папка создана ',
folder_create_failed: 'Не удалось создать папку: ',
remove_title: 'Удаление',
empty_dir: '(пусто)',
upload_failed: 'Не удалось загрузить: ',
all_uploads_failed: (n) => `Не удалось загрузить все ${n} файлов`,
settings_title: 'Настройки',
settings_save_btn: 'Сохранить настройки',
settings_label_model: 'Модель по умолчанию',
settings_label_send_key: 'Клавиша отправки',
settings_label_theme: 'Тема',
settings_label_language: 'Язык',
settings_label_token_usage: 'Показывать использование токенов',
settings_label_bubble_layout: 'Раскладка пузырьков чата',
settings_label_cli_sessions: 'Показывать сеансы агента',
settings_label_sync_insights: 'Синхронизировать с Insights',
settings_label_check_updates: 'Проверять обновления',
settings_label_bot_name: 'Имя помощника',
settings_label_password: 'Пароль доступа',
settings_saved: 'Настройки сохранены',
settings_save_failed: 'Не удалось сохранить: ',
settings_load_failed: 'Не удалось загрузить настройки: ',
settings_saved_pw: 'Настройки сохранены (пароль задан — теперь требуется вход)',
settings_saved_pw_updated: 'Настройки сохранены (пароль обновлён)',
login_title: 'Вход',
login_subtitle: 'Введите пароль, чтобы продолжить',
login_placeholder: 'Пароль',
login_btn: 'Войти',
login_invalid_pw: 'Неверный пароль',
login_conn_failed: 'Не удалось подключиться',
tab_chat: 'Чат',
tab_tasks: 'Задачи',
tab_skills: 'Навыки',
tab_memory: 'Память',
tab_workspaces: 'Рабочие пространства',
tab_profiles: 'Профили',
tab_todos: 'Список дел',
new_conversation: 'Новая беседа',
filter_conversations: 'Фильтр бесед...',
session_time_unknown: 'Неизвестно',
session_time_just_now: 'только что',
session_time_minutes_ago: (n) => {
const mod10 = n % 10;
const mod100 = n % 100;
const word = mod10 === 1 && mod100 !== 11
? 'минута'
: (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)
? 'минуты'
: 'минут');
return `${n} ${word} назад`;
},
session_time_hours_ago: (n) => {
const mod10 = n % 10;
const mod100 = n % 100;
const word = mod10 === 1 && mod100 !== 11
? 'час'
: (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)
? 'часа'
: 'часов');
return `${n} ${word} назад`;
},
session_time_days_ago: (n) => {
const mod10 = n % 10;
const mod100 = n % 100;
const word = mod10 === 1 && mod100 !== 11
? 'день'
: (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)
? 'дня'
: 'дней');
return `${n} ${word} назад`;
},
session_time_last_week: 'на прошлой неделе',
session_time_bucket_today: 'Сегодня',
session_time_bucket_yesterday: 'Вчера',
session_time_bucket_this_week: 'На этой неделе',
session_time_bucket_last_week: 'На прошлой неделе',
session_time_bucket_older: 'Ранее',
scheduled_jobs: 'Запланированные задания',
new_job: 'Новое задание',
loading: 'Загрузка...',
search_skills: 'Поиск навыков...',
new_skill: 'Новый навык',
personal_memory: 'Личная память',
current_task_list: 'Текущий список задач',
workspace_desc: 'Добавляйте рабочие пространства и переключайтесь между ними в своих сеансах.',
new_profile: 'Новый профиль',
transcript: 'Транскрипт',
download_transcript: 'Скачать как Markdown',
import: 'Импорт',
settings_label_sound: 'Звук уведомления',
settings_desc_sound: 'Проигрывать звук, когда помощник завершает ответ.',
settings_label_notifications: 'Уведомления браузера',
settings_desc_notifications: 'Показывать системное уведомление, когда ответ готов, а вкладка находится в фоне.',
settings_desc_token_usage: 'Показывает количество входных и выходных токенов под каждым ответом помощника. Также переключается через /usage.',
settings_desc_bubble_layout: 'Выравнивает сообщения пользователя справа, а ответы помощника слева. Выключено по умолчанию, чтобы блоки кода и вывод инструментов занимали всю ширину.',
settings_desc_cli_sessions: 'Объединяет сеансы из Hermes CLI (state.db) в список сеансов. Нажмите на CLI-сеанс, чтобы импортировать его и продолжить разговор.',
settings_desc_sync_insights: 'Синхронизирует использование токенов WebUI в state.db, чтобы Hermes /insights включал данные браузерных сеансов. Выключено по умолчанию.',
settings_desc_check_updates: 'Показывает баннер, когда доступны более новые версии WebUI или Agent. Периодически выполняет git fetch в фоне.',
settings_desc_bot_name: 'Отображаемое имя помощника во всём интерфейсе. По умолчанию Hermes.',
settings_desc_password: 'Введите новый пароль, чтобы задать или изменить его. Оставьте пустым, чтобы сохранить текущую настройку.',
password_placeholder: 'Введите новый пароль…',
disable_auth: 'Отключить авторизацию',
sign_out: 'Выйти',
cancel: 'Отмена',
create_job: 'Создать задание',
save_skill: 'Сохранить навык',
editing: 'Редактирование',
empty_title: 'Чем я могу помочь?',
empty_subtitle: 'Спрашивайте что угодно, запускайте команды, изучайте файлы или управляйте запланированными задачами.',
suggest_files: 'Какие файлы есть в этом рабочем пространстве?',
suggest_schedule: 'Что у меня сегодня в расписании?',
suggest_plan: 'Помоги спланировать небольшой проект.',
onboarding_badge: 'ПЕРВЫЙ ЗАПУСК',
onboarding_title: 'Добро пожаловать в Hermes Web UI',
onboarding_lead: 'Краткая пошаговая настройка проверит Hermes, сохранит рабочую конфигурацию провайдера, выберет рабочее пространство и модель и при желании защитит приложение паролем.',
onboarding_back: 'Назад',
onboarding_continue: 'Продолжить',
onboarding_skip: 'Пропустить настройку',
onboarding_skipped: 'Настройка пропущена — используется существующая конфигурация.',
onboarding_open: 'Открыть Hermes',
onboarding_step_system_title: 'Проверка системы',
onboarding_step_system_desc: 'Проверить Hermes Agent и видимость конфигурации.',
onboarding_step_setup_title: 'Настройка провайдера',
onboarding_step_setup_desc: 'Сохранить минимальную рабочую конфигурацию провайдера Hermes.',
onboarding_step_workspace_title: 'Рабочее пространство и модель',
onboarding_step_workspace_desc: 'Выбрать значения по умолчанию для новых сеансов и чатов.',
onboarding_step_password_title: 'Необязательный пароль',
onboarding_step_password_desc: 'Защитить Web UI перед тем, как делиться им.',
onboarding_step_finish_title: 'Готово',
onboarding_step_finish_desc: 'Проверьте настройки и войдите в приложение.',
onboarding_notice_system_ready: 'Hermes Agent, похоже, доступен из Web UI.',
onboarding_notice_system_unavailable: 'Hermes Agent ещё не полностью доступен. Bootstrap может установить его, но для настройки провайдера всё ещё может понадобиться терминал.',
onboarding_check_agent: 'Hermes Agent',
onboarding_check_agent_ready: 'Обнаружен и доступен для импорта',
onboarding_check_agent_missing: 'Отсутствует или доступен только частично',
onboarding_check_password: 'Пароль',
onboarding_check_password_enabled: 'Уже включён',
onboarding_check_password_disabled: 'Пока не включён',
onboarding_check_provider: 'Конфигурация провайдера',
onboarding_check_provider_ready: 'Готова к чату',
onboarding_check_provider_partial: 'Сохранена, но не завершена',
onboarding_check_provider_pending: 'Требует проверки',
onboarding_config_file: 'Файл конфигурации:',
onboarding_env_file: 'Файл .env:',
onboarding_unknown: 'Неизвестно',
onboarding_current_provider: 'Текущая конфигурация:',
onboarding_missing_imports: 'Отсутствующие импорты:',
onboarding_notice_setup_required: 'Выберите здесь простой путь настройки провайдера. Продвинутые OAuth-сценарии пока остаются в Hermes CLI.',
onboarding_notice_setup_already_ready: 'Уже обнаружена рабочая конфигурация провайдера Hermes. Вы можете оставить её или заменить здесь.',
onboarding_oauth_provider_ready_title: 'Провайдер уже авторизован',
onboarding_oauth_provider_ready_body: 'Этот экземпляр настроен на использование OAuth-провайдера (<strong>{provider}</strong>), настроенного через Hermes CLI. API-ключ здесь не нужен — нажмите «Продолжить», чтобы завершить настройку.',
onboarding_oauth_provider_not_ready_title: 'OAuth-провайдер ещё не авторизован',
onboarding_oauth_provider_not_ready_body: 'Этот экземпляр настроен на использование <strong>{provider}</strong>, который работает через OAuth, а не через API-ключ. Запустите <code>hermes auth</code> или <code>hermes model</code> в терминале, чтобы пройти авторизацию, затем обновите Web UI.',
onboarding_oauth_switch_hint: 'Или выберите ниже другой провайдер, чтобы перейти на настройку с ключом API:',
onboarding_notice_workspace: 'Эти значения используют те же API настроек, что и обычное приложение.',
onboarding_workspace_label: 'Рабочее пространство',
onboarding_workspace_or_path: 'Или укажите путь к рабочему пространству',
onboarding_workspace_placeholder: '/home/you/workspace',
onboarding_provider_label: 'Режим настройки',
onboarding_quick_setup_badge: 'Быстрая настройка',
onboarding_api_key_label: 'Ключ API',
onboarding_api_key_placeholder: 'Оставьте пустым, чтобы сохранить уже сохранённый ключ',
onboarding_api_key_help_prefix: 'Сохраняется как секрет в вашем файле `.env` Hermes с помощью',
onboarding_base_url_label: 'Базовый URL',
onboarding_base_url_placeholder: 'https://your-endpoint.example/v1',
onboarding_base_url_help: 'Используйте это для OpenAI-compatible маршрутизаторов, self-hosted серверов, LiteLLM, Ollama, LM Studio, vLLM и похожих endpoint-ов.',
onboarding_model_label: 'Модель по умолчанию',
onboarding_workspace_help: 'Выберите модель, которую Hermes должен использовать для новых чатов после завершения настройки.',
onboarding_custom_model_placeholder: 'имя_вашей_модели',
onboarding_custom_model_help: 'Для собственных endpoint-ов укажите точный ID модели, который ожидает ваш сервер.',
onboarding_notice_password_enabled: 'Пароль уже настроен. Вводите новый только если хотите заменить текущий.',
onboarding_notice_password_recommended: 'Необязательно, но рекомендуется, если вы собираетесь открывать UI не только на localhost.',
onboarding_password_label: 'Пароль (необязательно)',
onboarding_password_placeholder: 'Оставьте пустым, чтобы пропустить',
onboarding_password_help: 'Пароли сохраняются через существующий API настроек и хэшируются на сервере.',
onboarding_notice_finish: 'Позже вы сможете снова открыть настройки и изменить любое из этих значений.',
onboarding_not_set: 'Не задано',
onboarding_password_will_enable: 'Будет включён',
onboarding_password_will_replace: 'Будет заменён текущий пароль',
onboarding_password_keep_existing: 'Оставить текущий пароль',
onboarding_password_remains_disabled: 'Останется отключённым',
onboarding_password_skipped: 'Пропустить пока',
onboarding_finish_help: 'После завершения в настройках сохранится <code>onboarding_completed</code>, и вы попадёте в обычное приложение.',
onboarding_error_choose_workspace: 'Выберите рабочее пространство перед продолжением.',
onboarding_error_choose_model: 'Выберите модель перед продолжением.',
onboarding_error_provider_required: 'Выберите режим настройки перед продолжением.',
onboarding_error_base_url_required: 'Для собственных endpoint-ов требуется базовый URL.',
onboarding_error_workspace_required: 'Рабочее пространство обязательно.',
onboarding_error_model_required: 'Модель обязательна.',
onboarding_complete: 'Первичная настройка завершена',
error_prefix: 'Ошибка: ',
not_available: 'н/д',
never: 'никогда',
add: 'Добавить',
add_failed: 'Не удалось добавить: ',
remove_failed: 'Не удалось удалить: ',
switch_failed: 'Не удалось переключить: ',
name_required: 'Требуется имя',
content_required: 'Требуется содержимое',
view: 'Просмотр',
dismiss: 'Скрыть',
disable: 'Отключить',
cron_no_jobs: 'Запланированные задания не найдены.',
cron_status_off: 'неактивно',
cron_status_paused: 'на паузе',
cron_status_error: 'ошибка',
cron_status_active: 'активно',
cron_next: 'Следующий',
cron_last: 'Последний',
cron_run_now: 'Запустить сейчас',
cron_pause: 'Пауза',
cron_resume: 'Возобновить',
cron_job_name_placeholder: 'Имя задания',
cron_schedule_placeholder: 'Расписание',
cron_prompt_placeholder: 'Промпт',
cron_last_output: 'Последний вывод',
cron_all_runs: 'Все запуски',
cron_hide_runs: 'Скрыть запуски',
cron_no_runs_yet: '(пока запусков нет)',
cron_schedule_required_example: 'Требуется расписание (например, "0 9 * * *" или "every 1h")',
cron_schedule_required: 'Требуется расписание',
cron_prompt_required: 'Требуется промпт',
cron_job_created: 'Задание создано',
cron_job_triggered: 'Задание запущено',
cron_job_paused: 'Задание поставлено на паузу',
cron_job_resumed: 'Задание возобновлено',
cron_job_updated: 'Задание обновлено',
cron_delete_confirm_title: 'Удалить cron-задание',
cron_delete_confirm_message: 'Это действие нельзя отменить.',
cron_job_deleted: 'Задание удалено',
cron_completion_status: (name, status) => `Cron-задание «${name}» — ${status}`,
status_failed: 'неудачно',
status_completed: 'завершено',
todos_no_active: 'В этой сессии нет активного списка задач.',
clear_conversation_title: 'Очистить беседу',
clear_conversation_message: 'Очистить все сообщения? Это действие нельзя отменить.',
clear_failed: 'Не удалось очистить: ',
skills_no_match: 'Подходящих навыков не найдено.',
linked_files: 'Связанные файлы',
skill_load_failed: 'Не удалось загрузить навык: ',
skill_file_load_failed: 'Не удалось загрузить файл: ',
skill_name_required: 'Требуется имя навыка',
skill_updated: 'Навык обновлён',
skill_created: 'Навык создан',
memory_notes_label: 'память (заметки)',
memory_saved: 'Память сохранена',
my_notes: 'Мои заметки',
user_profile: 'Пользовательский профиль',
no_notes_yet: 'Пока нет заметок.',
no_profile_yet: 'Пока нет профиля.',
workspace_choose_path: 'Выберите путь к рабочему пространству',
workspace_choose_path_meta: 'Добавьте проверенный путь и переключите эту беседу',
workspace_manage: 'Управление рабочими пространствами',
workspace_manage_meta: 'Открыть панель Spaces',
workspace_use_title: 'Использовать в текущем сеансе',
workspace_use: 'Использовать',
workspace_add_path_placeholder: 'Добавьте путь к рабочему пространству (например, /Users/you/project)',
workspace_paths_validated_hint: 'Перед сохранением пути проверяются на существование.',
workspace_added: 'Рабочее пространство добавлено',
workspace_remove_confirm_title: 'Удалить рабочее пространство',
workspace_remove_confirm_message: (path) => `Удалить «${path}»?`,
workspace_removed: 'Рабочее пространство удалено',
workspace_switch_prompt_title: 'Переключить рабочее пространство',
workspace_switch_prompt_message: 'Введите абсолютный путь к рабочему пространству, чтобы добавить его и переключить эту беседу.',
workspace_switch_prompt_confirm: 'Переключить',
workspace_switch_prompt_placeholder: '/Users/you/project',
workspace_not_added: 'Рабочее пространство не добавлено',
workspace_already_saved: 'Рабочее пространство уже сохранено — выберите его из списка',
workspace_busy_switch: 'Нельзя переключать рабочее пространство, пока агент работает',
discard_file_edits_title: 'Отменить изменения файлов?',
discard_file_edits_message: 'При переключении рабочих пространств несохранённые изменения в предпросмотре будут потеряны.',
workspace_switched_to: (name) => `Переключено на ${name}`,
profiles_no_profiles: 'Профили не найдены.',
profile_api_keys_configured: 'API-ключи настроены',
profile_gateway_running: 'Gateway запущен',
profile_gateway_stopped: 'Gateway остановлен',
profile_active: 'АКТИВЕН',
profile_no_configuration: 'Нет конфигурации',
profile_skill_count: (count) => {
const mod10 = count % 10;
const mod100 = count % 100;
const word = mod10 === 1 && mod100 !== 11
? 'навык'
: (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)
? 'навыка'
: 'навыков');
return `${count} ${word}`;
},
profile_use: 'Использовать',
profile_switch_title: 'Переключиться на этот профиль',
profile_delete_title: 'Удалить этот профиль',
profile_default_label: '(по умолчанию)',
profile_name_placeholder: 'Название профиля (строчные буквы, a-z, 0-9, дефисы)',
profile_clone_label: 'Скопировать конфигурацию из активного профиля',
profile_base_url_placeholder: 'Базовый URL (необязательно, например http://localhost:11434)',
profile_api_key_placeholder: 'API-ключ (необязательно)',
manage_profiles: 'Управление профилями',
profiles_load_failed: 'Не удалось загрузить профили',
profiles_busy_switch: 'Нельзя переключать профили, пока агент работает',
profile_switched_new_conversation: (name) => `Переключено на профиль: ${name} — начата новая беседа`,
profile_switched: (name) => `Переключено на профиль: ${name}`,
profile_name_rule: 'Только строчные буквы, цифры, дефисы и подчёркивания',
profile_base_url_rule: 'Базовый URL должен начинаться с http:// или https://',
profile_created: (name) => `Профиль создан: ${name}`,
profile_delete_confirm_title: (name) => `Удалить профиль «${name}»?`,
profile_delete_confirm_message: 'Это удалит всю конфигурацию, навыки, память и сеансы этого профиля.',
profile_deleted: (name) => `Профиль удалён: ${name}`,
active_conversation_none: 'Активная беседа не выбрана.',
active_conversation_meta: (title, count) => {
const mod10 = count % 10;
const mod100 = count % 100;
const word = mod10 === 1 && mod100 !== 11
? 'сообщение'
: (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)
? 'сообщения'
: 'сообщений');
return `${title} · ${count} ${word}`;
},
settings_unsaved_changes: 'У вас есть несохранённые изменения.',
sign_out_failed: 'Не удалось выйти: ',
disable_auth_confirm_title: 'Отключить защиту паролем',
disable_auth_confirm_message: 'Любой сможет получить доступ к этому экземпляру.',
auth_disabled: 'Авторизация отключена — защита паролем снята',
disable_auth_failed: 'Не удалось отключить авторизацию: ',
bg_error_single: (title) => `В "${title}" возникла ошибка`,
bg_error_multi: (count) => `${count} сеансов столкнулись с ошибкой`,
},
es: { es: {
_lang: 'es', _lang: 'es',
_label: 'Español', _label: 'Español',
@@ -534,6 +998,7 @@ const LOCALES = {
cmd_clear: 'Borrar los mensajes de la conversación', cmd_clear: 'Borrar los mensajes de la conversación',
cmd_compress: 'Comprimir manualmente el contexto de la conversación (uso: /compress [tema])', cmd_compress: 'Comprimir manualmente el contexto de la conversación (uso: /compress [tema])',
cmd_compact_alias: 'Alias antiguo de /compress', cmd_compact_alias: 'Alias antiguo de /compress',
cmd_compact: 'Comprimir contexto de la conversación',
cmd_model: 'Cambiar de modelo (p. ej. /model gpt-4o)', cmd_model: 'Cambiar de modelo (p. ej. /model gpt-4o)',
cmd_workspace: 'Cambiar de espacio de trabajo por nombre', cmd_workspace: 'Cambiar de espacio de trabajo por nombre',
cmd_new: 'Iniciar una nueva sesión de chat', cmd_new: 'Iniciar una nueva sesión de chat',
@@ -869,6 +1334,11 @@ const LOCALES = {
profile_use: 'Use', profile_use: 'Use',
profile_switch_title: 'Switch to this profile', profile_switch_title: 'Switch to this profile',
profile_delete_title: 'Eliminar este perfil', profile_delete_title: 'Eliminar este perfil',
profile_default_label: '(predeterminado)',
profile_name_placeholder: 'Nombre del perfil (minúsculas, a-z, 0-9, guiones)',
profile_clone_label: 'Clonar configuración del perfil activo',
profile_base_url_placeholder: 'URL base (opcional, p. ej. http://localhost:11434)',
profile_api_key_placeholder: 'Clave API (opcional)',
manage_profiles: 'Manage profiles', manage_profiles: 'Manage profiles',
profiles_load_failed: 'Failed to load profiles', profiles_load_failed: 'Failed to load profiles',
profiles_busy_switch: 'Cannot switch profiles while agent is running', profiles_busy_switch: 'Cannot switch profiles while agent is running',
@@ -1508,6 +1978,11 @@ const LOCALES = {
profile_use: '使用', profile_use: '使用',
profile_switch_title: '切换到此配置档', profile_switch_title: '切换到此配置档',
profile_delete_title: '删除此配置档', profile_delete_title: '删除此配置档',
profile_default_label: '(默认)',
profile_name_placeholder: '配置档名称小写字母、a-z、0-9、连字符',
profile_clone_label: '复制当前配置档的配置',
profile_base_url_placeholder: 'Base URL可选例如 http://localhost:11434',
profile_api_key_placeholder: 'API 密钥(可选)',
manage_profiles: '管理配置档', manage_profiles: '管理配置档',
profiles_load_failed: '加载配置档失败', profiles_load_failed: '加载配置档失败',
profiles_busy_switch: 'Agent 运行中,无法切换配置档', profiles_busy_switch: 'Agent 运行中,无法切换配置档',

View File

@@ -127,19 +127,19 @@
</div> </div>
<!-- Profile create form (hidden by default) --> <!-- Profile create form (hidden by default) -->
<div id="profileCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0"> <div id="profileCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
<input id="profileFormName" placeholder="Profile name (lowercase, a-z 0-9 hyphens)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box"> <input id="profileFormName" data-i18n-placeholder="profile_name_placeholder" placeholder="Profile name (lowercase, a-z 0-9 hyphens)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted);margin-bottom:8px;cursor:pointer"> <label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted);margin-bottom:8px;cursor:pointer">
<input type="checkbox" id="profileFormClone" style="accent-color:var(--accent)"> Clone config from active profile <input type="checkbox" id="profileFormClone" style="accent-color:var(--accent)"> <span data-i18n="profile_clone_label">Clone config from active profile</span>
</label> </label>
<input id="profileFormBaseUrl" placeholder="Base URL (optional, e.g. http://localhost:11434)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box"> <input id="profileFormBaseUrl" data-i18n-placeholder="profile_base_url_placeholder" placeholder="Base URL (optional, e.g. http://localhost:11434)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<input id="profileFormApiKey" type="password" placeholder="API key (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box"> <input id="profileFormApiKey" type="password" data-i18n-placeholder="profile_api_key_placeholder" placeholder="API key (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitProfileCreate()">Create</button> <button class="cron-btn run" style="flex:1" onclick="submitProfileCreate()" data-i18n="create">Create</button>
<button class="cron-btn" style="flex:1" onclick="toggleProfileForm()">Cancel</button> <button class="cron-btn" style="flex:1" onclick="toggleProfileForm()" data-i18n="cancel">Cancel</button>
</div> </div>
<div id="profileFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div> <div id="profileFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div> </div>
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="profilesPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div> <div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="profilesPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div> </div>
<div class="sidebar-bottom"> <div class="sidebar-bottom">
<button class="hermes-launch-btn" id="btnHermesPanel" onclick="toggleSettings()" title="Open Hermes control center"> <button class="hermes-launch-btn" id="btnHermesPanel" onclick="toggleSettings()" title="Open Hermes control center">

View File

@@ -819,10 +819,11 @@ async function loadProfilesPanel() {
: `<span class="profile-opt-badge stopped" title="${esc(t('profile_gateway_stopped'))}"></span>`; : `<span class="profile-opt-badge stopped" title="${esc(t('profile_gateway_stopped'))}"></span>`;
const isActive = p.name === data.active; const isActive = p.name === data.active;
const activeBadge = isActive ? `<span style="color:var(--link);font-size:10px;font-weight:600;margin-left:6px">${esc(t('profile_active'))}</span>` : ''; const activeBadge = isActive ? `<span style="color:var(--link);font-size:10px;font-weight:600;margin-left:6px">${esc(t('profile_active'))}</span>` : '';
const defaultBadge = p.is_default ? ` <span style="opacity:.5">${esc(t('profile_default_label'))}</span>` : '';
card.innerHTML = ` card.innerHTML = `
<div class="profile-card-header"> <div class="profile-card-header">
<div style="min-width:0;flex:1"> <div style="min-width:0;flex:1">
<div class="profile-card-name${isActive ? ' is-active' : ''}">${gwDot}${esc(p.name)}${p.is_default ? ' <span style="opacity:.5">(default)</span>' : ''}${activeBadge}</div> <div class="profile-card-name${isActive ? ' is-active' : ''}">${gwDot}${esc(p.name)}${defaultBadge}${activeBadge}</div>
${meta.length ? `<div class="profile-card-meta">${esc(meta.join(' \u00b7 '))}</div>` : `<div class="profile-card-meta">${esc(t('profile_no_configuration'))}</div>`} ${meta.length ? `<div class="profile-card-meta">${esc(meta.join(' \u00b7 '))}</div>` : `<div class="profile-card-meta">${esc(t('profile_no_configuration'))}</div>`}
</div> </div>
<div class="profile-card-actions"> <div class="profile-card-actions">
@@ -833,7 +834,7 @@ async function loadProfilesPanel() {
panel.appendChild(card); panel.appendChild(card);
} }
} catch (e) { } catch (e) {
panel.innerHTML = `<div style="color:var(--accent);font-size:12px;padding:12px">Error: ${esc(e.message)}</div>`; panel.innerHTML = `<div style="color:var(--accent);font-size:12px;padding:12px">${esc(t('error_prefix'))}${esc(e.message)}</div>`;
} }
} }
@@ -851,7 +852,8 @@ function renderProfileDropdown(data) {
if (p.skill_count) meta.push(t('profile_skill_count', p.skill_count)); if (p.skill_count) meta.push(t('profile_skill_count', p.skill_count));
const gwDot = `<span class="profile-opt-badge ${p.gateway_running ? 'running' : 'stopped'}"></span>`; const gwDot = `<span class="profile-opt-badge ${p.gateway_running ? 'running' : 'stopped'}"></span>`;
const checkmark = p.name === active ? ' <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--link)" stroke-width="3" style="vertical-align:-1px"><polyline points="20 6 9 17 4 12"/></svg>' : ''; const checkmark = p.name === active ? ' <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--link)" stroke-width="3" style="vertical-align:-1px"><polyline points="20 6 9 17 4 12"/></svg>' : '';
opt.innerHTML = `<div class="profile-opt-name">${gwDot}${esc(p.name)}${p.is_default ? ' <span style="opacity:.5;font-weight:400">(default)</span>' : ''}${checkmark}</div>` + const defaultBadge = p.is_default ? ` <span style="opacity:.5;font-weight:400">${esc(t('profile_default_label'))}</span>` : '';
opt.innerHTML = `<div class="profile-opt-name">${gwDot}${esc(p.name)}${defaultBadge}${checkmark}</div>` +
(meta.length ? `<div class="profile-opt-meta">${esc(meta.join(' \u00b7 '))}</div>` : ''); (meta.length ? `<div class="profile-opt-meta">${esc(meta.join(' \u00b7 '))}</div>` : '');
opt.onclick = async () => { opt.onclick = async () => {
closeProfileDropdown(); closeProfileDropdown();

View File

@@ -212,7 +212,7 @@ function _openSessionActionMenu(session, anchorEl){
menu.className='session-action-menu open'; menu.className='session-action-menu open';
menu.appendChild(_buildSessionAction( menu.appendChild(_buildSessionAction(
session.pinned?'Unpin conversation':'Pin conversation', session.pinned?'Unpin conversation':'Pin conversation',
session.pinned?'Remove from the pinned section':'Keep this conversation at the top', session.pinned?'Remove from pinned':'Keep this conversation at the top',
session.pinned?ICONS.pin:ICONS.unpin, session.pinned?ICONS.pin:ICONS.unpin,
async()=>{ async()=>{
closeSessionActionMenu(); closeSessionActionMenu();
@@ -228,7 +228,7 @@ function _openSessionActionMenu(session, anchorEl){
)); ));
menu.appendChild(_buildSessionAction( menu.appendChild(_buildSessionAction(
'Move to project', 'Move to project',
session.project_id?'Change which project this conversation belongs to':'Assign this conversation to a project', session.project_id?'Change the project for this conversation':'Assign a project to this conversation',
ICONS.folder, ICONS.folder,
async()=>{ async()=>{
closeSessionActionMenu(); closeSessionActionMenu();

View File

@@ -66,3 +66,21 @@ def test_login_page_uses_traditional_chinese_for_zh_hant():
restored, restore_status = post("/api/settings", {"language": prev_lang}) restored, restore_status = post("/api/settings", {"language": prev_lang})
assert restore_status == 200 assert restore_status == 200
assert restored.get("language") == prev_lang assert restored.get("language") == prev_lang
def test_login_page_uses_russian_for_ru():
prev_lang = _current_language()
try:
saved, status = post("/api/settings", {"language": "ru"})
assert status == 200
assert saved.get("language") == "ru"
html, status2 = get_raw("/login")
assert status2 == 200
assert 'lang="ru-RU"' in html
assert "\u0412\u043e\u0439\u0442\u0438" in html
assert "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c, \u0447\u0442\u043e\u0431\u044b \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c" in html
assert "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c" in html
finally:
restored, restore_status = post("/api/settings", {"language": prev_lang})
assert restore_status == 200
assert restored.get("language") == prev_lang

View File

@@ -6,7 +6,7 @@ Covers:
2. static/ui.js: _checkProviderMismatch() helper exists and logic is correct 2. static/ui.js: _checkProviderMismatch() helper exists and logic is correct
3. static/messages.js: apperror handler has auth_mismatch branch 3. static/messages.js: apperror handler has auth_mismatch branch
4. static/i18n.js: provider_mismatch_warning and provider_mismatch_label keys 4. static/i18n.js: provider_mismatch_warning and provider_mismatch_label keys
present in all 5 locales (en, es, de, zh, zh-Hant) present in all locales (en, es, de, ru, zh, zh-Hant)
5. static/boot.js: modelSelect.onchange calls _checkProviderMismatch 5. static/boot.js: modelSelect.onchange calls _checkProviderMismatch
6. /api/models: response includes active_provider field 6. /api/models: response includes active_provider field
""" """
@@ -165,31 +165,43 @@ class TestApperrorHandler:
) )
# ── 4. static/i18n.js: all 5 locales ──────────────────────────────────────── # ── 4. static/i18n.js: all locales ───────────────────────────────────────────
class TestI18nProviderMismatch: class TestI18nProviderMismatch:
"""All 5 locales must have provider_mismatch_warning and provider_mismatch_label.""" """All locales must have provider_mismatch_warning and provider_mismatch_label."""
REQUIRED_KEYS = ["provider_mismatch_warning", "provider_mismatch_label"] REQUIRED_KEYS = ["provider_mismatch_warning", "provider_mismatch_label"]
def _locale_names(self, src: str) -> list[str]:
pattern = re.compile(
r"^\s{2}(?:'(?P<quoted>[A-Za-z0-9-]+)'|(?P<plain>[A-Za-z0-9-]+))\s*:\s*\{",
re.MULTILINE,
)
names = []
for match in pattern.finditer(src):
names.append(match.group("quoted") or match.group("plain"))
return names
def _count_key(self, src: str, key: str) -> int: def _count_key(self, src: str, key: str) -> int:
return len(re.findall(r'\b' + re.escape(key) + r'\b', src)) return len(re.findall(r'\b' + re.escape(key) + r'\b', src))
def test_all_locales_have_warning_key(self): def test_all_locales_have_warning_key(self):
"""provider_mismatch_warning must appear in all 5 locales.""" """provider_mismatch_warning must appear in all locales."""
src = _read("static/i18n.js") src = _read("static/i18n.js")
locale_count = len(self._locale_names(src))
count = self._count_key(src, "provider_mismatch_warning") count = self._count_key(src, "provider_mismatch_warning")
assert count >= 5, ( assert count >= locale_count, (
f"provider_mismatch_warning found {count} times, expected >= 5 " f"provider_mismatch_warning found {count} times, expected >= {locale_count} "
f"(one per locale: en, es, de, zh, zh-Hant)" f"(one per locale)"
) )
def test_all_locales_have_label_key(self): def test_all_locales_have_label_key(self):
"""provider_mismatch_label must appear in all 5 locales.""" """provider_mismatch_label must appear in all locales."""
src = _read("static/i18n.js") src = _read("static/i18n.js")
locale_count = len(self._locale_names(src))
count = self._count_key(src, "provider_mismatch_label") count = self._count_key(src, "provider_mismatch_label")
assert count >= 5, ( assert count >= locale_count, (
f"provider_mismatch_label found {count} times, expected >= 5" f"provider_mismatch_label found {count} times, expected >= {locale_count}"
) )
def test_warning_is_function_in_en(self): def test_warning_is_function_in_en(self):

View File

@@ -0,0 +1,116 @@
from collections import Counter
from pathlib import Path
import re
REPO = Path(__file__).resolve().parent.parent
def read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def test_russian_locale_block_exists():
src = read(REPO / "static" / "i18n.js")
assert "\n ru: {" in src
assert "_label: 'Русский'" in src
assert "_speech: 'ru-RU'" in src
def extract_locale_block(src: str, locale_key: str) -> str:
start_match = re.search(rf"\b{re.escape(locale_key)}\s*:\s*\{{", src)
assert start_match, f"{locale_key} locale block not found"
start = start_match.end() - 1
depth = 0
in_single = False
in_double = False
in_backtick = False
escape = False
for i in range(start, len(src)):
ch = src[i]
if escape:
escape = False
continue
if in_single:
if ch == "\\":
escape = True
elif ch == "'":
in_single = False
continue
if in_double:
if ch == "\\":
escape = True
elif ch == '"':
in_double = False
continue
if in_backtick:
if ch == "\\":
escape = True
elif ch == "`":
in_backtick = False
continue
if ch == "'":
in_single = True
continue
if ch == '"':
in_double = True
continue
if ch == "`":
in_backtick = True
continue
if ch == "{":
depth += 1
continue
if ch == "}":
depth -= 1
if depth == 0:
return src[start + 1 : i]
raise AssertionError(f"{locale_key} locale block braces are not balanced")
def test_russian_locale_includes_representative_translations():
src = read(REPO / "static" / "i18n.js")
expected = [
"settings_title: '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438'",
"login_title: '\u0412\u0445\u043e\u0434'",
"approval_heading: '\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u0435'",
"tab_tasks: '\u0417\u0430\u0434\u0430\u0447\u0438'",
"tab_profiles: '\u041f\u0440\u043e\u0444\u0438\u043b\u0438'",
"session_time_just_now: '\u0442\u043e\u043b\u044c\u043a\u043e \u0447\u0442\u043e'",
"onboarding_title: '\u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c \u0432 Hermes Web UI'",
"onboarding_complete: '\u041f\u0435\u0440\u0432\u0438\u0447\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0430'",
"profile_default_label: '\u0028\u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e\u0029'",
"profile_name_placeholder: '\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0444\u0438\u043b\u044f \u0028\u0441\u0442\u0440\u043e\u0447\u043d\u044b\u0435 \u0431\u0443\u043a\u0432\u044b, a-z, 0-9, \u0434\u0435\u0444\u0438\u0441\u044b\u0029'",
"profile_clone_label: '\u0421\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e \u0438\u0437 \u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0433\u043e \u043f\u0440\u043e\u0444\u0438\u043b\u044f'",
"profile_base_url_placeholder: '\u0411\u0430\u0437\u043e\u0432\u044b\u0439 URL \u0028\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e, \u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440 http://localhost:11434\u0029'",
"profile_api_key_placeholder: 'API-\u043a\u043b\u044e\u0447 \u0028\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0029'",
]
for entry in expected:
assert entry in src
def test_russian_locale_covers_english_keys():
src = read(REPO / "static" / "i18n.js")
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
en_keys = set(key_pattern.findall(extract_locale_block(src, "en")))
ru_keys = set(key_pattern.findall(extract_locale_block(src, "ru")))
missing = sorted(en_keys - ru_keys)
assert not missing, f"Russian locale missing keys: {missing}"
def test_russian_locale_has_no_duplicate_keys():
src = read(REPO / "static" / "i18n.js")
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
keys = key_pattern.findall(extract_locale_block(src, "ru"))
duplicates = sorted(k for k, count in Counter(keys).items() if count > 1)
assert not duplicates, f"Russian locale has duplicate keys: {duplicates}"

View File

@@ -30,6 +30,14 @@ def read(path):
return (REPO / path).read_text(encoding="utf-8") return (REPO / path).read_text(encoding="utf-8")
def _locale_count(src: str) -> int:
pattern = re.compile(
r"^\s{2}(?:'(?P<quoted>[A-Za-z0-9-]+)'|(?P<plain>[A-Za-z0-9-]+))\s*:\s*\{",
re.MULTILINE,
)
return sum(1 for _ in pattern.finditer(src))
# ── 14. cancelStream() cleanup is unconditional ───────────────────────────── # ── 14. cancelStream() cleanup is unconditional ─────────────────────────────
class TestCancelStreamCleanup: class TestCancelStreamCleanup:
@@ -165,9 +173,10 @@ def test_sse_cancel_handler_calls_set_busy():
def test_cancel_failed_i18n_key_exists_in_all_locales(): def test_cancel_failed_i18n_key_exists_in_all_locales():
"""cancel_failed key must still exist in i18n.js for all locales.""" """cancel_failed key must still exist in i18n.js for all locales."""
src = read("static/i18n.js") src = read("static/i18n.js")
# Should appear once per locale (en, es, de, zh-Hans, zh-Hant) # Should appear once per locale (en, es, de, ru, zh, zh-Hant)
locale_count = _locale_count(src)
count = src.count("cancel_failed:") count = src.count("cancel_failed:")
assert count >= 5, ( assert count >= locale_count, (
f"cancel_failed key only found {count} times in i18n.js — " f"cancel_failed key only found {count} times in i18n.js — "
"expected at least 5 (one per locale)" f"expected at least {locale_count} (one per locale)"
) )