From c4efe967255826220cf6acb4643574fb9416bac8 Mon Sep 17 00:00:00 2001 From: vansour Date: Tue, 14 Apr 2026 17:14:01 +0000 Subject: [PATCH] feat(i18n): complete zh-CN hardening and locale consistency --- api/routes.py | 63 +++- static/boot.js | 2 +- static/i18n.js | 493 ++++++++++++++++++++++++++++-- static/panels.js | 263 ++++++++-------- tests/test_chinese_locale.py | 65 ++++ tests/test_language_precedence.py | 88 ++++++ tests/test_login_locale.py | 64 ++++ 7 files changed, 888 insertions(+), 150 deletions(-) create mode 100644 tests/test_chinese_locale.py create mode 100644 tests/test_language_precedence.py create mode 100644 tests/test_login_locale.py diff --git a/api/routes.py b/api/routes.py index 607e49d..f665242 100644 --- a/api/routes.py +++ b/api/routes.py @@ -228,6 +228,24 @@ _LOGIN_LOCALE = { "invalid_pw": "Invalid password", "conn_failed": "Connection failed", }, + "es": { + "lang": "es-ES", + "title": "Iniciar sesi\u00f3n", + "subtitle": "Introduce tu contrase\u00f1a para continuar", + "placeholder": "Contrase\u00f1a", + "btn": "Iniciar sesi\u00f3n", + "invalid_pw": "Contrase\u00f1a inv\u00e1lida", + "conn_failed": "Error de conexi\u00f3n", + }, + "de": { + "lang": "de-DE", + "title": "Anmelden", + "subtitle": "Geben Sie Ihr Passwort ein, um fortzufahren", + "placeholder": "Passwort", + "btn": "Anmelden", + "invalid_pw": "Ung\u00fcltiges Passwort", + "conn_failed": "Verbindung fehlgeschlagen", + }, "zh": { "lang": "zh-CN", "title": "\u767b\u5f55", @@ -237,8 +255,49 @@ _LOGIN_LOCALE = { "invalid_pw": "\u5bc6\u7801\u9519\u8bef", "conn_failed": "\u8fde\u63a5\u5931\u8d25", }, + "zh-Hant": { + "lang": "zh-TW", + "title": "\u767b\u5f55", + "subtitle": "\u8f38\u5165\u5bc6\u78bc\u7e7c\u7e8c\u4f7f\u7528", + "placeholder": "\u5bc6\u78bc", + "btn": "\u767b\u5f55", + "invalid_pw": "\u5bc6\u78bc\u932f\u8aa4", + "conn_failed": "\u9023\u63a5\u5931\u6557", + }, } + +def _resolve_login_locale_key(raw_lang: str | None) -> str: + """Resolve settings.language to a known _LOGIN_LOCALE key.""" + if not raw_lang: + return "en" + lang = str(raw_lang).strip() + if not lang: + return "en" + if lang in _LOGIN_LOCALE: + return lang + + normalized = lang.replace("_", "-") + lower = normalized.lower() + + # Case-insensitive direct key match first. + for key in _LOGIN_LOCALE: + if key.lower() == lower: + return key + + # Common Chinese aliases. + if lower == "zh" or lower.startswith("zh-cn") or lower.startswith("zh-sg") or lower.startswith("zh-hans"): + return "zh" + if lower.startswith("zh-tw") or lower.startswith("zh-hk") or lower.startswith("zh-mo") or lower.startswith("zh-hant"): + return "zh-Hant" if "zh-Hant" in _LOGIN_LOCALE else "zh" + + # Fallback to base language subtag (e.g. en-US -> en). + base = lower.split("-", 1)[0] + for key in _LOGIN_LOCALE: + if key.lower() == base: + return key + return "en" + # ── Login page (self-contained, no external deps) ──────────────────────────── _LOGIN_PAGE_HTML = """ @@ -294,7 +353,9 @@ def handle_get(handler, parsed) -> bool: _settings = load_settings() _bn = _html.escape(_settings.get("bot_name") or "Hermes") _lang = _settings.get("language", "en") - _login_strings = _LOGIN_LOCALE.get(_lang, _LOGIN_LOCALE["en"]) + _login_strings = _LOGIN_LOCALE[ + _resolve_login_locale_key(_lang) + ] _page = ( _LOGIN_PAGE_HTML.replace("{{BOT_NAME}}", _bn) .replace("{{BOT_NAME_INITIAL}}", _bn[0].upper()) diff --git a/static/boot.js b/static/boot.js index 2f6234c..3112edc 100644 --- a/static/boot.js +++ b/static/boot.js @@ -584,7 +584,7 @@ function applyBotName(){ (async()=>{ // Load send key preference let _bootSettings={}; - try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;window._soundEnabled=!!s.sound_enabled;window._notificationsEnabled=!!s.notifications_enabled;window._botName=s.bot_name||'Hermes';const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);document.body.classList.toggle('bubble-layout',!!s.bubble_layout);if(s.language&&typeof setLocale==='function'){setLocale(s.language);if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();}applyBotName();}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;window._soundEnabled=false;window._notificationsEnabled=false;window._botName='Hermes';_bootSettings={check_for_updates:false};document.body.classList.remove('bubble-layout');} + try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;window._soundEnabled=!!s.sound_enabled;window._notificationsEnabled=!!s.notifications_enabled;window._botName=s.bot_name||'Hermes';const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);document.body.classList.toggle('bubble-layout',!!s.bubble_layout);if(typeof setLocale==='function'){const _lang=typeof resolvePreferredLocale==='function'?resolvePreferredLocale(s.language,localStorage.getItem('hermes-lang')):(s.language||localStorage.getItem('hermes-lang')||'en');setLocale(_lang);if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();}applyBotName();}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;window._soundEnabled=false;window._notificationsEnabled=false;window._botName='Hermes';_bootSettings={check_for_updates:false};document.body.classList.remove('bubble-layout');} // Non-blocking update check (fire-and-forget, once per tab session) // ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards) const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1'; diff --git a/static/i18n.js b/static/i18n.js index a04ad44..36f1d4e 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -290,6 +290,118 @@ const LOCALES = { onboarding_error_workspace_required: 'Workspace is required.', onboarding_error_model_required: 'Model is required.', onboarding_complete: 'Onboarding complete', + // panel/runtime i18n + error_prefix: 'Error: ', + not_available: 'N/A', + never: 'never', + add: 'Add', + add_failed: 'Add failed: ', + remove_failed: 'Remove failed: ', + switch_failed: 'Switch failed: ', + name_required: 'Name is required', + content_required: 'Content is required', + view: 'View', + dismiss: 'Dismiss', + disable: 'Disable', + cron_no_jobs: 'No scheduled jobs found.', + cron_status_off: 'off', + cron_status_paused: 'paused', + cron_status_error: 'error', + cron_status_active: 'active', + cron_next: 'Next', + cron_last: 'Last', + cron_run_now: 'Run now', + cron_pause: 'Pause', + cron_resume: 'Resume', + cron_job_name_placeholder: 'Job name', + cron_schedule_placeholder: 'Schedule', + cron_prompt_placeholder: 'Prompt', + cron_last_output: 'Last output', + cron_all_runs: 'All runs', + cron_hide_runs: 'Hide runs', + cron_no_runs_yet: '(no runs yet)', + cron_schedule_required_example: 'Schedule is required (e.g. "0 9 * * *" or "every 1h")', + cron_schedule_required: 'Schedule is required', + cron_prompt_required: 'Prompt is required', + cron_job_created: 'Job created', + cron_job_triggered: 'Job triggered', + cron_job_paused: 'Job paused', + cron_job_resumed: 'Job resumed', + cron_job_updated: 'Job updated', + cron_delete_confirm_title: 'Delete cron job', + cron_delete_confirm_message: 'This cannot be undone.', + cron_job_deleted: 'Job deleted', + cron_completion_status: (name, status) => `Cron "${name}" ${status}`, + status_failed: 'failed', + status_completed: 'completed', + todos_no_active: 'No active task list in this session.', + clear_conversation_title: 'Clear conversation', + clear_conversation_message: 'Clear all messages? This cannot be undone.', + clear_failed: 'Clear failed: ', + skills_no_match: 'No skills match.', + linked_files: 'Linked Files', + skill_load_failed: 'Could not load skill: ', + skill_file_load_failed: 'Could not load file: ', + skill_name_required: 'Skill name is required', + skill_updated: 'Skill updated', + skill_created: 'Skill created', + memory_notes_label: 'memory (notes)', + memory_saved: 'Memory saved', + my_notes: 'My Notes', + user_profile: 'User Profile', + no_notes_yet: 'No notes yet.', + no_profile_yet: 'No profile yet.', + workspace_choose_path: 'Choose workspace path', + workspace_choose_path_meta: 'Add a validated path and switch this conversation', + workspace_manage: 'Manage workspaces', + workspace_manage_meta: 'Open the Spaces panel', + workspace_use_title: 'Use in current session', + workspace_use: 'Use', + workspace_add_path_placeholder: 'Add workspace path (e.g. /home/user/my-project)', + workspace_paths_validated_hint: 'Paths are validated as existing directories before saving.', + workspace_added: 'Workspace added', + workspace_remove_confirm_title: 'Remove workspace', + workspace_remove_confirm_message: (path) => `Remove "${path}"?`, + workspace_removed: 'Workspace removed', + workspace_switch_prompt_title: 'Switch workspace', + workspace_switch_prompt_message: 'Enter an absolute workspace path to add and switch this conversation to.', + workspace_switch_prompt_confirm: 'Switch', + workspace_switch_prompt_placeholder: '/Users/you/project', + workspace_not_added: 'Workspace was not added', + workspace_already_saved: 'Workspace already saved — choose it from the list', + workspace_busy_switch: 'Cannot switch workspace while agent is running', + discard_file_edits_title: 'Discard file edits?', + discard_file_edits_message: 'Switching workspaces will discard unsaved file edits in the preview.', + workspace_switched_to: (name) => `Switched to ${name}`, + profiles_no_profiles: 'No profiles found.', + profile_api_keys_configured: 'API keys configured', + profile_gateway_running: 'Gateway running', + profile_gateway_stopped: 'Gateway stopped', + profile_active: 'ACTIVE', + profile_no_configuration: 'No configuration', + profile_use: 'Use', + profile_switch_title: 'Switch to this profile', + manage_profiles: 'Manage profiles', + profiles_load_failed: 'Failed to load profiles', + profiles_busy_switch: 'Cannot switch profiles while agent is running', + profile_switched_new_conversation: (name) => `Switched to profile: ${name} — new conversation started`, + profile_switched: (name) => `Switched to profile: ${name}`, + profile_name_rule: 'Lowercase letters, numbers, hyphens, underscores only', + profile_base_url_rule: 'Base URL must start with http:// or https://', + profile_created: (name) => `Profile created: ${name}`, + profile_delete_confirm_title: (name) => `Delete profile "${name}"?`, + profile_delete_confirm_message: 'This removes all config, skills, memory, and sessions for this profile.', + profile_deleted: (name) => `Profile deleted: ${name}`, + active_conversation_none: 'No active conversation selected.', + active_conversation_meta: (title, count) => `${title} · ${count} message${count === 1 ? '' : 's'}`, + settings_unsaved_changes: 'You have unsaved changes.', + sign_out_failed: 'Sign out failed: ', + disable_auth_confirm_title: 'Disable password protection', + disable_auth_confirm_message: 'Anyone will be able to access this instance.', + auth_disabled: 'Auth disabled — password protection removed', + disable_auth_failed: 'Failed to disable auth: ', + bg_error_single: (title) => `"${title}" has encountered an error`, + bg_error_multi: (count) => `${count} sessions have encountered an error`, }, es: { @@ -570,6 +682,118 @@ const LOCALES = { onboarding_error_workspace_required: 'El espacio de trabajo es obligatorio.', onboarding_error_model_required: 'El modelo es obligatorio.', onboarding_complete: 'Onboarding completado', + // panel/runtime i18n + error_prefix: 'Error: ', + not_available: 'N/A', + never: 'never', + add: 'Add', + add_failed: 'Add failed: ', + remove_failed: 'Remove failed: ', + switch_failed: 'Switch failed: ', + name_required: 'Name is required', + content_required: 'Content is required', + view: 'View', + dismiss: 'Dismiss', + disable: 'Disable', + cron_no_jobs: 'No scheduled jobs found.', + cron_status_off: 'off', + cron_status_paused: 'paused', + cron_status_error: 'error', + cron_status_active: 'active', + cron_next: 'Next', + cron_last: 'Last', + cron_run_now: 'Run now', + cron_pause: 'Pause', + cron_resume: 'Resume', + cron_job_name_placeholder: 'Job name', + cron_schedule_placeholder: 'Schedule', + cron_prompt_placeholder: 'Prompt', + cron_last_output: 'Last output', + cron_all_runs: 'All runs', + cron_hide_runs: 'Hide runs', + cron_no_runs_yet: '(no runs yet)', + cron_schedule_required_example: 'Schedule is required (e.g. "0 9 * * *" or "every 1h")', + cron_schedule_required: 'Schedule is required', + cron_prompt_required: 'Prompt is required', + cron_job_created: 'Job created', + cron_job_triggered: 'Job triggered', + cron_job_paused: 'Job paused', + cron_job_resumed: 'Job resumed', + cron_job_updated: 'Job updated', + cron_delete_confirm_title: 'Delete cron job', + cron_delete_confirm_message: 'This cannot be undone.', + cron_job_deleted: 'Job deleted', + cron_completion_status: (name, status) => `Cron "${name}" ${status}`, + status_failed: 'failed', + status_completed: 'completed', + todos_no_active: 'No active task list in this session.', + clear_conversation_title: 'Clear conversation', + clear_conversation_message: 'Clear all messages? This cannot be undone.', + clear_failed: 'Clear failed: ', + skills_no_match: 'No skills match.', + linked_files: 'Linked Files', + skill_load_failed: 'Could not load skill: ', + skill_file_load_failed: 'Could not load file: ', + skill_name_required: 'Skill name is required', + skill_updated: 'Skill updated', + skill_created: 'Skill created', + memory_notes_label: 'memory (notes)', + memory_saved: 'Memory saved', + my_notes: 'My Notes', + user_profile: 'User Profile', + no_notes_yet: 'No notes yet.', + no_profile_yet: 'No profile yet.', + workspace_choose_path: 'Choose workspace path', + workspace_choose_path_meta: 'Add a validated path and switch this conversation', + workspace_manage: 'Manage workspaces', + workspace_manage_meta: 'Open the Spaces panel', + workspace_use_title: 'Use in current session', + workspace_use: 'Use', + workspace_add_path_placeholder: 'Add workspace path (e.g. /home/user/my-project)', + workspace_paths_validated_hint: 'Paths are validated as existing directories before saving.', + workspace_added: 'Workspace added', + workspace_remove_confirm_title: 'Remove workspace', + workspace_remove_confirm_message: (path) => `Remove "${path}"?`, + workspace_removed: 'Workspace removed', + workspace_switch_prompt_title: 'Switch workspace', + workspace_switch_prompt_message: 'Enter an absolute workspace path to add and switch this conversation to.', + workspace_switch_prompt_confirm: 'Switch', + workspace_switch_prompt_placeholder: '/Users/you/project', + workspace_not_added: 'Workspace was not added', + workspace_already_saved: 'Workspace already saved — choose it from the list', + workspace_busy_switch: 'Cannot switch workspace while agent is running', + discard_file_edits_title: 'Discard file edits?', + discard_file_edits_message: 'Switching workspaces will discard unsaved file edits in the preview.', + workspace_switched_to: (name) => `Switched to ${name}`, + profiles_no_profiles: 'No profiles found.', + profile_api_keys_configured: 'API keys configured', + profile_gateway_running: 'Gateway running', + profile_gateway_stopped: 'Gateway stopped', + profile_active: 'ACTIVE', + profile_no_configuration: 'No configuration', + profile_use: 'Use', + profile_switch_title: 'Switch to this profile', + manage_profiles: 'Manage profiles', + profiles_load_failed: 'Failed to load profiles', + profiles_busy_switch: 'Cannot switch profiles while agent is running', + profile_switched_new_conversation: (name) => `Switched to profile: ${name} — new conversation started`, + profile_switched: (name) => `Switched to profile: ${name}`, + profile_name_rule: 'Lowercase letters, numbers, hyphens, underscores only', + profile_base_url_rule: 'Base URL must start with http:// or https://', + profile_created: (name) => `Profile created: ${name}`, + profile_delete_confirm_title: (name) => `Delete profile "${name}"?`, + profile_delete_confirm_message: 'This removes all config, skills, memory, and sessions for this profile.', + profile_deleted: (name) => `Profile deleted: ${name}`, + active_conversation_none: 'No active conversation selected.', + active_conversation_meta: (title, count) => `${title} · ${count} message${count === 1 ? '' : 's'}`, + settings_unsaved_changes: 'You have unsaved changes.', + sign_out_failed: 'Sign out failed: ', + disable_auth_confirm_title: 'Disable password protection', + disable_auth_confirm_message: 'Anyone will be able to access this instance.', + auth_disabled: 'Auth disabled — password protection removed', + disable_auth_failed: 'Failed to disable auth: ', + bg_error_single: (title) => `"${title}" has encountered an error`, + bg_error_multi: (count) => `${count} sessions have encountered an error`, }, de: { @@ -898,6 +1122,7 @@ const LOCALES = { settings_label_theme: '\u4e3b\u9898', settings_label_language: '\u8bed\u8a00', settings_label_token_usage: '\u663e\u793a token \u7528\u91cf', + settings_label_bubble_layout: '聊天气泡布局', settings_label_cli_sessions: '\u663e\u793a CLI \u4f1a\u8bdd', settings_label_sync_insights: '\u540c\u6b65\u5230 insights', settings_label_check_updates: '\u68c0\u67e5\u66f4\u65b0', @@ -921,8 +1146,20 @@ const LOCALES = { tab_tasks: '任务', tab_todos: '待办', tab_workspaces: '工作区', + tab_profiles: '配置', new_conversation: '新建对话', filter_conversations: '筛选对话…', + session_time_unknown: '未知', + session_time_just_now: '刚刚', + session_time_minutes_ago: (n) => `${n} 分钟前`, + session_time_hours_ago: (n) => `${n} 小时前`, + session_time_days_ago: (n) => `${n} 天前`, + 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: '新任务', search_skills: '搜索技能…', @@ -930,6 +1167,7 @@ const LOCALES = { save_skill: '保存技能', personal_memory: '个人记忆', current_task_list: '当前任务列表', + workspace_desc: '为你的会话添加并切换工作区。', new_profile: '新配置', transcript: '记录', download_transcript: '下载为 Markdown', @@ -951,11 +1189,198 @@ const LOCALES = { settings_desc_sound: '助手完成回复时播放提示音。', settings_desc_notifications: '当标签页在后台时,回复完成后显示系统通知。', settings_desc_token_usage: '在助手每次回复下方显示输入/输出 token 数量。也可以用 /usage 切换。', + settings_desc_bubble_layout: '开启后将用户消息右对齐、助手消息左对齐。默认关闭,以保持代码块和工具输出为全宽显示。', settings_desc_cli_sessions: '将 Hermes CLI(state.db)中的会话合并到会话列表。点击某个 CLI 会话可导入并继续对话。', settings_desc_sync_insights: '将 WebUI token 使用情况同步到 state.db,使 hermes /insights 包含浏览器会话数据。默认关闭。', settings_desc_check_updates: '当有更新的 WebUI 或助手版本时显示横幅。会在后台定期执行 git fetch。', settings_desc_bot_name: '助手在 UI 中的显示名称。默认为 Hermes。', settings_desc_password: '输入新密码以设置或更改。留空保持当前设置。', + // onboarding + onboarding_badge: '首次运行', + onboarding_title: '欢迎使用 Hermes Web UI', + onboarding_lead: '快速引导将验证 Hermes、保存真实的提供商配置、选择工作区和模型,并可选设置密码保护应用。', + onboarding_back: '返回', + onboarding_continue: '继续', + 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: '此实例已配置为使用通过 Hermes CLI 设置的 OAuth 提供商({provider})。这里不需要 API key,点击继续即可完成设置。', + onboarding_oauth_provider_not_ready_title: 'OAuth 提供商尚未认证', + onboarding_oauth_provider_not_ready_body: '此实例已配置为使用 {provider},该提供商使用 OAuth 而非 API key。请在终端运行 hermes authhermes model 完成认证,然后重新加载 Web UI。', + onboarding_oauth_switch_hint: '或者在下方选择其他提供商,切换到 API key 配置:', + 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 key', + onboarding_api_key_placeholder: '留空可保留已保存的 key', + onboarding_api_key_help_prefix: '会作为密钥保存到 Hermes .env 文件中,变量名为', + onboarding_base_url_label: 'Base URL', + onboarding_base_url_placeholder: 'https://your-endpoint.example/v1', + onboarding_base_url_help: '用于 OpenAI 兼容路由、自托管服务、LiteLLM、Ollama、LM Studio、vLLM 或类似端点。', + onboarding_model_label: '默认模型', + onboarding_workspace_help: '选择设置完成后 Hermes 在新聊天中使用的模型。', + onboarding_custom_model_placeholder: 'your-model-name', + onboarding_custom_model_help: '对于自定义端点,请填写服务端要求的精确模型 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_skipped: '暂时跳过', + onboarding_finish_help: '完成后会在设置中写入 onboarding_completed,并进入常规应用界面。', + onboarding_error_choose_workspace: '继续前请先选择工作区。', + onboarding_error_choose_model: '继续前请先选择模型。', + onboarding_error_provider_required: '继续前请先选择设置模式。', + onboarding_error_base_url_required: '自定义端点必须填写 Base URL。', + onboarding_error_workspace_required: '必须填写工作区。', + onboarding_error_model_required: '必须填写模型。', + onboarding_complete: '引导完成', + // panel/runtime i18n + 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_delete_confirm_message: '此操作无法撤销。', + cron_job_deleted: '任务已删除', + cron_completion_status: (name, status) => `定时任务“${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: '添加工作区路径(例如 /home/user/my-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: 'Agent 运行中,无法切换工作区', + discard_file_edits_title: '放弃文件编辑?', + discard_file_edits_message: '切换工作区将丢弃预览区未保存的文件修改。', + workspace_switched_to: (name) => `已切换到 ${name}`, + profiles_no_profiles: '未找到配置档。', + profile_api_keys_configured: '已配置 API 密钥', + profile_gateway_running: '网关运行中', + profile_gateway_stopped: '网关已停止', + profile_active: '当前', + profile_no_configuration: '无配置', + profile_use: '使用', + profile_switch_title: '切换到此配置档', + manage_profiles: '管理配置档', + profiles_load_failed: '加载配置档失败', + profiles_busy_switch: 'Agent 运行中,无法切换配置档', + profile_switched_new_conversation: (name) => `已切换到配置档:${name},并新建对话`, + profile_switched: (name) => `已切换到配置档:${name}`, + profile_name_rule: '仅允许小写字母、数字、连字符和下划线', + profile_base_url_rule: 'Base 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) => `${title} · ${count} 条消息`, + 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} 个会话出现错误`, }, // Traditional Chinese (zh-Hant) @@ -1144,23 +1569,6 @@ const LOCALES = { settings_desc_check_updates: '\u7576\u6709\u66f4\u65b0\u7684 WebUI \u6216\u52a9\u624b\u7248\u672c\u6642\u986f\u793a\u6a19\u8a18\u3002\u5c07\u5728\u5f8c\u81ea\u6b63\u5e38\u57f7\u884c Git-Fetch\u3002', settings_desc_bot_name: '\u52a9\u624b\u5728 UI \u4e2d\u7684\u986f\u793a\u540d\u7a31\u3002\u9810\u8a2d\u70b8\u7528\u6539\u3002', settings_desc_password: '\u8a2d\u5b9a WebUI \u767b\u5165\u5bc6\u78bc\u3002\u5047\u5982\u5df2\u8a2d\u7f6e\uff0c\u6bcf\u6b21\u52a0\u8f09\u90fd\u9700\u8981\u767b\u5165\u3002', - settings_label_sound: '\u901a\u77e5\u8072\u97f3', - // boot.js - cancelling: '\u6b63\u5728\u53d6\u6d88...', - cancel_failed: '\u53d6\u6d88\u5931\u6557\uff1a', - mic_denied: '\u9ea6\u514b\u98a8\u8a2a\u554f\u88ab\u62d2\u7d75\uff0c\u8acb\u6aa2\u67e5\u700f\u89bd\u5668\u6b0a\u9650\u3002', - mic_no_speech: '\u6c92\u6709\u6aa2\u6e2c\u5230\u8a71\u97f3\uff0c\u8acb\u518d\u5617\u4e00\u6b21\u3002', - mic_network: '\u8a71\u97f3\u8b58\u5225\u76ee\u524d\u4e0d\u53ef\u7528\u3002', - mic_error: '\u8a71\u97f3\u8f38\u5165\u51fa\u932f\uff1a', - session_imported: '\u6703\u8a71\u5df2\u5c0e\u5165', - import_failed: '\u5c0e\u5165\u5931\u6557\uff1a', - import_invalid_json: 'JSON \u7121\u6548', - image_pasted: '\u5df2\u7c98\u8cbc\u5716\u7247\uff1a', - // messages.js - edit_message: '\u7de8\u8f2f\u8a0a\u606f', - regenerate: '\u91cd\u65b0\u751f\u6210\u56de\u8986', - copy: '\u8907\u88fd', - copied: '\u5df2\u8907\u88fd', // ui.js workspace_desc: '\u8acb\u9078\u64c7\u5de5\u4f5c\u5340\uff0c\u6216\u8f09\u5165\u65b0\u540d\u7a31\u5beb\u4e00\u500b', tab_profiles: '\u914d\u7f6e', @@ -1170,6 +1578,52 @@ const LOCALES = { // Active locale — defaults to English; overridden by loadLocale() at boot. let _locale = LOCALES.en; +/** + * Resolve an incoming locale tag to a known LOCALES key. + * Supports exact keys, case-insensitive matches, and a few common aliases + * (e.g. zh-CN -> zh, zh-TW -> zh-Hant). Returns null when unresolved. + * @param {string} lang + * @returns {string|null} + */ +function resolveLocale(lang) { + if (typeof lang !== 'string') return null; + const raw = lang.trim(); + if (!raw) return null; + if (LOCALES[raw]) return raw; + + const lower = raw.toLowerCase().replace(/_/g, '-'); + + // Case-insensitive direct match first. + const direct = Object.keys(LOCALES).find((k) => k.toLowerCase() === lower); + if (direct) return direct; + + // Common Chinese variants. + if (lower === 'zh' || lower.startsWith('zh-cn') || lower.startsWith('zh-sg') || lower.startsWith('zh-hans')) { + return LOCALES.zh ? 'zh' : null; + } + if (lower.startsWith('zh-tw') || lower.startsWith('zh-hk') || lower.startsWith('zh-mo') || lower.startsWith('zh-hant')) { + return LOCALES['zh-Hant'] ? 'zh-Hant' : null; + } + + // Fallback to base language subtag (e.g. en-US -> en). + const base = lower.split('-')[0]; + const baseMatch = Object.keys(LOCALES).find((k) => k.toLowerCase() === base); + return baseMatch || null; +} + +/** + * Resolve locale with precedence: + * 1) primary (typically server setting) + * 2) fallback (typically localStorage) + * 3) English + * @param {string} primary + * @param {string} fallback + * @returns {string} + */ +function resolvePreferredLocale(primary, fallback) { + return resolveLocale(primary) || resolveLocale(fallback) || 'en'; +} + /** * Translate a key. Falls back to English if the key is missing in the active locale. * Supports function values (for interpolated strings): call t('key', arg). @@ -1189,7 +1643,7 @@ function t(key, ...args) { * @param {string} lang */ function setLocale(lang) { - const resolved = LOCALES[lang] ? lang : 'en'; + const resolved = resolveLocale(lang) || 'en'; _locale = LOCALES[resolved]; localStorage.setItem('hermes-lang', resolved); document.documentElement.lang = _locale._speech || resolved; @@ -1200,8 +1654,7 @@ function setLocale(lang) { * Server-persisted preference is applied later in loadSettingsPanel(). */ function loadLocale() { - const saved = localStorage.getItem('hermes-lang'); - setLocale(saved && LOCALES[saved] ? saved : 'en'); + setLocale(resolvePreferredLocale(null, localStorage.getItem('hermes-lang'))); } /** diff --git a/static/panels.js b/static/panels.js index 8e556df..0cf648e 100644 --- a/static/panels.js +++ b/static/panels.js @@ -24,7 +24,7 @@ async function loadCrons() { try { const data = await api('/api/crons'); if (!data.jobs || !data.jobs.length) { - box.innerHTML = '
No scheduled jobs found.
'; + box.innerHTML = `
${esc(t('cron_no_jobs'))}
`; return; } box.innerHTML = ''; @@ -33,42 +33,42 @@ async function loadCrons() { item.className = 'cron-item'; item.id = 'cron-' + job.id; const statusClass = job.enabled === false ? 'disabled' : job.state === 'paused' ? 'paused' : job.last_status === 'error' ? 'error' : 'active'; - const statusLabel = job.enabled === false ? 'off' : job.state === 'paused' ? 'paused' : job.last_status === 'error' ? 'error' : 'active'; - const nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : 'N/A'; - const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : 'never'; + const statusLabel = job.enabled === false ? t('cron_status_off') : job.state === 'paused' ? t('cron_status_paused') : job.last_status === 'error' ? t('cron_status_error') : t('cron_status_active'); + const nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : t('not_available'); + const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : t('never'); item.innerHTML = `
${esc(job.name)} ${statusLabel}
-
${li('clock',12)} ${esc(job.schedule_display || job.schedule?.expression || '')}  |  Next: ${esc(nextRun)}  |  Last: ${esc(lastRun)}
+
${li('clock',12)} ${esc(job.schedule_display || job.schedule?.expression || '')}  |  ${esc(t('cron_next'))}: ${esc(nextRun)}  |  ${esc(t('cron_last'))}: ${esc(lastRun)}
${esc((job.prompt||'').slice(0,300))}${(job.prompt||'').length>300?'…':''}
- - ${statusLabel==='paused' - ? `` - : ``} - - + + ${job.state==='paused' + ? `` + : ``} + +
- Last output - + ${esc(t('cron_last_output'))} +
-
Loading…
+
${esc(t('loading'))}
`; @@ -76,7 +76,7 @@ async function loadCrons() { // Eagerly load last output for visible items loadCronOutput(job.id); } - } catch(e) { box.innerHTML = `
Error: ${esc(e.message)}
`; } + } catch(e) { box.innerHTML = `
${esc(t('error_prefix'))}${esc(e.message)}
`; } } let _cronSelectedSkills=[]; @@ -164,18 +164,18 @@ async function submitCronCreate(){ const deliver=$('cronFormDeliver').value; const errEl=$('cronFormError'); errEl.style.display='none'; - if(!schedule){errEl.textContent='Schedule is required (e.g. "0 9 * * *" or "every 1h")';errEl.style.display='';return;} - if(!prompt){errEl.textContent='Prompt is required';errEl.style.display='';return;} + if(!schedule){errEl.textContent=t('cron_schedule_required_example');errEl.style.display='';return;} + if(!prompt){errEl.textContent=t('cron_prompt_required');errEl.style.display='';return;} try{ const body={schedule,prompt,deliver}; if(name)body.name=name; if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills; await api('/api/crons/create',{method:'POST',body:JSON.stringify(body)}); toggleCronForm(); - showToast('Job created'); + showToast(t('cron_job_created')); await loadCrons(); }catch(e){ - errEl.textContent='Error: '+e.message;errEl.style.display=''; + errEl.textContent=t('error_prefix')+e.message;errEl.style.display=''; } } @@ -192,7 +192,7 @@ async function loadCronOutput(jobId) { const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=1`); const el = $('cron-out-text-' + jobId); if (!el) return; - if (!data.outputs || !data.outputs.length) { el.textContent = '(no runs yet)'; return; } + if (!data.outputs || !data.outputs.length) { el.textContent = t('cron_no_runs_yet'); return; } const out = data.outputs[0]; const ts = out.filename.replace('.md','').replace(/_/g,' '); el.textContent = ts + '\n\n' + _cronOutputSnippet(out.content); @@ -205,14 +205,14 @@ async function loadCronHistory(jobId, btn) { // Toggle: if already open, close it if (histEl.style.display !== 'none') { histEl.style.display = 'none'; - if (btn) btn.textContent = 'All runs'; + if (btn) btn.textContent = t('cron_all_runs'); return; } - if (btn) btn.textContent = 'Loading…'; + if (btn) btn.textContent = t('loading'); try { const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=20`); if (!data.outputs || !data.outputs.length) { - histEl.innerHTML = '
(no runs yet)
'; + histEl.innerHTML = `
${esc(t('cron_no_runs_yet'))}
`; } else { histEl.innerHTML = data.outputs.map((out, i) => { const ts = out.filename.replace('.md','').replace(/_/g,' '); @@ -228,9 +228,9 @@ async function loadCronHistory(jobId, btn) { }).join(''); } histEl.style.display = ''; - if (btn) btn.textContent = 'Hide runs'; + if (btn) btn.textContent = t('cron_hide_runs'); } catch(e) { - if (btn) btn.textContent = 'All runs'; + if (btn) btn.textContent = t('cron_all_runs'); } } @@ -242,25 +242,25 @@ function toggleCron(id) { async function cronRun(id) { try { await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})}); - showToast('Job triggered'); + showToast(t('cron_job_triggered')); setTimeout(() => loadCronOutput(id), 5000); - } catch(e) { showToast('Run failed: ' + e.message, 4000); } + } catch(e) { showToast(t('failed_colon') + e.message, 4000); } } async function cronPause(id) { try { await api('/api/crons/pause', {method:'POST', body: JSON.stringify({job_id: id})}); - showToast('Job paused'); + showToast(t('cron_job_paused')); await loadCrons(); - } catch(e) { showToast('Pause failed: ' + e.message, 4000); } + } catch(e) { showToast(t('failed_colon') + e.message, 4000); } } async function cronResume(id) { try { await api('/api/crons/resume', {method:'POST', body: JSON.stringify({job_id: id})}); - showToast('Job resumed'); + showToast(t('cron_job_resumed')); await loadCrons(); - } catch(e) { showToast('Resume failed: ' + e.message, 4000); } + } catch(e) { showToast(t('failed_colon') + e.message, 4000); } } function cronEditOpen(id, job) { @@ -284,25 +284,25 @@ async function cronEditSave(id) { const schedule = $('cron-edit-schedule-' + id).value.trim(); const prompt = $('cron-edit-prompt-' + id).value.trim(); const errEl = $('cron-edit-err-' + id); - if (!schedule) { errEl.textContent = 'Schedule is required'; errEl.style.display = ''; return; } - if (!prompt) { errEl.textContent = 'Prompt is required'; errEl.style.display = ''; return; } + if (!schedule) { errEl.textContent = t('cron_schedule_required'); errEl.style.display = ''; return; } + if (!prompt) { errEl.textContent = t('cron_prompt_required'); errEl.style.display = ''; return; } try { const updates = {job_id: id, schedule, prompt}; if (name) updates.name = name; await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)}); - showToast('Job updated'); + showToast(t('cron_job_updated')); await loadCrons(); - } catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; } + } catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; } } async function cronDelete(id) { - const _delCron=await showConfirmDialog({title:'Delete cron job',message:'This cannot be undone.',confirmLabel:'Delete',danger:true,focusCancel:true}); + const _delCron=await showConfirmDialog({title:t('cron_delete_confirm_title'),message:t('cron_delete_confirm_message'),confirmLabel:t('delete_title'),danger:true,focusCancel:true}); if(!_delCron) return; try { await api('/api/crons/delete', {method:'POST', body: JSON.stringify({job_id: id})}); - showToast('Job deleted'); + showToast(t('cron_job_deleted')); await loadCrons(); - } catch(e) { showToast('Delete failed: ' + e.message, 4000); } + } catch(e) { showToast(t('delete_failed') + e.message, 4000); } } function loadTodos() { @@ -324,7 +324,7 @@ function loadTodos() { } } if (!todos.length) { - panel.innerHTML = '
No active task list in this session.
'; + panel.innerHTML = `
${esc(t('todos_no_active'))}
`; return; } const statusIcon = {pending:li('square',14), in_progress:li('loader',14), completed:li('check',14), cancelled:li('x',14)}; @@ -341,7 +341,7 @@ function loadTodos() { async function clearConversation() { if(!S.session) return; - const _clrMsg=await showConfirmDialog({title:'Clear conversation',message:'Clear all messages? This cannot be undone.',confirmLabel:'Clear',danger:true,focusCancel:true}); + const _clrMsg=await showConfirmDialog({title:t('clear_conversation_title'),message:t('clear_conversation_message'),confirmLabel:t('clear'),danger:true,focusCancel:true}); if(!_clrMsg) return; try { const data = await api('/api/session/clear', {method:'POST', @@ -351,8 +351,8 @@ async function clearConversation() { S.toolCalls = []; syncTopbar(); renderMessages(); - showToast('Conversation cleared'); - } catch(e) { setStatus('Clear failed: ' + e.message); } + showToast(t('conversation_cleared')); + } catch(e) { setStatus(t('clear_failed') + e.message); } } // ── Skills panel ── @@ -382,7 +382,7 @@ function renderSkills(skills) { } const box = $('skillsList'); box.innerHTML = ''; - if (!filtered.length) { box.innerHTML = '
No skills match.
'; return; } + if (!filtered.length) { box.innerHTML = `
${esc(t('skills_no_match'))}
`; return; } for (const [cat, items] of Object.entries(cats).sort()) { const sec = document.createElement('div'); sec.className = 'skills-category'; @@ -418,7 +418,7 @@ async function openSkill(name, el) { const lf = data.linked_files || {}; const categories = Object.entries(lf).filter(([,files]) => files && files.length > 0); if (categories.length) { - html += '
Linked Files
'; + html += `
${esc(t('linked_files'))}
`; for (const [cat, files] of categories) { html += `

${esc(cat)}

`; for (const f of files) { @@ -435,7 +435,7 @@ async function openSkill(name, el) { }); $('previewArea').classList.add('visible'); $('fileTree').style.display = 'none'; - } catch(e) { setStatus('Could not load skill: ' + e.message); } + } catch(e) { setStatus(t('skill_load_failed') + e.message); } } async function openSkillFile(skillName, filePath) { @@ -453,7 +453,7 @@ async function openSkillFile(skillName, filePath) { $('previewCode').textContent = data.content || ''; requestAnimationFrame(() => highlightCode()); } - } catch(e) { setStatus('Could not load file: ' + e.message); } + } catch(e) { setStatus(t('skill_file_load_failed') + e.message); } } // ── Skill create/edit form ── @@ -479,15 +479,15 @@ async function submitSkillSave() { const content = $('skillFormContent').value; const errEl = $('skillFormError'); errEl.style.display = 'none'; - if (!name) { errEl.textContent = 'Skill name is required'; errEl.style.display = ''; return; } - if (!content.trim()) { errEl.textContent = 'Content is required'; errEl.style.display = ''; return; } + if (!name) { errEl.textContent = t('skill_name_required'); errEl.style.display = ''; return; } + if (!content.trim()) { errEl.textContent = t('content_required'); errEl.style.display = ''; return; } try { await api('/api/skills/save', {method:'POST', body: JSON.stringify({name, category: category||undefined, content})}); - showToast(_editingSkillName ? 'Skill updated' : 'Skill created'); + showToast(_editingSkillName ? t('skill_updated') : t('skill_created')); _skillsData = null; toggleSkillForm(); await loadSkills(); - } catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; } + } catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; } } // ── Memory inline edit ── @@ -498,7 +498,7 @@ function toggleMemoryEdit() { if (!form) return; const open = form.style.display !== 'none'; if (open) { form.style.display = 'none'; return; } - $('memEditSection').textContent = 'memory (notes)'; + $('memEditSection').textContent = t('memory_notes_label'); $('memEditContent').value = _memoryData ? (_memoryData.memory || '') : ''; $('memEditError').style.display = 'none'; form.style.display = ''; @@ -515,10 +515,10 @@ async function submitMemorySave() { errEl.style.display = 'none'; try { await api('/api/memory/write', {method:'POST', body: JSON.stringify({section: 'memory', content})}); - showToast('Memory saved'); + showToast(t('memory_saved')); closeMemoryEdit(); await loadMemory(true); - } catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; } + } catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; } } // ── Workspace management ── @@ -550,7 +550,7 @@ function syncWorkspaceDisplays(){ if(composerLabel) composerLabel.textContent=label; if(composerChip){ composerChip.disabled=!hasSession; - composerChip.title=hasSession?ws:'No active workspace'; + composerChip.title=hasSession?ws:t('no_workspace'); composerChip.classList.toggle('active',!!(composerDropdown&&composerDropdown.classList.contains('open'))); } } @@ -610,15 +610,15 @@ function renderWorkspaceDropdownInto(dd, workspaces, currentWs){ } dd.appendChild(document.createElement('div')).className='ws-divider'; dd.appendChild(_renderWorkspaceAction( - 'Choose workspace path', - 'Add a validated path and switch this conversation', + t('workspace_choose_path'), + t('workspace_choose_path_meta'), li('folder',12), ()=>promptWorkspacePath() )); const div=document.createElement('div');div.className='ws-divider';dd.appendChild(div); dd.appendChild(_renderWorkspaceAction( - 'Manage workspaces', - 'Open the Spaces panel', + t('workspace_manage'), + t('workspace_manage_meta'), li('settings',12), ()=>{closeWsDropdown();mobileSwitchPanel('workspaces');} )); @@ -693,19 +693,19 @@ function renderWorkspacesPanel(workspaces){
${esc(w.path)}
- - + +
`; panel.appendChild(row); } const addRow=document.createElement('div');addRow.className='ws-add-row'; addRow.innerHTML=` - - `; + + `; panel.appendChild(addRow); const hint=document.createElement('div'); hint.style.cssText='font-size:11px;color:var(--muted);padding:4px 0 8px'; - hint.textContent='Paths are validated as existing directories before saving.'; + hint.textContent=t('workspace_paths_validated_hint'); panel.appendChild(hint); } @@ -718,28 +718,28 @@ async function addWorkspace(){ _workspaceList=data.workspaces; renderWorkspacesPanel(data.workspaces); if(input)input.value=''; - showToast('Workspace added'); - }catch(e){setStatus('Add failed: '+e.message);} + showToast(t('workspace_added')); + }catch(e){setStatus(t('add_failed')+e.message);} } async function removeWorkspace(path){ - const _rmWs=await showConfirmDialog({title:'Remove workspace',message:`Remove "${path}"?`,confirmLabel:'Remove',danger:true,focusCancel:true}); + const _rmWs=await showConfirmDialog({title:t('workspace_remove_confirm_title'),message:t('workspace_remove_confirm_message',path),confirmLabel:t('remove'),danger:true,focusCancel:true}); if(!_rmWs) return; try{ const data=await api('/api/workspaces/remove',{method:'POST',body:JSON.stringify({path})}); _workspaceList=data.workspaces; renderWorkspacesPanel(data.workspaces); - showToast('Workspace removed'); - }catch(e){setStatus('Remove failed: '+e.message);} + showToast(t('workspace_removed')); + }catch(e){setStatus(t('remove_failed')+e.message);} } async function promptWorkspacePath(){ if(!S.session)return; const value=await showPromptDialog({ - title:'Switch workspace', - message:'Enter an absolute workspace path to add and switch this conversation to.', - confirmLabel:'Switch', - placeholder:'/Users/you/project', + title:t('workspace_switch_prompt_title'), + message:t('workspace_switch_prompt_message'), + confirmLabel:t('workspace_switch_prompt_confirm'), + placeholder:t('workspace_switch_prompt_placeholder'), value:S.session.workspace||'' }); const path=(value||'').trim(); @@ -748,27 +748,27 @@ async function promptWorkspacePath(){ const data=await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path})}); _workspaceList=data.workspaces||[]; const target=_workspaceList[_workspaceList.length-1]; - if(!target) throw new Error('Workspace was not added'); + if(!target) throw new Error(t('workspace_not_added')); await switchToWorkspace(target.path,target.name); }catch(e){ if(String(e.message||'').includes('Workspace already in list')){ - showToast('Workspace already saved — choose it from the list'); + showToast(t('workspace_already_saved')); return; } - showToast('Workspace switch failed: '+e.message); + showToast(t('workspace_switch_failed')+e.message); } } async function switchToWorkspace(path,name){ if(!S.session)return; if(S.busy){ - showToast('Cannot switch workspace while agent is running'); + showToast(t('workspace_busy_switch')); return; } if(typeof _previewDirty!=='undefined'&&_previewDirty){ const discard=await showConfirmDialog({ - title:'Discard file edits?', - message:'Switching workspaces will discard unsaved file edits in the preview.', + title:t('discard_file_edits_title'), + message:t('discard_file_edits_message'), confirmLabel:t('discard'), danger:true }); @@ -784,8 +784,8 @@ async function switchToWorkspace(path,name){ S.session.workspace=path; syncTopbar(); await loadDir('.'); - showToast(`Switched to ${name||getWorkspaceFriendlyName(path)}`); - }catch(e){setStatus('Switch failed: '+e.message);} + showToast(t('workspace_switched_to',name||getWorkspaceFriendlyName(path))); + }catch(e){setStatus(t('switch_failed')+e.message);} } // ── Profile panel + dropdown ── @@ -799,7 +799,7 @@ async function loadProfilesPanel() { _profilesCache = data; panel.innerHTML = ''; if (!data.profiles || !data.profiles.length) { - panel.innerHTML = '
No profiles found.
'; + panel.innerHTML = `
${esc(t('profiles_no_profiles'))}
`; return; } for (const p of data.profiles) { @@ -809,20 +809,20 @@ async function loadProfilesPanel() { if (p.model) meta.push(p.model.split('/').pop()); if (p.provider) meta.push(p.provider); if (p.skill_count) meta.push(p.skill_count + ' skill' + (p.skill_count !== 1 ? 's' : '')); - if (p.has_env) meta.push('API keys configured'); + if (p.has_env) meta.push(t('profile_api_keys_configured')); const gwDot = p.gateway_running - ? '' - : ''; + ? `` + : ``; const isActive = p.name === data.active; - const activeBadge = isActive ? 'ACTIVE' : ''; + const activeBadge = isActive ? `${esc(t('profile_active'))}` : ''; card.innerHTML = `
${gwDot}${esc(p.name)}${p.is_default ? ' (default)' : ''}${activeBadge}
- ${meta.length ? `
${esc(meta.join(' \u00b7 '))}
` : '
No configuration
'} + ${meta.length ? `
${esc(meta.join(' \u00b7 '))}
` : `
${esc(t('profile_no_configuration'))}
`}
- ${!isActive ? `` : ''} + ${!isActive ? `` : ''} ${!p.is_default ? `` : ''}
`; @@ -859,7 +859,7 @@ function renderProfileDropdown(data) { // Divider + Manage link const div = document.createElement('div'); div.className = 'ws-divider'; dd.appendChild(div); const mgmt = document.createElement('div'); mgmt.className = 'profile-opt ws-manage'; - mgmt.innerHTML = `${li('settings',12)} Manage profiles`; + mgmt.innerHTML = `${li('settings',12)} ${esc(t('manage_profiles'))}`; mgmt.onclick = () => { closeProfileDropdown(); mobileSwitchPanel('profiles'); }; dd.appendChild(mgmt); } @@ -876,7 +876,7 @@ function toggleProfileDropdown() { _positionProfileDropdown(); const chip=$('profileChip'); if(chip) chip.classList.add('active'); - }).catch(e => { showToast('Failed to load profiles'); }); + }).catch(e => { showToast(t('profiles_load_failed')); }); } function closeProfileDropdown() { @@ -894,7 +894,7 @@ window.addEventListener('resize',()=>{ }); async function switchToProfile(name) { - if (S.busy) { showToast('Cannot switch profiles while agent is running'); return; } + if (S.busy) { showToast(t('profiles_busy_switch')); return; } // Determine whether the current session has any messages. // A session with messages is "in progress" and belongs to the current profile — @@ -948,12 +948,12 @@ async function switchToProfile(name) { // Start a new session for the new profile so nothing gets cross-tagged. await newSession(false); await renderSessionList(); - showToast('Switched to profile: ' + name + ' — new conversation started'); + showToast(t('profile_switched_new_conversation', name)); } else { // No messages yet — just refresh the list and topbar in place await renderSessionList(); syncTopbar(); - showToast('Switched to profile: ' + name); + showToast(t('profile_switched', name)); } // ── Sidebar panels ───────────────────────────────────────────────────── @@ -963,7 +963,7 @@ async function switchToProfile(name) { if (_currentPanel === 'profiles') await loadProfilesPanel(); if (_currentPanel === 'workspaces') await loadWorkspacesPanel(); - } catch (e) { showToast('Switch failed: ' + e.message); } + } catch (e) { showToast(t('switch_failed') + e.message); } } function toggleProfileForm() { @@ -985,13 +985,13 @@ async function submitProfileCreate() { const name = ($('profileFormName').value || '').trim().toLowerCase(); const cloneConfig = $('profileFormClone').checked; const errEl = $('profileFormError'); - if (!name) { errEl.textContent = 'Name is required'; errEl.style.display = ''; return; } - if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) { errEl.textContent = 'Lowercase letters, numbers, hyphens, underscores only'; errEl.style.display = ''; return; } + if (!name) { errEl.textContent = t('name_required'); errEl.style.display = ''; return; } + if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) { errEl.textContent = t('profile_name_rule'); errEl.style.display = ''; return; } try { const baseUrl = (($('profileFormBaseUrl') && $('profileFormBaseUrl').value) || '').trim(); const apiKey = (($('profileFormApiKey') && $('profileFormApiKey').value) || '').trim(); if (baseUrl && !/^https?:\/\//.test(baseUrl)) { - errEl.textContent = 'Base URL must start with http:// or https://'; errEl.style.display = ''; return; + errEl.textContent = t('profile_base_url_rule'); errEl.style.display = ''; return; } const payload = { name, clone_config: cloneConfig }; if (baseUrl) payload.base_url = baseUrl; @@ -999,18 +999,18 @@ async function submitProfileCreate() { await api('/api/profile/create', { method: 'POST', body: JSON.stringify(payload) }); toggleProfileForm(); await loadProfilesPanel(); - showToast('Profile created: ' + name); - } catch (e) { errEl.textContent = e.message || 'Create failed'; errEl.style.display = ''; } + showToast(t('profile_created', name)); + } catch (e) { errEl.textContent = e.message || 'Create failed'; errEl.style.display = ''; } } async function deleteProfile(name) { - const _delProf=await showConfirmDialog({title:`Delete profile "${name}"?`,message:'This removes all config, skills, memory, and sessions for this profile.',confirmLabel:'Delete',danger:true,focusCancel:true}); + const _delProf=await showConfirmDialog({title:t('profile_delete_confirm_title',name),message:t('profile_delete_confirm_message'),confirmLabel:t('delete_title'),danger:true,focusCancel:true}); if(!_delProf) return; try { await api('/api/profile/delete', { method: 'POST', body: JSON.stringify({ name }) }); await loadProfilesPanel(); - showToast('Profile deleted: ' + name); - } catch (e) { showToast('Delete failed: ' + e.message); } + showToast(t('profile_deleted', name)); + } catch (e) { showToast(t('delete_failed') + e.message); } } // ── Memory panel ── @@ -1023,23 +1023,23 @@ async function loadMemory(force) { panel.innerHTML = `
- ${li('brain',14)} My Notes + ${li('brain',14)} ${esc(t('my_notes'))} ${fmtTime(data.memory_mtime)}
${data.memory ? `
${renderMd(data.memory)}
` - : '
No notes yet.
'} + : `
${esc(t('no_notes_yet'))}
`}
- ${li('user',14)} User Profile + ${li('user',14)} ${esc(t('user_profile'))} ${fmtTime(data.user_mtime)}
${data.user ? `
${renderMd(data.user)}
` - : '
No profile yet.
'} + : `
${esc(t('no_profile_yet'))}
`}
`; - } catch(e) { panel.innerHTML = `
Error: ${esc(e.message)}
`; } + } catch(e) { panel.innerHTML = `
${esc(t('error_prefix'))}${esc(e.message)}
`; } } // Drag and drop @@ -1074,12 +1074,12 @@ function switchSettingsSection(name){ function _syncHermesPanelSessionActions(){ const hasSession=!!S.session; const visibleMessages=hasSession?(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length:0; - const title=hasSession?(S.session.title||'Untitled'):'No active conversation selected.'; + const title=hasSession?(S.session.title||t('untitled')):t('active_conversation_none'); const meta=$('hermesSessionMeta'); if(meta){ meta.textContent=hasSession - ? `${title} · ${visibleMessages} message${visibleMessages===1?'':'s'}` - : 'No active conversation selected.'; + ? t('active_conversation_meta', title, visibleMessages) + : t('active_conversation_none'); } const setDisabled=(id,disabled)=>{ const el=$(id); @@ -1148,10 +1148,10 @@ function _showSettingsUnsavedBar(){ bar = document.createElement('div'); bar.id = 'settingsUnsavedBar'; bar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.3);border-radius:8px;padding:10px 14px;margin:0 0 12px;font-size:13px;'; - bar.innerHTML = 'You have unsaved changes.' + bar.innerHTML = `${esc(t('settings_unsaved_changes'))}` + '' - + '' - + '' + + `` + + `` + ''; const body = document.querySelector('.settings-main') || document.querySelector('.settings-body') || document.querySelector('.settings-panel'); if(body) body.prepend(bar); @@ -1171,8 +1171,14 @@ function _markSettingsDirty(){ async function loadSettingsPanel(){ try{ const settings=await api('/api/settings'); - // Apply server-persisted locale immediately (overrides localStorage boot default) - if(settings.language && typeof setLocale==='function') setLocale(settings.language); + const resolvedLanguage=(typeof resolvePreferredLocale==='function') + ? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang')) + : (settings.language || localStorage.getItem('hermes-lang') || 'en'); + // Keep settings modal and current page strings in sync with the resolved locale. + if(typeof setLocale==='function'){ + setLocale(resolvedLanguage); + if(typeof applyLocaleToDOM==='function') applyLocaleToDOM(); + } // Populate model dropdown from /api/models const modelSel=$('settingsModel'); if(modelSel){ @@ -1210,7 +1216,7 @@ async function loadSettingsPanel(){ langSel.appendChild(opt); } } - langSel.value=settings.language||'en'; + langSel.value=resolvedLanguage; langSel.addEventListener('change',_markSettingsDirty,{once:false}); } const showUsageCb=$('settingsShowTokenUsage'); @@ -1287,7 +1293,7 @@ async function saveSettings(andClose){ _settingsDirty=false; _settingsThemeOnOpen=theme; _hideSettingsPanel(); return; - }catch(e){showToast('Save failed: '+e.message);return;} + }catch(e){showToast(t('settings_save_failed')+e.message);return;} } try{ await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); @@ -1319,23 +1325,23 @@ async function signOut(){ await api('/api/auth/logout',{method:'POST',body:'{}'}); window.location.href='/login'; }catch(e){ - showToast('Sign out failed: '+e.message); + showToast(t('sign_out_failed')+e.message); } } async function disableAuth(){ - const _disAuth=await showConfirmDialog({title:'Disable password protection',message:'Anyone will be able to access this instance.',confirmLabel:'Disable',danger:true,focusCancel:true}); + const _disAuth=await showConfirmDialog({title:t('disable_auth_confirm_title'),message:t('disable_auth_confirm_message'),confirmLabel:t('disable'),danger:true,focusCancel:true}); if(!_disAuth) return; try{ await api('/api/settings',{method:'POST',body:JSON.stringify({_clear_password:true})}); - showToast('Auth disabled — password protection removed'); + showToast(t('auth_disabled')); // Hide both auth buttons since auth is now off const disableBtn=$('btnDisableAuth'); if(disableBtn) disableBtn.style.display='none'; const signOutBtn=$('btnSignOut'); if(signOutBtn) signOutBtn.style.display='none'; }catch(e){ - showToast('Failed to disable auth: '+e.message); + showToast(t('disable_auth_failed')+e.message); } } @@ -1359,7 +1365,7 @@ function startCronPolling(){ const data=await api(`/api/crons/recent?since=${_cronPollSince}`); if(data.completions&&data.completions.length>0){ for(const c of data.completions){ - showToast(`Cron "${c.name}" ${c.status==='error'?'failed':'completed'}`,4000); + showToast(t('cron_completion_status', c.name, c.status==='error' ? t('status_failed') : t('status_completed')),4000); _cronPollSince=Math.max(_cronPollSince,c.completed_at); } _cronUnreadCount+=data.completions.length; @@ -1404,7 +1410,7 @@ const _backgroundErrors=[]; // {session_id, title, message, ts} function trackBackgroundError(sessionId, title, message){ // Only track if user is NOT currently viewing this session if(S.session&&S.session.session_id===sessionId) return; - _backgroundErrors.push({session_id:sessionId, title:title||'Untitled', message, ts:Date.now()}); + _backgroundErrors.push({session_id:sessionId, title:title||t('untitled'), message, ts:Date.now()}); showErrorBanner(); } @@ -1421,7 +1427,8 @@ function showErrorBanner(){ const latest=_backgroundErrors[0]; // FIFO: show oldest (first) error if(!latest){banner.style.display='none';return;} const count=_backgroundErrors.length; - banner.innerHTML=`\u26a0 ${count>1?count+' sessions have':'"'+esc(latest.title)+'" has'} encountered an error
`; + const msg=count>1?t('bg_error_multi',count):t('bg_error_single',latest.title); + banner.innerHTML=`\u26a0 ${esc(msg)}
`; banner.style.display=''; } diff --git a/tests/test_chinese_locale.py b/tests/test_chinese_locale.py new file mode 100644 index 0000000..9ae3082 --- /dev/null +++ b/tests/test_chinese_locale.py @@ -0,0 +1,65 @@ +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_chinese_locale_block_exists(): + src = read(REPO / "static" / "i18n.js") + assert "\n zh: {" in src + assert "_lang: 'zh'" in src + assert "_speech: 'zh-CN'" in src + + +def test_chinese_locale_includes_representative_translations(): + src = read(REPO / "static" / "i18n.js") + expected = [ + "settings_title: '\\u8bbe\\u7f6e'", + "login_title: '\\u767b\\u5f55'", + "approval_heading: '需要审批'", + "tab_tasks: '任务'", + "tab_profiles: '配置'", + "session_time_just_now: '刚刚'", + "onboarding_title: '欢迎使用 Hermes Web UI'", + "onboarding_complete: '引导完成'", + ] + for entry in expected: + assert entry in src + + +def test_chinese_locale_covers_english_keys(): + src = read(REPO / "static" / "i18n.js") + en_match = re.search(r"\n en: \{([\s\S]*?)\n \},\n\n es: \{", src) + zh_match = re.search( + r"\n zh: \{([\s\S]*?)\n \},\n\n // Traditional Chinese \(zh-Hant\)", + src, + ) + assert en_match, "English locale block not found" + assert zh_match, "Chinese 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))) + zh_keys = set(key_pattern.findall(zh_match.group(1))) + + missing = sorted(en_keys - zh_keys) + assert not missing, f"Chinese locale missing keys: {missing}" + + +def test_chinese_locale_has_no_duplicate_keys(): + src = read(REPO / "static" / "i18n.js") + zh_match = re.search( + r"\n zh: \{([\s\S]*?)\n \},\n\n // Traditional Chinese \(zh-Hant\)", + src, + ) + assert zh_match, "Chinese locale block not found" + + key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE) + keys = key_pattern.findall(zh_match.group(1)) + duplicates = sorted(k for k, count in Counter(keys).items() if count > 1) + assert not duplicates, f"Chinese locale has duplicate keys: {duplicates}" diff --git a/tests/test_language_precedence.py b/tests/test_language_precedence.py new file mode 100644 index 0000000..29c92a2 --- /dev/null +++ b/tests/test_language_precedence.py @@ -0,0 +1,88 @@ +import json +import pathlib +import re +import subprocess +import textwrap + + +REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() +I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8") +BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8") +PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8") + + +def _run_i18n_case(script_expr: str) -> dict: + wrapped_expr = f"(() => ({script_expr}))()" + script = textwrap.dedent( + f""" + const fs = require('fs'); + const vm = require('vm'); + const src = fs.readFileSync({json.dumps(str(REPO_ROOT / "static" / "i18n.js"))}, 'utf8'); + const storage = {{}}; + const ctx = {{ + localStorage: {{ + getItem: (k) => Object.prototype.hasOwnProperty.call(storage, k) ? storage[k] : null, + setItem: (k, v) => {{ storage[k] = String(v); }}, + }}, + document: {{ + documentElement: {{ lang: '' }}, + querySelectorAll: () => [], + }}, + }}; + vm.createContext(ctx); + vm.runInContext(src, ctx); + const out = vm.runInContext({json.dumps(wrapped_expr)}, ctx); + process.stdout.write(JSON.stringify(out)); + """ + ) + proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + return json.loads(proc.stdout) + + +def test_i18n_exposes_locale_resolvers(): + assert "function resolveLocale(" in I18N_JS + assert "function resolvePreferredLocale(" in I18N_JS + + +def test_locale_alias_resolution_and_precedence_logic(): + result = _run_i18n_case( + """ +{ + zhCn: resolveLocale('zh-CN'), + zhTw: resolveLocale('zh_TW'), + enUs: resolveLocale('EN-us'), + esMx: resolveLocale('es-MX'), + bad: resolveLocale('xx-YY'), + preferred1: resolvePreferredLocale('zh-CN', 'en'), + preferred2: resolvePreferredLocale('xx-YY', 'zh-Hant'), + preferred3: resolvePreferredLocale('', 'xx-YY'), +} + """ + ) + assert result["zhCn"] == "zh" + assert result["zhTw"] == "zh-Hant" + assert result["enUs"] == "en" + assert result["esMx"] == "es" + assert result["bad"] is None + assert result["preferred1"] == "zh" + assert result["preferred2"] == "zh-Hant" + assert result["preferred3"] == "en" + + +def test_set_locale_normalizes_alias_and_persists_canonical_key(): + result = _run_i18n_case( + """ +{ + ...(setLocale('zh-CN'), {}), + saved: localStorage.getItem('hermes-lang'), + htmlLang: document.documentElement.lang, +} + """ + ) + assert result["saved"] == "zh" + assert result["htmlLang"] == "zh-CN" + + +def test_boot_and_settings_panel_use_shared_locale_precedence(): + assert re.search(r"resolvePreferredLocale\(s\.language\s*,\s*localStorage\.getItem\('hermes-lang'\)\)", BOOT_JS) + assert re.search(r"resolvePreferredLocale\(settings\.language\s*,\s*localStorage\.getItem\('hermes-lang'\)\)", PANELS_JS) diff --git a/tests/test_login_locale.py b/tests/test_login_locale.py new file mode 100644 index 0000000..ad63e01 --- /dev/null +++ b/tests/test_login_locale.py @@ -0,0 +1,64 @@ +import json +import urllib.error +import urllib.request + + +BASE = "http://127.0.0.1:8788" + + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + + +def get_raw(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return r.read().decode(), r.status + + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request( + BASE + path, data=data, headers={"Content-Type": "application/json"} + ) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + + +def _current_language(): + settings, status = get("/api/settings") + assert status == 200 + return settings.get("language") or "en" + + +def test_login_page_uses_simplified_chinese_for_zh_cn_alias(): + prev_lang = _current_language() + try: + saved, status = post("/api/settings", {"language": "zh-CN"}) + assert status == 200 + assert saved.get("language") == "zh-CN" + html, status2 = get_raw("/login") + assert status2 == 200 + assert 'lang="zh-CN"' in html + assert "\u767b\u5f55" in html + assert "\u8f93\u5165\u5bc6\u7801\u7ee7\u7eed\u4f7f\u7528" in html + finally: + post("/api/settings", {"language": prev_lang}) + + +def test_login_page_uses_traditional_chinese_for_zh_hant(): + prev_lang = _current_language() + try: + saved, status = post("/api/settings", {"language": "zh-Hant"}) + assert status == 200 + assert saved.get("language") == "zh-Hant" + html, status2 = get_raw("/login") + assert status2 == 200 + assert 'lang="zh-TW"' in html + assert "\u8f38\u5165\u5bc6\u78bc\u7e7c\u7e8c\u4f7f\u7528" in html + assert "\u5bc6\u78bc\u932f\u8aa4" in html + finally: + post("/api/settings", {"language": prev_lang})