feat(i18n): add Spanish locale for WebUI (#275)

* 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 <gabogabucho@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
nesquena-hermes
2026-04-11 20:06:37 -07:00
committed by GitHub
parent 068836cf6b
commit eca6f5efbd
2 changed files with 234 additions and 0 deletions

View File

@@ -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 <name>',
no_model_match: 'No hay ningún modelo que coincida con "',
switched_to: 'Se cambió a ',
workspace_usage: 'Uso: /workspace <name>',
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 <name>` 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',

View File

@@ -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}"