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:
189
static/i18n.js
189
static/i18n.js
@@ -193,6 +193,195 @@ const LOCALES = {
|
|||||||
suggest_plan: 'Help me plan a small project.',
|
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: {
|
de: {
|
||||||
_lang: 'de',
|
_lang: 'de',
|
||||||
_label: 'Deutsch',
|
_label: 'Deutsch',
|
||||||
|
|||||||
45
tests/test_spanish_locale.py
Normal file
45
tests/test_spanish_locale.py
Normal 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}"
|
||||||
Reference in New Issue
Block a user