diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bc99374..b75d406 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -63,7 +63,7 @@ actions. The topbar remains focused on conversation context and the workspace/fi panels.js Cron, skills, memory, workspace, profiles, todo, settings (~974 lines) commands.js Slash command registry, parser, autocomplete dropdown (~156 lines) onboarding.js First-run wizard overlay, provider setup flow, and settings/workspace orchestration. - boot.js Event wiring, mobile nav, voice input, boot IIFE (~338 lines) + boot.js Event wiring, mobile sidebar/workspace nav, voice input, boot IIFE (~338 lines) tests/ conftest.py Isolated test server (port 8788, separate HERMES_HOME) (~240 lines) test_sprint{1-20b}.py Feature tests per sprint (21 files, 415 test functions) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3856808..d429f2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Hermes Web UI -- Changelog +## [v0.50.38] feat: mobile nav cleanup, Prism syntax highlighting, zh-CN/zh-Hant i18n + +Three community contributions combined: + +**PR #425 — Remove mobile bottom nav (@aronprins)** +The fixed iOS-style bottom navigation bar on phones has been removed. The sidebar drawer +tabs already handle all navigation — the bottom nav was redundant and consumed ~56px of +vertical chat space. `test_mobile_layout.py` updated with `test_mobile_bottom_nav_removed()` +and new sidebar nav coverage tests. + +**PR #426 — Prism syntax highlighting with light + dark theme token colors (@GiggleSamurai)** +Fenced code blocks now emit `class="language-{lang}"` on `` elements, enabling Prism's +autoloader to apply token-level syntax highlighting. Added 36-line `:root[data-theme="light"]` +token color overrides scoped to light theme only; dark/dim/monokai/nord themes unaffected. +Background guard uses `var(--code-bg) !important` to prevent Prism's dark background from +overriding theme variables. 2 new regression tests in `test_issue_code_syntax_highlight.py`. + +**PR #428 — zh-CN/zh-Hant i18n hardening (@vansour)** +Pluggable `resolvePreferredLocale()` function with smart zh-CN/zh-SG/zh-TW/zh-HK variant +mapping. Full zh-Simplified and zh-Traditional locale blocks added to `i18n.js`. Login page +locale routing updated in `api/routes.py` (`_resolve_login_locale_key()` helper). Hardcoded +strings in `panels.js` cron UI extracted to i18n keys. 3 new test files: +`test_chinese_locale.py`, `test_language_precedence.py`, `test_login_locale.py`. + +- Total tests: 1073 (was 1063) + ## [v0.50.37] fix(onboarding): skip wizard when Hermes is already configured Fixes #420 — existing Hermes users with a valid `config.yaml` were shown the first-run diff --git a/README.md b/README.md index c70e424..5da980c 100644 --- a/README.md +++ b/README.md @@ -277,8 +277,8 @@ WireGuard. Install it on your server and your phone, and they join the same private network -- no port forwarding, no SSH tunnels, no public exposure. The Hermes Web UI is fully responsive with a mobile-optimized layout -(hamburger sidebar, bottom navigation bar, touch-friendly controls), so it -works well as a daily-driver agent interface from your phone. +(hamburger sidebar, sidebar top tabs in the drawer, touch-friendly controls), +so it works well as a daily-driver agent interface from your phone. **Setup:** @@ -451,10 +451,10 @@ across 53 test files. ### Mobile responsive - Hamburger sidebar -- slide-in overlay on mobile (<640px) -- Bottom navigation bar -- 5-tab iOS-style fixed bar +- Sidebar top tabs stay available on mobile; no fixed bottom nav stealing chat height - Files slide-over panel from right edge - Touch targets minimum 44px on all interactive elements -- Composer positioned above bottom nav +- Full-height chat/composer on phones without bottom-nav spacing - Desktop layout completely unchanged --- @@ -542,7 +542,7 @@ A run of focused quality-of-life improvements: terminal tool approval prompts th Added the 7th built-in theme: pure black backgrounds with warm accents tuned to reduce burn-in risk. Small diff, big impact for anyone on an OLED display. **[@Bobby9228](https://github.com/Bobby9228)** — Mobile Profiles button + Android Chrome fixes (PRs #253, #263, #265) -Added the Profiles tab to the mobile bottom navigation bar, making profile switching reachable on phones, plus a set of Android Chrome-specific fixes for the profile dropdown. +Added the Profiles entry to the mobile navigation flow, making profile switching reachable on phones, plus a set of Android Chrome-specific fixes for the profile dropdown. **[@franksong2702](https://github.com/franksong2702)** — Session title guard + breadcrumb nav (PRs #301, #302) Two clean bug fixes / features: the session title guard that stops `title_from()` from overwriting user-renamed sessions after every turn, and clickable breadcrumb navigation in the workspace file preview panel. diff --git a/ROADMAP.md b/ROADMAP.md index 14db757..7dbedee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,7 +39,7 @@ | Sprint 18 | Thinking display + workspace tree | File preview auto-close, thinking/reasoning cards, expandable directory tree (#22) | 318 | | Sprint 19 | Auth + security hardening | Password auth (off by default), login page, security headers, 20MB body limit (#23) | 328 | | Sprint 20 | Voice input + send button | Voice input (Web Speech API), send button icon-circle with pop-in animation | 415 | -| Sprint 21 | Mobile responsive + Docker | Hamburger sidebar, bottom nav, files slide-over, Docker support (#21, #7) | 415 | +| Sprint 21 | Mobile responsive + Docker | Hamburger sidebar, mobile nav, files slide-over, Docker support (#21, #7) | 415 | | Sprint 22 | Multi-profile support | Profile picker, management panel, seamless switching, per-session tracking (#28) | 415 | | Sprint 23 | Agentic transparency | Token/cost display, subagent cards, skill picker in cron, skill linked files, workspace tree persistence, timestamp fixes | 424 | | v0.44.0 patch | Fix batch: approval card, login CSP, update diagnostics, Lucide icons | PRs #221 #225 #226 #227 #228 | 579 | @@ -50,7 +50,7 @@ | v0.48.0 | Gateway session sync | Real-time Telegram/Discord/Slack sessions in sidebar via SSE + DB polling (#274 @bergeouss); +10 tests | 658 | | v0.48.1 | Table inline formatting | `inlineMd()` in table cells — **bold**, *italic*, `code`, links render correctly (PR #278); 0 new tests | 658 | | v0.48.2 | Provider mismatch warning | Toast warning + auth_mismatch error type for provider/model mismatches (#283, fixes #266); +21 tests | 679 | -| v0.49.1 | Docker docs + mobile Profiles button | Two-container Docker compose (#291/#288); Profiles button in mobile bottom nav with mobileSwitchPanel, data-panel, correct SVG size and position (#297/#265 @gabogabucho); +3 tests | 700 | +| v0.49.1 | Docker docs + mobile Profiles button | Two-container Docker compose (#291/#288); Profiles added to the mobile navigation flow with correct panel wiring and SVG sizing (#297/#265 @gabogabucho); +3 tests | 700 | | v0.49.0 | First-run onboarding wizard + self-update hardening | One-shot bootstrap + guided setup wizard; provider config persisted to config.yaml + .env; OpenRouter/Anthropic/OpenAI/Custom; wizard hidden after completion (#285); self-update stderr/split-ref/conflict fixes (#287); skip flaky redaction test (#289); +18 tests | 697 | | v0.32 | Auto-compaction handling | Compression detection, /compact command, real context window indicator | 424 | | v0.33 | /insights sync | Opt-in state.db sync so `hermes /insights` includes WebUI sessions | 424 | @@ -223,7 +223,7 @@ - [x] Voice input via Web Speech API (Sprint 20) ### Mobile -- [x] Mobile responsive layout — hamburger sidebar, bottom nav, files slide-over (Sprint 21) +- [x] Mobile responsive layout — hamburger sidebar, sidebar tabs on phones, files slide-over (Sprint 21 + later mobile nav simplification) ### Profiles - [x] Multi-profile support — create, switch, delete profiles (Sprint 22, Issue #28) diff --git a/TESTING.md b/TESTING.md index fb81998..57e0c6a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,7 +8,7 @@ > Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser. > Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}. > -> Automated tests: 1063 total (1063 passing, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, and the onboarding skip/existing-config guard. +> Automated tests: 1073 total (1073 passing, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, and the onboarding skip/existing-config guard. > Run: `pytest tests/ -v --timeout=60` --- @@ -1715,12 +1715,13 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`. - Open on mobile viewport (<640px): hamburger icon visible in topbar. - Tap hamburger → sidebar slides in from left with backdrop overlay. - Tap outside sidebar → closes. Tap a session → closes and loads session. -- Bottom navigation bar: 5 tabs (Chat, Tasks, Skills, Memory, Spaces). -- Tap "Tasks" in bottom nav → sidebar opens showing Tasks panel. -- Tap "Chat" in bottom nav → sidebar closes (chat is in main area). +- Sidebar top nav remains visible inside the mobile drawer; includes Chat/Tasks/Skills/Memory/Spaces/Profile tabs. +- Tap "Tasks" in the drawer nav → Tasks panel opens in the sidebar drawer. +- Tap "Chat" in the drawer nav → sidebar closes and chat is unobstructed in the main area. - Files button in topbar → right panel slides in from right. +- No fixed mobile bottom nav; chat transcript and composer use the reclaimed vertical space. - All touch targets are at least 44px (session items, buttons, icons). -- Desktop viewport (>640px): no hamburger, no bottom nav, no mobile elements. +- Desktop viewport (>640px): no hamburger or mobile overlay; desktop layout unchanged. - Docker: `docker compose up -d` starts server on port 8787. - Docker: session data persists across container restarts (named volume). diff --git a/api/routes.py b/api/routes.py index 607e49d..b233589 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": "Entrar", + "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 42881fe..1983b9b 100644 --- a/static/boot.js +++ b/static/boot.js @@ -155,11 +155,7 @@ function toggleWorkspacePanel(force){ openWorkspacePanel(nextMode); } function mobileSwitchPanel(name){ - // Switch the panel content view switchPanel(name); - // For non-chat panels (tasks, skills, memory, spaces), open the sidebar - // so the panel is visible. For 'chat', the content is in the main area — - // just close the sidebar so the chat view is unobstructed. if(name==='chat'){ closeMobileSidebar(); } else { @@ -170,10 +166,6 @@ function mobileSwitchPanel(name){ if(overlay)overlay.classList.add('visible'); } } - // Update bottom nav active state - document.querySelectorAll('.mobile-nav-btn').forEach(btn=>{ - btn.classList.toggle('active',btn.dataset.panel===name); - }); } $('btnSend').onclick=()=>{ @@ -592,7 +584,45 @@ 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'); + if(typeof setLocale==='function'){ + const _lang=typeof resolvePreferredLocale==='function' + ? resolvePreferredLocale(null, localStorage.getItem('hermes-lang')) + : (localStorage.getItem('hermes-lang') || 'en'); + setLocale(_lang); + if(typeof applyLocaleToDOM==='function')applyLocaleToDOM(); + } + applyBotName(); + } // 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..12ad6df 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -290,6 +290,120 @@ 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_skill_count: (count) => `${count} skill${count === 1 ? '' : 's'}`, + profile_use: 'Use', + profile_switch_title: 'Switch to this profile', + profile_delete_title: 'Delete 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 +684,120 @@ 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_skill_count: (count) => `${count} habilidad${count === 1 ? '' : 'es'}`, + profile_use: 'Use', + profile_switch_title: 'Switch to this profile', + profile_delete_title: 'Eliminar este perfil', + 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 +1126,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 +1150,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 +1171,7 @@ const LOCALES = { save_skill: '保存技能', personal_memory: '个人记忆', current_task_list: '当前任务列表', + workspace_desc: '为你的会话添加并切换工作区。', new_profile: '新配置', transcript: '记录', download_transcript: '下载为 Markdown', @@ -951,11 +1193,202 @@ 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_skip: '跳过设置', + onboarding_skipped: '设置已跳过 — 使用现有配置。', + onboarding_open: '打开 Hermes', + onboarding_step_system_title: '系统检查', + onboarding_step_system_desc: '验证 Hermes Agent 与配置可见性。', + onboarding_step_setup_title: '提供商设置', + onboarding_step_setup_desc: '保存最小可用的 Hermes 提供商配置。', + onboarding_step_workspace_title: '工作区 + 模型', + onboarding_step_workspace_desc: '为新会话和聊天选择默认值。', + onboarding_step_password_title: '可选密码', + onboarding_step_password_desc: '在分享前为 Web UI 添加保护。', + onboarding_step_finish_title: '完成', + onboarding_step_finish_desc: '确认信息并进入应用。', + onboarding_notice_system_ready: 'Hermes Agent 看起来可从 Web UI 访问。', + onboarding_notice_system_unavailable: 'Hermes Agent 尚未完全可用。Bootstrap 可以安装它,但提供商设置可能仍需要终端。', + onboarding_check_agent: 'Hermes Agent', + onboarding_check_agent_ready: '已检测且可导入', + onboarding_check_agent_missing: '缺失或仅部分可导入', + onboarding_check_password: '密码', + onboarding_check_password_enabled: '已启用', + onboarding_check_password_disabled: '尚未启用', + onboarding_check_provider: '提供商配置', + onboarding_check_provider_ready: '可开始聊天', + onboarding_check_provider_partial: '已保存但不完整', + onboarding_check_provider_pending: '需要验证', + onboarding_config_file: '配置文件:', + onboarding_env_file: '.env 文件:', + onboarding_unknown: '未知', + onboarding_current_provider: '当前配置:', + onboarding_missing_imports: '缺失导入:', + onboarding_notice_setup_required: '请先在此选择一个简单的提供商路径。高级 OAuth 流程暂时仍建议在 Hermes CLI 中完成。', + onboarding_notice_setup_already_ready: '已检测到可用的 Hermes 提供商配置。你可以保留它,或在这里替换。', + onboarding_oauth_provider_ready_title: '提供商已完成认证', + onboarding_oauth_provider_ready_body: '此实例已配置为使用通过 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_skill_count: (count) => `${count} 个技能`, + profile_use: '使用', + profile_switch_title: '切换到此配置档', + profile_delete_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 +1577,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 +1586,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 +1651,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 +1662,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/index.html b/static/index.html index 1f79a94..cd47457 100644 --- a/static/index.html +++ b/static/index.html @@ -536,7 +536,7 @@
System
Instance version and access controls.
- v0.50.37 + v0.50.38
@@ -553,32 +553,6 @@
-