From eca6f5efbdc717a5fea64a902b6a11f523c19a03 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 11 Apr 2026 20:06:37 -0700 Subject: [PATCH] feat(i18n): add Spanish locale for WebUI (#275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(i18n): add Spanish locale for WebUI * fix(i18n): translate tab_skills to Habilidades in Spanish locale tab_skills was left as 'Skills' (English) in the es block — the only sidebar tab that wasn't translated. Changed to 'Habilidades', the correct Spanish term for Skills. Also added tab_skills and tab_memory to the representative translation assertions in test_spanish_locale.py to lock this in for future changes. --------- Co-authored-by: gabogabucho Co-authored-by: Nathan Esquenazi --- static/i18n.js | 189 +++++++++++++++++++++++++++++++++++ tests/test_spanish_locale.py | 45 +++++++++ 2 files changed, 234 insertions(+) create mode 100644 tests/test_spanish_locale.py diff --git a/static/i18n.js b/static/i18n.js index a7bc0d1..847ed74 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -193,6 +193,195 @@ const LOCALES = { suggest_plan: 'Help me plan a small project.', }, + es: { + _lang: 'es', + _label: 'Español', + _speech: 'es-ES', + // boot.js + cancelling: 'Cancelando…', + cancel_failed: 'Error al cancelar: ', + mic_denied: 'Acceso al micrófono denegado. Revisa los permisos del navegador.', + mic_no_speech: 'No se detectó voz. Inténtalo de nuevo.', + mic_network: 'El reconocimiento de voz no está disponible.', + mic_error: 'Error de entrada por voz: ', + session_imported: 'Sesión importada', + import_failed: 'Error al importar: ', + import_invalid_json: 'JSON inválido', + image_pasted: 'Imagen pegada: ', + // messages.js + edit_message: 'Editar mensaje', + regenerate: 'Regenerar respuesta', + copy: 'Copiar', + copied: '¡Copiado!', + you: 'Tú', + thinking: 'Pensando', + expand_all: 'Expandir todo', + collapse_all: 'Contraer todo', + edit_failed: 'Error al editar: ', + regen_failed: 'Error al regenerar: ', + reconnect_active: 'Todavía se está generando una respuesta. ¿Recargar cuando termine?', + reconnect_finished: 'Había una respuesta en curso cuando te fuiste. Puede que los mensajes se hayan actualizado.', + // approval card + approval_heading: 'Se requiere aprobación', + approval_desc_prefix: 'Se detectó un comando peligroso', + approval_btn_once: 'Permitir una vez', + approval_btn_once_title: 'Permitir solo este comando (Enter)', + approval_btn_session: 'Permitir en la sesión', + approval_btn_session_title: 'Permitir durante esta sesión de conversación', + approval_btn_always: 'Permitir siempre', + approval_btn_always_title: 'Permitir siempre este patrón de comando', + approval_btn_deny: 'Denegar', + approval_btn_deny_title: 'Denegar — no ejecutar este comando', + approval_responding: 'Respondiendo…', + untitled: 'Sin título', + n_messages: (n) => `${n} mensajes`, + model_unavailable: ' (no disponible)', + model_unavailable_title: 'Este modelo ya no está en tu lista actual de proveedores', + // commands.js + cmd_help: 'Listar los comandos disponibles', + cmd_clear: 'Borrar los mensajes de la conversación', + cmd_compact: 'Comprimir el contexto de la conversación', + cmd_model: 'Cambiar de modelo (p. ej. /model gpt-4o)', + cmd_workspace: 'Cambiar de espacio de trabajo por nombre', + cmd_new: 'Iniciar una nueva sesión de chat', + cmd_usage: 'Activar o desactivar el uso de tokens', + cmd_theme: 'Cambiar tema (dark/light/slate/solarized/monokai/nord/oled)', + cmd_personality: 'Cambiar la personalidad del agente', + cmd_skills: 'Listar las skills de Hermes disponibles', + available_commands: 'Comandos disponibles:', + type_slash: 'Escribe / para ver los comandos', + conversation_cleared: 'Conversación borrada', + model_usage: 'Uso: /model ', + no_model_match: 'No hay ningún modelo que coincida con "', + switched_to: 'Se cambió a ', + workspace_usage: 'Uso: /workspace ', + no_workspace_match: 'No hay ningún espacio de trabajo que coincida con "', + switched_workspace: 'Se cambió al espacio de trabajo: ', + workspace_switch_failed: 'Error al cambiar de espacio de trabajo: ', + new_session: 'Nueva sesión creada', + compressing: 'Solicitando compresión del contexto...', + token_usage_on: 'Uso de tokens activado', + token_usage_off: 'Uso de tokens desactivado', + theme_usage: 'Uso: /theme ', + theme_set: 'Tema: ', + no_active_session: 'No hay ninguna sesión activa', + no_personalities: 'No se encontraron personalidades (añádelas a ~/.hermes/personalities/)', + available_personalities: 'Personalidades disponibles:', + personality_switch_hint: '\n\nUsa `/personality ` para cambiar, o `/personality none` para limpiar.', + personalities_load_failed: 'No se pudieron cargar las personalidades', + personality_cleared: 'Personalidad borrada', + personality_set: 'Personalidad: ', + failed_colon: 'Error: ', + // ui.js + no_workspace: 'Sin espacio de trabajo', + // workspace.js + unsaved_confirm: 'Tienes cambios sin guardar en la vista previa. ¿Descartar y navegar?', + save: 'Guardar', + edit: 'Editar', + save_title: 'Guardar cambios', + edit_title: 'Editar este archivo', + saved: 'Guardado', + save_failed: 'Error al guardar: ', + image_load_failed: 'No se pudo cargar la imagen', + file_open_failed: 'No se pudo abrir el archivo', + downloading: (name) => `Descargando ${name}…`, + double_click_rename: 'Haz doble clic para renombrar', + renamed_to: 'Renombrado a ', + rename_failed: 'Error al renombrar: ', + delete_title: 'Eliminar', + delete_confirm: (name) => `¿Eliminar ${name}?`, + deleted: 'Eliminado ', + delete_failed: 'Error al eliminar: ', + new_file_prompt: 'Nombre del archivo nuevo (p. ej. notes.md):', + created: 'Creado ', + create_failed: 'Error al crear: ', + new_folder_prompt: 'Nombre de la carpeta nueva:', + folder_created: 'Carpeta creada ', + folder_create_failed: 'Error al crear la carpeta: ', + remove_title: 'Quitar', + empty_dir: '(vacío)', + upload_failed: 'Error al subir: ', + all_uploads_failed: (n) => `Fallaron las ${n} subida(s)`, + // settings panel + settings_title: 'Configuración', + settings_save_btn: 'Guardar configuración', + settings_label_model: 'Modelo predeterminado', + settings_label_send_key: 'Tecla de envío', + settings_label_theme: 'Tema', + settings_label_language: 'Idioma', + settings_label_token_usage: 'Mostrar uso de tokens', + settings_label_cli_sessions: 'Mostrar sesiones de CLI', + settings_label_sync_insights: 'Sincronizar con insights', + settings_label_check_updates: 'Buscar actualizaciones', + settings_label_bot_name: 'Nombre del asistente', + settings_label_password: 'Contraseña de acceso', + settings_saved: 'Configuración guardada', + settings_save_failed: 'Error al guardar: ', + settings_load_failed: 'Error al cargar la configuración: ', + settings_saved_pw: 'Configuración guardada (contraseña establecida — ahora se requiere iniciar sesión)', + // login page (used server-side via /api/i18n/login endpoint) + login_title: 'Iniciar sesión', + login_subtitle: 'Introduce tu contraseña para continuar', + login_placeholder: 'Contraseña', + login_btn: 'Entrar', + login_invalid_pw: 'Contraseña inválida', + login_conn_failed: 'Error de conexión', + dialog_confirm_title: 'Confirmar acción', + dialog_prompt_title: 'Introduce un valor', + dialog_confirm_btn: 'Confirmar', + discard: 'Descartar', + clear: 'Borrar', + create: 'Crear', + remove: 'Quitar', + project_name_prompt: 'Nombre del proyecto:', + // Sidebar & Tabs + tab_chat: 'Chat', + tab_tasks: 'Tareas', + tab_skills: 'Habilidades', + tab_memory: 'Memoria', + tab_workspaces: 'Espacios', + tab_profiles: 'Perfiles', + tab_todos: 'Todos', + new_conversation: 'Nueva conversación', + filter_conversations: 'Filtrar conversaciones...', + scheduled_jobs: 'Tareas programadas', + new_job: 'Nueva tarea', + loading: 'Cargando...', + search_skills: 'Buscar skills...', + new_skill: 'Nueva skill', + personal_memory: 'Memoria personal', + current_task_list: 'Lista de tareas actual', + workspace_desc: 'Añade y cambia espacios de trabajo para tus sesiones.', + new_profile: 'Nuevo perfil', + transcript: 'Transcripción', + download_transcript: 'Descargar como Markdown', + import: 'Importar', + // Settings detail + settings_label_sound: 'Sonido de notificación', + settings_desc_sound: 'Reproduce un sonido cuando el asistente termina una respuesta.', + settings_label_notifications: 'Notificaciones del navegador', + settings_desc_notifications: 'Muestra una notificación del sistema cuando una respuesta termina mientras la pestaña está en segundo plano.', + settings_desc_token_usage: 'Muestra el conteo de tokens de entrada/salida debajo de cada respuesta del asistente. También se puede alternar con /usage.', + settings_desc_cli_sessions: 'Fusiona las sesiones del CLI de Hermes (state.db) en la lista de sesiones. Haz clic en una sesión de CLI para importarla y continuar la conversación.', + settings_desc_sync_insights: 'Refleja el uso de tokens de la WebUI en state.db para que hermes /insights incluya datos de sesiones del navegador. Desactivado por defecto.', + settings_desc_check_updates: 'Muestra un banner cuando haya versiones más nuevas de la WebUI o del Agent. Ejecuta periódicamente un git fetch en segundo plano.', + settings_desc_bot_name: 'Nombre visible del asistente en toda la UI. Por defecto es Hermes.', + settings_desc_password: 'Introduce una nueva contraseña para establecerla o cambiarla. Déjalo en blanco para mantener la configuración actual.', + password_placeholder: 'Introduce una contraseña nueva…', + disable_auth: 'Desactivar autenticación', + sign_out: 'Cerrar sesión', + cancel: 'Cancelar', + create_job: 'Crear tarea', + save_skill: 'Guardar skill', + editing: 'Editando', + // Empty state + empty_title: '¿En qué puedo ayudarte?', + empty_subtitle: 'Pregunta lo que quieras, ejecuta comandos, explora archivos o gestiona tus tareas programadas.', + suggest_files: '¿Qué archivos hay en este espacio de trabajo?', + suggest_schedule: '¿Qué tengo hoy en mi agenda?', + suggest_plan: 'Ayúdame a planificar un proyecto pequeño.', + }, + de: { _lang: 'de', _label: 'Deutsch', diff --git a/tests/test_spanish_locale.py b/tests/test_spanish_locale.py new file mode 100644 index 0000000..9d11436 --- /dev/null +++ b/tests/test_spanish_locale.py @@ -0,0 +1,45 @@ +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_spanish_locale_block_exists(): + src = read(REPO / "static" / "i18n.js") + assert "\n es: {" in src + assert "_label: 'Español'" in src + assert "_speech: 'es-ES'" in src + + +def test_spanish_locale_includes_representative_translations(): + src = read(REPO / "static" / "i18n.js") + expected = [ + "settings_title: 'Configuración'", + "login_title: 'Iniciar sesión'", + "approval_heading: 'Se requiere aprobación'", + "tab_tasks: 'Tareas'", + "tab_skills: 'Habilidades'", + "tab_memory: 'Memoria'", + ] + for entry in expected: + assert entry in src + + +def test_spanish_locale_covers_english_keys(): + src = read(REPO / "static" / "i18n.js") + en_match = re.search(r"\n en: \{([\s\S]*?)\n \},\n\n es: \{", src) + es_match = re.search(r"\n es: \{([\s\S]*?)\n \},\n\n de: \{", src) + assert en_match, "English locale block not found" + assert es_match, "Spanish locale block not found" + + key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE) + en_keys = set(key_pattern.findall(en_match.group(1))) + es_keys = set(key_pattern.findall(es_match.group(1))) + + missing = sorted(en_keys - es_keys) + assert not missing, f"Spanish locale missing keys: {missing}"