diff --git a/CHANGELOG.md b/CHANGELOG.md index cd14636b..15259a34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,14 @@ ## [Unreleased] +### Added + +- **Deep-link straight to a profile with `?profile=`.** Opening the WebUI with a `?profile=NAME` query parameter now switches to that profile on boot. The name is validated against the existing profile allowlist (a nonexistent profile boots normally, path-traversal / cross-profile attempts are rejected), the switch goes through the same access-enforced `switchToProfile`, and boot fails safe on anything invalid. Thanks @rodboev. (#5730, #5682) + ### Fixed +- **Russian localization refreshed and onboarding notes localized.** The `ru` bundle was refreshed and brought to full key parity with English (identical key sets across all locales), new update/workspace UI strings were propagated to every locale, and onboarding provider notes now resolve through a localized key (with the English text preserved as fallback). Thanks @DrMaks22. (#5778) + - **A pending prompt no longer renders twice across a context-compaction boundary.** On reload / reattach to an active turn, a synthetic `[CONTEXT COMPACTION]` marker (a user-role row that isn't a real submitted turn) placed after your prompt was treated as the latest user message, so the tail scan missed the actual prompt right before it and rendered the same pending prompt twice. The session-load and refresh/reconnect tail scans now skip compaction markers when locating the current user message, while completed assistant rows stay hard boundaries so genuinely-repeated prompts still render. Thanks @starship-s. (#5920) - **Sessions from Zed and other ACP (Agent Client Protocol) clients now show up in the sidebar.** An ACP-adapter session persists to the agent database with source `acp`, which was classified as `other` and fell through both sidebar buckets — so those conversations were completely invisible and unclickable. ACP sessions are now grouped with the CLI/TUI family (labelled "ACP"), with the same zero-message / ended-connection hiding rules as other interactive agent sessions. Thanks @ai-ag2026. (#5939) diff --git a/api/onboarding.py b/api/onboarding.py index 0c58c0b9..7af2910a 100644 --- a/api/onboarding.py +++ b/api/onboarding.py @@ -732,15 +732,18 @@ def _status_from_runtime(cfg: dict, imports_ok: bool) -> dict: ) chat_ready = bool(_HERMES_FOUND and imports_ok and provider_ready) + note_args: list[str] = [] if not _HERMES_FOUND or not imports_ok: state = "agent_unavailable" + note_key = "onboarding_notice_system_unavailable" note = ( "Hermes is not fully importable from the Web UI yet. Finish bootstrap or fix the " "agent install before provider setup will work." ) elif chat_ready: state = "ready" + note_key = "onboarding_notice_system_ready" provider_name = _PROVIDER_DISPLAY.get( provider, provider.title() if provider else "Hermes" ) @@ -748,11 +751,15 @@ def _status_from_runtime(cfg: dict, imports_ok: bool) -> dict: elif provider_configured: state = "provider_incomplete" if provider == "custom" and not base_url: + note_key = "onboarding_notice_custom_base_url_required" note = ( - "Hermes has a saved provider/model selection but still needs the " - "base URL and API key required to chat." + "Hermes has a saved provider/model selection, but the custom " + "provider still needs a base URL. Add the API key too if that " + "server requires one." ) elif provider not in _SUPPORTED_PROVIDER_SETUPS: + note_key = "onboarding_notice_provider_auth_required" + note_args = [provider] # OAuth / unsupported provider: avoid misleading "API key" wording. note = ( f"Provider '{provider}' is configured but not yet authenticated. " @@ -760,12 +767,14 @@ def _status_from_runtime(cfg: dict, imports_ok: bool) -> dict: "setup, then reload the Web UI." ) else: + note_key = "onboarding_notice_provider_api_key_required" note = ( "Hermes has a saved provider/model selection but still needs the " "API key required to chat." ) else: state = "needs_provider" + note_key = "onboarding_notice_provider_choice_required" note = "Hermes is installed, but you still need to choose a provider and save working credentials." return { @@ -774,6 +783,8 @@ def _status_from_runtime(cfg: dict, imports_ok: bool) -> dict: "chat_ready": chat_ready, "setup_state": state, "provider_note": note, + "provider_note_key": note_key, + "provider_note_args": note_args, "current_provider": provider or None, "current_model": model or None, "current_base_url": base_url or None, diff --git a/static/boot.js b/static/boot.js index 863225a6..6ee0f3c1 100644 --- a/static/boot.js +++ b/static/boot.js @@ -122,6 +122,9 @@ function _prefillHasDraftText(prefillIntent){ function _rootPrefillNeedsFreshComposer(urlSession, savedLocal, prefillIntent){ return !urlSession&&!!savedLocal&&_prefillHasDraftText(prefillIntent); } +function _profileQueryBlocksSavedLocalRestore(profileIntent, urlSession){ + return !!(profileIntent&&profileIntent.hasParam&&profileIntent.valid&&!urlSession); +} async function _applyComposerPrefillOnBoot(prefillIntent){ if(!prefillIntent||!prefillIntent.hasText) return; const msg=(typeof $==='function')?$('msg'):document.getElementById('msg'); @@ -328,6 +331,14 @@ function _setButtonTooltip(btn, text){ } } +function _uiText(key, fallback){ + if(typeof t==='function'){ + const val=t(key); + if(val&&val!==key) return val; + } + return fallback; +} + function syncWorkspacePanelUI(){ const {layout,panel,toggleBtn,edgeToggleBtn,collapseBtn}= _workspacePanelEls(); if(!layout||!panel)return; @@ -340,17 +351,21 @@ function syncWorkspacePanelUI(){ if(toggleBtn){ toggleBtn.classList.toggle('active',isOpen); toggleBtn.setAttribute('aria-pressed',isOpen?'true':'false'); - _setButtonTooltip(toggleBtn, isOpen?'Hide workspace panel':'Show workspace panel'); + const label=_uiText(isOpen?'workspace_panel_hide':'workspace_panel_show', isOpen?'Hide workspace panel':'Show workspace panel'); + _setButtonTooltip(toggleBtn, label); + toggleBtn.setAttribute('aria-label', label); toggleBtn.disabled=!canBrowse; } if(edgeToggleBtn){ edgeToggleBtn.classList.toggle('active',isOpen); edgeToggleBtn.setAttribute('aria-expanded',isOpen?'true':'false'); - _setButtonTooltip(edgeToggleBtn, isOpen?'Hide workspace panel':'Show workspace panel'); + const label=_uiText(isOpen?'workspace_panel_hide':'workspace_panel_show', isOpen?'Hide workspace panel':'Show workspace panel'); + _setButtonTooltip(edgeToggleBtn, label); + edgeToggleBtn.setAttribute('aria-label', label); edgeToggleBtn.disabled=!canBrowse; } if(collapseBtn){ - _setButtonTooltip(collapseBtn, isCompact?'Close workspace panel':'Hide workspace panel'); + _setButtonTooltip(collapseBtn, isCompact?_uiText('workspace_panel_close','Close workspace panel'):_uiText('workspace_panel_hide','Hide workspace panel')); } const hasSession=!!S.session; ['btnUpDir','btnNewFile','btnNewFolder','btnRefreshPanel'].forEach(id=>{ @@ -360,7 +375,9 @@ function syncWorkspacePanelUI(){ const clearBtn=$('btnClearPreview'); if(clearBtn){ clearBtn.disabled=!isOpen; - _setButtonTooltip(clearBtn, hasPreview?'Close preview':'Close'); + const label=hasPreview?_uiText('workspace_close_preview','Close preview'):_uiText('terminal_close','Close'); + _setButtonTooltip(clearBtn, label); + clearBtn.setAttribute('aria-label', label); if(!isCompact) clearBtn.style.display=''; } } @@ -2060,6 +2077,7 @@ $('modelSelect').onchange=async()=>{ const modelState=(typeof _modelStateForSelect==='function') ? _modelStateForSelect($('modelSelect'),selectedModel) : {model:selectedModel,model_provider:null}; + if(typeof clearProfileTransitionReasoningContext==='function') clearProfileTransitionReasoningContext(); if(typeof closeModelDropdown==='function') closeModelDropdown(); if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider); else try{localStorage.setItem('hermes-webui-model',modelState.model)}catch{} @@ -3382,6 +3400,31 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer; if(profileLabel) profileLabel.textContent=S.activeProfile||'default'; const titleLabel=$('titlebarProfileLabel'); if(titleLabel) titleLabel.textContent=S.activeProfile||'default'; + const profileIntent=(typeof _profileQueryIntentFromLocation==='function')?_profileQueryIntentFromLocation():null; + const _savedLocalBeforeProfileSwitch=localStorage.getItem('hermes-webui-session'); + const _profileSwitchProfileBefore=S.activeProfile||'default'; + const _profileSwitchIsDefaultBefore=!!S.activeProfileIsDefault; + let _profileSwitchCompleted=false; + let _profileSwitchChangedProfile=false; + if(profileIntent&&profileIntent.hasParam){ + try{ + if(profileIntent.valid){ + if(typeof switchToProfile==='function'){ + _profileSwitchCompleted=await switchToProfile(profileIntent.name)===true; + if(_profileSwitchCompleted){ + _profileSwitchChangedProfile=(S.activeProfile||'default')!==_profileSwitchProfileBefore||!!S.activeProfileIsDefault!==_profileSwitchIsDefaultBefore; + if(typeof _consumeProfileQueryParamFromLocation==='function') _consumeProfileQueryParamFromLocation(); + } + } + }else{ + console.warn('[boot] ignored invalid profile query', profileIntent.name); + if(typeof _consumeProfileQueryParamFromLocation==='function') _consumeProfileQueryParamFromLocation(); + } + }catch(e){ + console.warn('[boot] profile query switch failed', e); + } + } + if(typeof fetchReasoningChip==='function'&&(!_profileSwitchCompleted||!_profileSwitchChangedProfile)) fetchReasoningChip(); // Fetch available models without blocking session restore. The static HTML // options are enough for first paint; the dynamic provider list can settle // after the saved session is visible. @@ -3474,8 +3517,6 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer; // re-run when the browser restores the page from bfcache. const _srch = document.getElementById('sessionSearch'); if (_srch) _srch.value = ''; if (typeof syncSessionSearchClear === 'function') syncSessionSearchClear(); - // Initialize reasoning chip on boot (fixes #1103 — chip hidden until session load) - if(typeof fetchReasoningChip==='function') fetchReasoningChip(); if(typeof refreshProviderQuotaIndicator==='function') refreshProviderQuotaIndicator(); const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null; const pwaLaunchAction=(window.HermesPWA&&typeof window.HermesPWA.launchAction==='function') @@ -3496,6 +3537,12 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer; syncTopbar();syncWorkspacePanelState();await renderSessionList();await _finalizeComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE();return; }catch(e){console.warn('[pwa] new-chat launch action failed', e);} } + const _profileQueryBlocksSavedLocal=_profileQueryBlocksSavedLocalRestore(profileIntent, urlSession); + if(_profileQueryBlocksSavedLocal&&_profileSwitchCompleted&&_profileSwitchChangedProfile){ + try{ + if(localStorage.getItem('hermes-webui-session')===_savedLocalBeforeProfileSwitch) localStorage.removeItem('hermes-webui-session'); + }catch(_){} + } const savedLocal=localStorage.getItem('hermes-webui-session'); const saved=urlSession||savedLocal; if(saved){ diff --git a/static/i18n.js b/static/i18n.js index 155c7e4a..63723eb1 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -450,6 +450,16 @@ const LOCALES = { workspace_empty_dir: 'This workspace is empty.', workspace_show_hidden_files: 'Show hidden files', workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.', + workspace_panel_show: 'Show workspace panel', + workspace_panel_hide: 'Hide workspace panel', + workspace_panel_close: 'Close workspace panel', + workspace_panel_toggle: 'Toggle workspace files panel', + workspace_panel_views: 'Workspace panel views', + workspace_parent_directory: 'Parent directory', + workspace_upload_file: 'Upload file', + workspace_close_preview: 'Close preview', + workspace_files_tab: 'Files', + workspace_artifacts_tab: 'Artifacts', workspace_hidden_files_visible: 'hidden visible', workspace_hidden_files_visible_title: 'Hidden files are visible — click for options', workspace_options: 'Workspace options', @@ -508,20 +518,20 @@ const LOCALES = { session_copy_link_desc: 'Copy a direct link to this conversation', session_link_copied: 'Conversation link copied to clipboard', session_link_copy_failed: 'Failed to copy conversation link: ', - share_session: 'Share', - share_session_tooltip: 'Create a public read-only share link', - share_session_status_active: 'Public share active', - share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.', - share_session_copy_existing: 'Copy existing', - share_session_refresh_snapshot: 'Refresh snapshot', - share_session_link_copied: 'Share link copied to clipboard', - share_session_created: 'Public share link created', - share_session_failed: 'Share failed: ', - stop_sharing_session: 'Stop sharing', - stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation', - stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.', - share_session_revoked: 'Public share link revoked', - share_session_revoke_failed: 'Failed to revoke share link: ', + share_session: 'Поделиться', + share_session_tooltip: 'Создать публичную ссылку только для чтения', + share_session_status_active: 'Публичная ссылка активна', + share_session_existing_confirm: 'У этой беседы уже есть публичная ссылка только для чтения.\n\nВыберите «Копировать текущую», чтобы использовать её снова, или «Обновить снимок», чтобы пересобрать публичный снимок из последних видимых сообщений.', + share_session_copy_existing: 'Копировать текущую', + share_session_refresh_snapshot: 'Обновить снимок', + share_session_link_copied: 'Ссылка скопирована в буфер обмена', + share_session_created: 'Публичная ссылка создана', + share_session_failed: 'Не удалось создать ссылку: ', + stop_sharing_session: 'Закрыть доступ', + stop_sharing_session_tooltip: 'Отозвать публичную ссылку только для чтения для этой беседы', + stop_sharing_session_confirm: 'Отозвать эту публичную ссылку? Все, кто откроет старую ссылку, увидят, что она больше недоступна.', + share_session_revoked: 'Публичная ссылка отозвана', + share_session_revoke_failed: 'Не удалось отозвать ссылку: ', new_file_prompt: 'New file name (e.g. notes.md):', new_file: 'New File', new_folder: 'New Folder', @@ -621,6 +631,15 @@ const LOCALES = { settings_check_now: 'Check now', settings_checking: 'Checking\u2026', settings_up_to_date: 'Up to date \u2713', + update_later: 'Later', + update_now: 'Update Now', + update_updating: 'Updating…', + update_force: 'Force update', + update_clear_lock_retry: 'Clear lock and retry update', + update_hard_refresh_now: 'Hard refresh now', + update_no_target: 'No update target selected. Refresh update status and retry.', + update_failed_network: 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.', + update_failed_prefix: 'Update failed: ', settings_updates_available: '{count} update(s) available', settings_updates_disabled: 'Update checks disabled', settings_update_check_failed: 'Update check failed', @@ -1328,6 +1347,10 @@ const LOCALES = { onboarding_current_provider: 'Current setup:', onboarding_missing_imports: 'Missing imports:', onboarding_notice_setup_required: 'Choose a simple provider path here. Advanced OAuth flows still belong in the Hermes CLI for now.', + onboarding_notice_provider_choice_required: 'Hermes is installed, but you still need to choose a provider and save working credentials.', + onboarding_notice_custom_base_url_required: 'Hermes has a saved provider/model selection, but the custom provider still needs a base URL. Add the API key too if that server requires one.', + onboarding_notice_provider_auth_required: 'Provider "{0}" is configured but not yet authenticated. Run "hermes auth" or "hermes model" in a terminal to complete setup, then reload the Web UI.', + onboarding_notice_provider_api_key_required: 'Hermes has a saved provider/model selection but still needs the API key required to chat.', onboarding_notice_setup_already_ready: 'A working Hermes provider setup is already detected. You can keep it or replace it here.', onboarding_oauth_provider_ready_title: 'Provider already authenticated', onboarding_oauth_provider_ready_body: 'This instance is configured to use an OAuth provider ({provider}) that was set up via the Hermes CLI. No API key is needed here — click Continue to finish setup.', @@ -2169,6 +2192,16 @@ const LOCALES = { workspace_empty_dir: 'Questo workspace è vuoto.', workspace_show_hidden_files: 'Mostra file nascosti', workspace_show_hidden_files_desc: "Includi .DS_Store, .git, node_modules e altri file nascosti/di sistema nell'albero dei file.", + workspace_panel_show: 'Show workspace panel', + workspace_panel_hide: 'Hide workspace panel', + workspace_panel_close: 'Close workspace panel', + workspace_panel_toggle: 'Toggle workspace files panel', + workspace_panel_views: 'Workspace panel views', + workspace_parent_directory: 'Parent directory', + workspace_upload_file: 'Upload file', + workspace_close_preview: 'Close preview', + workspace_files_tab: 'Files', + workspace_artifacts_tab: 'Artifacts', workspace_hidden_files_visible: 'nascosti visibili', workspace_hidden_files_visible_title: 'I file nascosti sono visibili — clicca per opzioni', workspace_options: 'Opzioni workspace', @@ -2340,6 +2373,15 @@ const LOCALES = { settings_check_now: 'Verifica ora', settings_checking: 'Verifica in corso\u2026', settings_up_to_date: 'Aggiornato \u2713', + update_later: 'Later', + update_now: 'Update Now', + update_updating: 'Updating…', + update_force: 'Force update', + update_clear_lock_retry: 'Clear lock and retry update', + update_hard_refresh_now: 'Hard refresh now', + update_no_target: 'No update target selected. Refresh update status and retry.', + update_failed_network: 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.', + update_failed_prefix: 'Update failed: ', settings_updates_available: '{count} aggiornamento/i disponibile/i', settings_updates_disabled: 'Verifica aggiornamenti disabilitata', settings_update_check_failed: 'Verifica aggiornamenti fallita', @@ -3035,6 +3077,10 @@ const LOCALES = { onboarding_current_provider: 'Configurazione attuale:', onboarding_missing_imports: 'Import mancanti:', onboarding_notice_setup_required: 'Scegli un percorso provider semplice qui. I flussi OAuth avanzati rimangono nella CLI di Hermes per ora.', + onboarding_notice_provider_choice_required: 'Hermes è installato, ma devi ancora scegliere un provider e salvare credenziali funzionanti.', + onboarding_notice_custom_base_url_required: 'Hermes ha già una selezione provider/modello salvata, ma il provider personalizzato richiede ancora un URL base. Aggiungi anche la chiave API se quel server la richiede.', + onboarding_notice_provider_auth_required: 'Il provider "{0}" è configurato ma non ancora autenticato. Esegui "hermes auth" o "hermes model" in un terminale per completare la configurazione, poi ricarica Web UI.', + onboarding_notice_provider_api_key_required: 'Hermes ha già una selezione provider/modello salvata, ma serve ancora la chiave API necessaria per chattare.', onboarding_notice_setup_already_ready: 'Una configurazione provider Hermes funzionante è già stata rilevata. Puoi mantenerla o sostituirla qui.', onboarding_oauth_provider_ready_title: 'Provider già autenticato', onboarding_oauth_provider_ready_body: 'Questa istanza è configurata per usare un provider OAuth ({provider}) impostato tramite la CLI di Hermes. Nessuna chiave API necessaria — clicca Continua per finire.', @@ -3879,6 +3925,16 @@ const LOCALES = { workspace_empty_dir: 'このワークスペースは空です。', workspace_show_hidden_files: '隠しファイルを表示', workspace_show_hidden_files_desc: '.DS_Store、.git、node_modules などの隠しファイル・システムファイルをファイルツリーに表示します。', + workspace_panel_show: 'Show workspace panel', + workspace_panel_hide: 'Hide workspace panel', + workspace_panel_close: 'Close workspace panel', + workspace_panel_toggle: 'Toggle workspace files panel', + workspace_panel_views: 'Workspace panel views', + workspace_parent_directory: 'Parent directory', + workspace_upload_file: 'Upload file', + workspace_close_preview: 'Close preview', + workspace_files_tab: 'Files', + workspace_artifacts_tab: 'Artifacts', workspace_hidden_files_visible: '隠しファイル表示中', workspace_hidden_files_visible_title: '隠しファイルが表示されています — クリックでオプション', workspace_options: 'ワークスペースオプション', @@ -4050,6 +4106,15 @@ const LOCALES = { settings_check_now: '今すぐ確認', settings_checking: '確認中…', settings_up_to_date: '最新です ✓', + update_later: 'Later', + update_now: 'Update Now', + update_updating: 'Updating…', + update_force: 'Force update', + update_clear_lock_retry: 'Clear lock and retry update', + update_hard_refresh_now: 'Hard refresh now', + update_no_target: 'No update target selected. Refresh update status and retry.', + update_failed_network: 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.', + update_failed_prefix: 'Update failed: ', settings_updates_available: '{count} 件のアップデートあり', settings_update_check_failed: 'アップデート確認失敗', settings_update_no_git: 'アップデートを確認できません(gitチェックアウトではありません)', @@ -4750,6 +4815,10 @@ const LOCALES = { onboarding_current_provider: '現在の設定:', onboarding_missing_imports: '不足しているインポート:', onboarding_notice_setup_required: 'シンプルなプロバイダ経路をここで選んでください。高度な OAuth フローは現状 Hermes CLI 側で扱います。', + onboarding_notice_provider_choice_required: 'Hermes はインストール済みですが、プロバイダを選び、有効な認証情報を保存する必要があります。', + onboarding_notice_custom_base_url_required: 'Hermes にはプロバイダとモデルの選択が保存されていますが、カスタムプロバイダにはまだベース URL が必要です。そのサーバーが要求する場合は API キーも追加してください。', + onboarding_notice_provider_auth_required: 'プロバイダ「{0}」は設定済みですが、まだ認証されていません。ターミナルで "hermes auth" または "hermes model" を実行して設定を完了し、Web UI を再読み込みしてください。', + onboarding_notice_provider_api_key_required: 'Hermes にはプロバイダとモデルの選択が保存されていますが、チャットに必要な API キーがまだ必要です。', onboarding_notice_setup_already_ready: '動作する Hermes プロバイダ設定が既に検出されています。維持するか、ここで置き換えできます。', onboarding_oauth_provider_ready_title: 'プロバイダは認証済み', onboarding_oauth_provider_ready_body: 'このインスタンスは Hermes CLI で設定された OAuth プロバイダ ({provider}) を使用します。APIキーは不要 — 続けるをクリックしてセットアップを完了してください。', @@ -5205,69 +5274,69 @@ const LOCALES = { diff_loading: 'Загрузка diff', diff_error: 'Не удалось загрузить файл патча', diff_too_large: 'Файл патча слишком большой для отображения', - tree_view: '樹狀', - raw_view: '原始', - parse_failed_note: 'parse failed', + tree_view: 'Дерево', + raw_view: 'Исходный текст', + parse_failed_note: 'не удалось разобрать', you: 'Вы', - mcp_servers_title: 'MCP 伺服器', - mcp_servers_desc: '管理 config.yaml 中設定的 MCP 伺服器。', - mcp_no_servers: '未設定 MCP 伺服器。', - mcp_add_server: '+ 新增伺服器', - mcp_field_name: '伺服器名稱', - mcp_transport_label: '傳輸類型', - mcp_field_command: '命令', - mcp_field_args: '參數(逗號分隔)', + mcp_servers_title: 'Серверы MCP', + mcp_servers_desc: 'Просмотр серверов MCP, настроенных в config.yaml.', + mcp_no_servers: 'Серверы MCP не настроены.', + mcp_add_server: '+ Добавить сервер', + mcp_field_name: 'Имя сервера', + mcp_transport_label: 'Тип транспорта', + mcp_field_command: 'Команда', + mcp_field_args: 'Аргументы через запятую', mcp_field_url: 'URL', - mcp_field_timeout: '逾時(秒)', - mcp_save: '儲存', - mcp_cancel: '取消', - mcp_name_required: '需要伺服器名稱。', - mcp_url_required: 'HTTP 傳輸需要 URL。', - mcp_command_required: 'stdio 傳輸需要命令。', - mcp_saved: 'MCP 伺服器已儲存。', - mcp_save_failed: '儲存 MCP 伺服器失敗。', - mcp_delete_confirm_title: '刪除 MCP 伺服器', - mcp_delete_confirm_message: '刪除 MCP 伺服器「{0}」?此操作無法撤銷。', - mcp_deleted: 'MCP 伺服器已刪除。', - mcp_delete_failed: '刪除 MCP 伺服器失敗。', - mcp_load_failed: '載入 MCP 伺服器失敗。', - mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.', - mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.', - mcp_status_active: 'Active', - mcp_status_configured: 'Configured', - mcp_status_disabled: 'Disabled', - mcp_status_invalid_config: 'Invalid config', - mcp_status_unknown: 'Unknown', - mcp_tool_count: '{0} tools', - mcp_enabled_yes: 'Enabled', - mcp_enabled_no: 'Disabled', - mcp_enable_server: 'Enable this MCP server', - mcp_disable_server: 'Disable this MCP server', - mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, - mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, - mcp_toggle_failed: 'Failed to update MCP server.', - mcp_tools_title: 'MCP Tools', - mcp_tools_desc: 'Search known tools across active MCP servers.', - mcp_tools_search_placeholder: 'Search tools by name, server, or description…', - mcp_tools_no_tools: 'No MCP tools are available from the active runtime inventory.', - mcp_tools_no_matches: 'No MCP tools match your search.', - mcp_tools_load_failed: 'Failed to load MCP tools.', - mcp_tools_schema_empty: 'No schema parameters.', - mcp_tools_runtime_note: 'Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.', - mcp_tools_summary_no_matches: (query,total) => `No MCP tools matching “${query}” (${total} total MCP tools).`, - mcp_tools_summary_none: 'No MCP tools to show.', - mcp_tools_summary_matching: (query) => ` matching “${query}”`, - mcp_tools_summary_total_note: (total) => ` (${total} total MCP tools)`, - mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `Showing ${start}-${end} of ${filtered} MCP tools${searchNote}${totalNote}. Page ${page} of ${pages}.`, - mcp_tools_page_size_prefix: 'Show', - mcp_tools_page_size_suffix: 'per page', - mcp_tools_per_page_aria: 'MCP tools per page', - mcp_tools_inactive_configured_servers: (servers) => `Configured but inactive in this WebUI runtime: ${servers}.`, - mcp_tools_pagination_label: 'MCP tools pagination', - mcp_tools_previous_page: '‹ Prev', - mcp_tools_previous_page_aria: 'Previous MCP tools page', - mcp_tools_next_page: 'Next ›', - mcp_tools_next_page_aria: 'Next MCP tools page', + mcp_field_timeout: 'Тайм-аут (секунды)', + mcp_save: 'Сохранить', + mcp_cancel: 'Отмена', + mcp_name_required: 'Укажите имя сервера.', + mcp_url_required: 'Для HTTP-транспорта нужен URL.', + mcp_command_required: 'Для stdio-транспорта нужна команда.', + mcp_saved: 'Сервер MCP сохранён.', + mcp_save_failed: 'Не удалось сохранить сервер MCP.', + mcp_delete_confirm_title: 'Удалить сервер MCP', + mcp_delete_confirm_message: 'Удалить сервер MCP «{0}»? Это действие нельзя отменить.', + mcp_deleted: 'Сервер MCP удалён.', + mcp_delete_failed: 'Не удалось удалить сервер MCP.', + mcp_load_failed: 'Не удалось загрузить серверы MCP.', + mcp_restart_hint: 'Изменения серверов пока доступны здесь только для чтения. Отредактируйте config.yaml и перезапустите Hermes, чтобы применить их.', + mcp_toggle_followup: 'Включение и отключение серверов отложено до явной семантики перезагрузки MCP.', + mcp_status_active: 'Активен', + mcp_status_configured: 'Настроен', + mcp_status_disabled: 'Отключён', + mcp_status_invalid_config: 'Неверная конфигурация', + mcp_status_unknown: 'Неизвестно', + mcp_tool_count: 'Инструментов: {0}', + mcp_enabled_yes: 'Включён', + mcp_enabled_no: 'Отключён', + mcp_enable_server: 'Включить этот сервер MCP', + mcp_disable_server: 'Отключить этот сервер MCP', + mcp_enabled_toast: (name) => `Сервер MCP «${name}» включён.`, + mcp_disabled_toast: (name) => `Сервер MCP «${name}» отключён.`, + mcp_toggle_failed: 'Не удалось обновить сервер MCP.', + mcp_tools_title: 'Инструменты MCP', + mcp_tools_desc: 'Поиск известных инструментов на активных серверах MCP.', + mcp_tools_search_placeholder: 'Искать по имени, серверу или описанию…', + mcp_tools_no_tools: 'В активной среде выполнения нет доступных инструментов MCP.', + mcp_tools_no_matches: 'Нет инструментов MCP по вашему запросу.', + mcp_tools_load_failed: 'Не удалось загрузить инструменты MCP.', + mcp_tools_schema_empty: 'Параметры схемы отсутствуют.', + mcp_tools_runtime_note: 'Инвентарь инструментов использует только уже известные активные данные среды выполнения MCP; WebUI не запускает и не проверяет серверы.', + mcp_tools_summary_no_matches: (query, total) => `Инструментов MCP по запросу «${query}» нет (всего: ${total}).`, + mcp_tools_summary_none: 'Нет инструментов MCP для показа.', + mcp_tools_summary_matching: (query) => ` по запросу «${query}»`, + mcp_tools_summary_total_note: (total) => ` (всего инструментов MCP: ${total})`, + mcp_tools_summary_showing: (start, end, filtered, searchNote, totalNote, page, pages) => `Показаны ${start}–${end} из ${filtered} инструментов MCP${searchNote}${totalNote}. Страница ${page} из ${pages}.`, + mcp_tools_page_size_prefix: 'Показывать', + mcp_tools_page_size_suffix: 'на странице', + mcp_tools_per_page_aria: 'Инструментов MCP на странице', + mcp_tools_inactive_configured_servers: (servers) => `Настроены, но неактивны в этой среде WebUI: ${servers}.`, + mcp_tools_pagination_label: 'Пагинация инструментов MCP', + mcp_tools_previous_page: '‹ Назад', + mcp_tools_previous_page_aria: 'Предыдущая страница инструментов MCP', + mcp_tools_next_page: 'Далее ›', + mcp_tools_next_page_aria: 'Следующая страница инструментов MCP', thinking: 'Думаю', expand_all: 'Развернуть всё', collapse_all: 'Свернуть всё', @@ -5286,7 +5355,7 @@ const LOCALES = { approval_btn_deny: 'Запретить', approval_btn_deny_title: 'Запретить — не выполнять эту команду', approval_responding: 'Отвечаю…', - approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.', + approval_gateway_unsupported: 'Для подтверждений нужен более новый Gateway. Обновите подключённый Hermes Gateway, чтобы включить их.', approval_gateway_unsupported_label: 'Одобрение не поддерживается', untitled: 'Без названия', n_messages: (n) => `${n} сообщений`, @@ -5308,26 +5377,25 @@ const LOCALES = { `"${m}" может не работать с вашим настроенным провайдером (${p}). Всё равно отправить или запустите \`hermes model\` в терминале, чтобы переключиться.`, provider_mismatch_label: 'Несовпадение провайдера', gateway_auth_label: 'Сбой аутентификации Gateway', - gateway_start: 'Start', - gateway_stop: 'Stop', - gateway_restart: 'Restart', - gateway_start_success: 'Gateway start completed.', - gateway_stop_success: 'Gateway stop completed.', - gateway_restart_success: 'Gateway restart completed.', - gateway_start_failed: 'Gateway start failed', - gateway_stop_failed: 'Gateway stop failed', - gateway_restart_failed: 'Gateway restart failed', - gateway_running: 'Running', - gateway_session: 'session', - gateway_sessions: 'sessions', - gateway_status_load_failed: 'Failed to load gateway status', + gateway_start: 'Запустить', + gateway_stop: 'Остановить', + gateway_restart: 'Перезапустить', + gateway_start_success: 'Gateway запущен.', + gateway_stop_success: 'Gateway остановлен.', + gateway_restart_success: 'Gateway перезапущен.', + gateway_start_failed: 'Не удалось запустить Gateway', + gateway_stop_failed: 'Не удалось остановить Gateway', + gateway_restart_failed: 'Не удалось перезапустить Gateway', + gateway_running: 'Работает', + gateway_session: 'сеанс', + gateway_sessions: 'сеансов', + gateway_status_load_failed: 'Не удалось загрузить статус Gateway', model_not_found_label: 'Модель не найдена', model_custom_label: 'Пользовательский ID модели', model_custom_placeholder: 'например, openai/gpt-5.4', model_show_all_models: 'Показать все {0} моделей', cmd_help: 'Показать доступные команды', cmd_clear: 'Очистить сообщения беседы', - cmd_compact: 'Сжать контекст беседы', cmd_model: 'Переключить модель (например, /model gpt-4o)', cmd_workspace: 'Переключить рабочее пространство по названию', cmd_terminal: 'Открыть терминал рабочей области', @@ -5361,21 +5429,21 @@ const LOCALES = { no_active_session: 'Нет активной сессии', cmd_queue: 'Поставить сообщение в очередь на следующий оборот', cmd_goal: 'Задать или проверить постоянную цель', - goal_evaluating_progress: 'Evaluating goal progress…', - goal_working_toward: 'Working toward goal…', - goal_continuing_toast: 'Continuing toward goal…', - goal_status_none: 'No active goal. Set one with /goal .', - goal_status_active: (turns, max_turns, goal) => `⊙ Goal (active, ${turns}/${max_turns} turns): ${goal}`, - goal_status_paused: (turns, max_turns, reason, goal) => `⏸ Goal (paused, ${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`, - goal_status_done: (turns, max_turns, goal) => `✓ Goal done (${turns}/${max_turns}): ${goal}`, - goal_set: (turns, goal) => `⊙ Goal set (${turns}-turn budget): ${goal}`, - goal_paused: (goal) => `⏸ Goal paused: ${goal}`, - goal_resumed: (goal) => `▶ Goal resumed: ${goal}`, - goal_cleared: 'Goal cleared.', - goal_no_goal: 'No active goal.', - goal_achieved: (reason) => `✓ Goal achieved: ${reason}`, - goal_paused_budget_exhausted: (turns, max_turns) => `⏸ Goal paused — ${turns}/${max_turns} turns used. Use /goal resume to keep going, or /goal clear to stop.`, - goal_continuing: (turns, max_turns, reason) => `↻ Continuing toward goal (${turns}/${max_turns}): ${reason}`, + goal_evaluating_progress: 'Оцениваю прогресс по цели…', + goal_working_toward: 'Работаю над целью…', + goal_continuing_toast: 'Продолжаю работу над целью…', + goal_status_none: 'Активной цели нет. Задайте её командой /goal <текст>.', + goal_status_active: (turns, max_turns, goal) => `⊙ Цель активна (${turns}/${max_turns} ходов): ${goal}`, + goal_status_paused: (turns, max_turns, reason, goal) => `⏸ Цель на паузе (${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`, + goal_status_done: (turns, max_turns, goal) => `✓ Цель выполнена (${turns}/${max_turns}): ${goal}`, + goal_set: (turns, goal) => `⊙ Цель задана (бюджет: ${turns} ходов): ${goal}`, + goal_paused: (goal) => `⏸ Цель на паузе: ${goal}`, + goal_resumed: (goal) => `▶ Цель возобновлена: ${goal}`, + goal_cleared: 'Цель очищена.', + goal_no_goal: 'Активной цели нет.', + goal_achieved: (reason) => `✓ Цель достигнута: ${reason}`, + goal_paused_budget_exhausted: (turns, max_turns) => `⏸ Цель приостановлена — использовано ${turns}/${max_turns} ходов. Используйте /goal resume, чтобы продолжить, или /goal clear, чтобы остановиться.`, + goal_continuing: (turns, max_turns, reason) => `↻ Продолжаю работу над целью (${turns}/${max_turns}): ${reason}`, cmd_interrupt: 'Прервать текущий оборот и отправить новое сообщение', cmd_steer: 'Направить агента исправлением (переходит к прерыванию)', cmd_queue_no_msg: 'Использование: /queue <сообщение>', @@ -5388,16 +5456,16 @@ const LOCALES = { cmd_steer_delivered: 'Steer доставлен — агент увидит его в следующем ответе инструмента', steer_leftover_queued: 'Steer поставлен в очередь на следующий ход', busy_steer_fallback: 'Steer недоступен — черновик восстановлен', - steer_fail_no_cached_agent: 'No cached agent is available for steering this run', - steer_fail_agent_lacks_steer: 'This agent version does not support steer', - steer_fail_session_not_found: 'Session not found — it may have expired', - steer_fail_not_running: 'Agent is not currently running', - steer_fail_stream_dead: 'The agent stream has ended', - steer_fail_steer_error: 'Steer delivery failed — the agent may have finished', - steer_fail_network_error: 'Network error — check your connection', - steer_fail_unknown: 'Steer unavailable', - steer_recovery_retry: 'Retry', - steer_recovery_dismiss: 'Dismiss', + steer_fail_no_cached_agent: 'Для этой задачи нет кэшированного агента, которому можно отправить steer', + steer_fail_agent_lacks_steer: 'Эта версия агента не поддерживает steer', + steer_fail_session_not_found: 'Сеанс не найден — возможно, он истёк', + steer_fail_not_running: 'Агент сейчас не работает', + steer_fail_stream_dead: 'Поток агента завершён', + steer_fail_steer_error: 'Не удалось доставить steer — возможно, агент уже завершил работу', + steer_fail_network_error: 'Ошибка сети — проверьте подключение', + steer_fail_unknown: 'Steer недоступен', + steer_recovery_retry: 'Повторить', + steer_recovery_dismiss: 'Скрыть', busy_interrupt_confirm: 'Прервано — отправка нового сообщения', settings_label_default_message_mode: 'Режим сообщений по умолчанию', settings_desc_default_message_mode: 'Определяет поведение при отправке сообщения во время работы агента. Очередь ждёт; Прерывание отменяет и начинает заново; Steer внедряет коррекцию без прерывания. Если Steer недоступен, черновик восстанавливается, чтобы вы могли выбрать следующее действие.', @@ -5410,14 +5478,24 @@ const LOCALES = { settings_default_message_mode_queue: 'Поставить в очередь', settings_default_message_mode_interrupt: 'Прервать текущий оборот', settings_default_message_mode_steer: 'Steer (коррекция в середине хода)', - settings_label_busy_placeholder_hint: 'Show busy placeholder hint', - settings_label_new_chat_on_workspace_switch: 'Start a new chat when switching workspace', - settings_desc_new_chat_on_workspace_switch: 'When on, selecting a different workspace starts a fresh conversation bound to that workspace and leaves your current chat on its original workspace, avoiding stale cross-workspace context. When off (default), switching workspace changes the current workspace in place.', - workspace_switched_new_chat: 'Started a new chat in {0}', - settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.', - composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer', - composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer', - composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt', + settings_label_busy_placeholder_hint: 'Показывать подсказку занятости в поле ввода', + settings_label_new_chat_on_workspace_switch: 'Начинать новый чат при смене рабочего пространства', + settings_desc_new_chat_on_workspace_switch: 'Когда включено, выбор другого рабочего пространства начинает новую беседу, привязанную к нему, и оставляет текущий чат в исходном рабочем пространстве, чтобы избежать устаревшего межпространственного контекста. Когда выключено (по умолчанию), смена рабочего пространства меняет его в текущем чате.', + workspace_switched_new_chat: 'Начат новый чат в {0}', + settings_desc_busy_placeholder_hint: 'Когда сеанс занят, а черновик пуст, поле ввода показывает подсказку с текущим действием.', + composer_placeholder_busy_queue: 'Enter = очередь | /interrupt | /background | /steer', + composer_placeholder_busy_interrupt: 'Enter = прервать | /queue | /background | /steer', + composer_placeholder_busy_steer: 'Enter = правка | /queue | /background | /interrupt', + slash_skill_badge: 'Навык', + slash_skill_desc: 'Применить этот навык', + cmd_stop: 'Остановить текущий ответ', + cmd_title: 'Показать или изменить название сеанса', + cmd_retry: 'Отправить последнее сообщение заново', + cmd_undo: 'Удалить последний обмен', + cmd_btw: 'Задать побочный вопрос (временно)', + cmd_btw_usage: '/btw <вопрос> — задать побочный вопрос с контекстом сеанса', + cmd_background: 'Запустить промпт в фоне', + cmd_background_usage: '/background <промпт> — запустить параллельно, не блокируя чат', process_wakeup_label: 'Фоновое пробуждение', no_personalities: 'Личности не найдены (добавьте их в ~/.hermes/personalities/)', @@ -5470,7 +5548,7 @@ const LOCALES = { session_toolsets_configured_servers: 'Настроенные серверы MCP', session_toolsets_loading_servers: 'Загрузка настроенных серверов...', session_toolsets_no_configured_servers: 'Нет настроенных серверов MCP', - session_toolsets_placeholder: 'tool1, tool2, \u2026', + session_toolsets_placeholder: 'tool1, tool2, …', session_toolsets_apply: 'Применить', session_toolsets_clear: 'Использовать значения по умолчанию', session_toolsets_applied: 'Наборы инструментов обновлены', @@ -5490,10 +5568,20 @@ const LOCALES = { settings_autosave_retry: 'Повторить', workspace_empty_dir: 'Это рабочее пространство пусто.', workspace_show_hidden_files: 'Показывать скрытые файлы', - workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.', - workspace_hidden_files_visible: 'hidden visible', - workspace_hidden_files_visible_title: 'Hidden files are visible — click for options', - workspace_options: 'Workspace options', + workspace_show_hidden_files_desc: 'Показывать .DS_Store, .git, node_modules и другие скрытые или системные файлы в дереве.', + workspace_panel_show: 'Показать панель рабочей области', + workspace_panel_hide: 'Скрыть панель рабочей области', + workspace_panel_close: 'Закрыть панель рабочей области', + workspace_panel_toggle: 'Переключить панель файлов рабочей области', + workspace_panel_views: 'Виды панели рабочей области', + workspace_parent_directory: 'Родительская папка', + workspace_upload_file: 'Загрузить файл', + workspace_close_preview: 'Закрыть предпросмотр', + workspace_files_tab: 'Файлы', + workspace_artifacts_tab: 'Артефакты', + workspace_hidden_files_visible: 'скрытые видны', + workspace_hidden_files_visible_title: 'Скрытые файлы видны — нажмите, чтобы открыть параметры', + workspace_options: 'Параметры рабочей области', workspace_empty_no_path: 'Рабочее пространство не выбрано. Настройте его в Настройки → Рабочее пространство.', available_personalities: 'Доступные личности:', personality_switch_hint: '\n\nИспользуйте `/personality ` для переключения или `/personality none` для сброса.', @@ -5535,11 +5623,11 @@ const LOCALES = { file_open_failed: 'Не удалось открыть файл', downloading: (name) => `Скачиваю ${name}…`, double_click_rename: 'Дважды щёлкните, чтобы переименовать', - symlink_link_to: 'Symlink → {target}', - external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?', - external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.', - external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.', - session_rename_failed_no_row: 'Could not start rename — row not found.', + symlink_link_to: 'Символическая ссылка → {target}', + external_link_open_confirm: 'Эта символическая ссылка указывает за пределы рабочей области. Открыть её во внешнем режиме только для чтения в этой сессии браузера?', + external_link_read_only: 'Этот внешний просмотр доступен только для чтения. Создание, переименование, перемещение, удаление, загрузка, показ в файловом менеджере и редактирование здесь отключены.', + external_link_grant_expired: 'Срок доступа к внешнему просмотру истёк. Снова откройте escape-target symlink, чтобы авторизовать его.', + session_rename_failed_no_row: 'Не удалось начать переименование — строка не найдена.', renamed_to: 'Переименовано в ', rename_failed: 'Не удалось переименовать: ', moved_to: 'Перемещено в ', @@ -5615,15 +5703,15 @@ const LOCALES = { settings_save_btn: 'Сохранить настройки', settings_label_model: 'Модель по умолчанию', settings_desc_model: 'Используется для новых бесед. Существующие беседы сохраняют выбранную модель.', - settings_main_advanced_button_aria: 'Advanced options for main model', - settings_main_advanced_title: 'Main model options', - settings_main_advanced_subtitle: 'Advanced config for the default chat model.', - settings_main_advanced_saved: 'Main model options saved', - settings_main_advanced_save_failed: 'Failed to save main model options', - settings_main_advanced_service_tier: 'Service tier', - settings_main_advanced_service_tier_desc: 'Optional request setting for OpenAI-family providers.', - settings_main_advanced_service_tier_default: 'Default / off', - settings_main_advanced_service_tier_priority: 'Priority (fast)', + settings_main_advanced_button_aria: 'Дополнительные параметры основной модели', + settings_main_advanced_title: 'Параметры основной модели', + settings_main_advanced_subtitle: 'Дополнительная конфигурация модели чата по умолчанию.', + settings_main_advanced_saved: 'Параметры основной модели сохранены', + settings_main_advanced_save_failed: 'Не удалось сохранить параметры основной модели', + settings_main_advanced_service_tier: 'Уровень обслуживания', + settings_main_advanced_service_tier_desc: 'Необязательный параметр запроса для провайдеров семейства OpenAI.', + settings_main_advanced_service_tier_default: 'По умолчанию / выкл.', + settings_main_advanced_service_tier_priority: 'Priority (быстро)', settings_label_auxiliary_models: 'Вспомогательные модели', settings_desc_auxiliary_models: 'Маршрутизация побочных задач: зрение, сжатие, генерация заголовков и т.д. «Авто» использует основную модель чата.', settings_btn_reset_aux_models: 'Сбросить всё на авто', @@ -5640,51 +5728,51 @@ const LOCALES = { settings_aux_save_failed: 'Не удалось сохранить вспомогательную модель', settings_aux_saved: 'Вспомогательные модели обновлены', settings_aux_no_changes: 'Нет изменений для применения', - settings_aux_task_vision: 'Vision', - settings_aux_task_vision_desc: 'image/screenshot analysis', - settings_aux_task_compression: 'Compression', - settings_aux_task_compression_desc: 'context summarization', - settings_aux_task_web_extract: 'Web extract', - settings_aux_task_web_extract_desc: 'web page summarization', - settings_aux_task_approval: 'Approval', - settings_aux_task_approval_desc: 'smart command approval', + settings_aux_task_vision: 'Зрение', + settings_aux_task_vision_desc: 'анализ изображений и скриншотов', + settings_aux_task_compression: 'Сжатие', + settings_aux_task_compression_desc: 'сводка контекста', + settings_aux_task_web_extract: 'Извлечение из веба', + settings_aux_task_web_extract_desc: 'сводка веб-страниц', + settings_aux_task_approval: 'Подтверждения', + settings_aux_task_approval_desc: 'умное подтверждение команд', settings_aux_task_mcp: 'MCP', - settings_aux_task_mcp_desc: 'MCP tool reasoning', - settings_aux_task_title_generation: 'Title generation', - settings_aux_task_title_generation_desc: 'session titles', - settings_aux_task_skills_hub: 'Skills hub', - settings_aux_task_skills_hub_desc: 'skills search/install', - settings_aux_task_curator: 'Curator', - settings_aux_task_curator_desc: 'skill-usage review pass', - settings_aux_task_kanban_decomposer: 'Kanban decomposer', - settings_aux_task_kanban_decomposer_desc: 'task decomposition', - settings_aux_task_profile_describer: 'Profile describer', - settings_aux_task_profile_describer_desc: 'profile summaries', - settings_aux_task_triage_specifier: 'Triage specifier', - settings_aux_task_triage_specifier_desc: 'issue/task triage specs', - settings_aux_advanced_button_title: 'Advanced options', - settings_aux_advanced_button_aria: 'Advanced options for {task}', - settings_aux_advanced_title: '{task} options', - settings_aux_advanced_subtitle: 'Advanced config for auxiliary.', - settings_aux_advanced_save: 'Save options', - settings_aux_advanced_base_url: 'Base URL', - settings_aux_advanced_base_url_desc: 'Optional provider endpoint override.', - settings_aux_advanced_timeout: 'Timeout seconds', - settings_aux_advanced_timeout_desc: 'Request timeout for this auxiliary task. Blank uses Hermes default.', - settings_aux_advanced_download_timeout: 'Download timeout seconds', - settings_aux_advanced_download_timeout_desc: 'Only relevant for tasks that download media/content, e.g. vision. Blank uses default.', - settings_aux_advanced_max_concurrency: 'Max concurrency', - settings_aux_advanced_max_concurrency_desc: 'Optional per-task concurrency limit. Blank uses default.', - settings_aux_advanced_extra_body: 'Extra body JSON', - settings_aux_advanced_extra_body_desc: 'Optional JSON object merged into the model request body.', - settings_aux_advanced_api_key: 'API key override', - settings_aux_advanced_api_key_set_hint: 'API key is set. Leave blank to keep it, or use clear to remove it.', - settings_aux_advanced_api_key_empty_hint: 'Leave blank to use provider/default credentials.', - settings_aux_advanced_api_key_clear: 'Clear existing API key override', - settings_aux_advanced_extra_body_invalid_json: 'Extra body must be valid JSON', - settings_aux_advanced_extra_body_object_required: 'Extra body must be a JSON object', - settings_aux_advanced_saved: 'Auxiliary options saved', - settings_aux_advanced_save_failed: 'Failed to save auxiliary options', + settings_aux_task_mcp_desc: 'рассуждение по инструментам MCP', + settings_aux_task_title_generation: 'Генерация названий', + settings_aux_task_title_generation_desc: 'названия сеансов', + settings_aux_task_skills_hub: 'Центр навыков', + settings_aux_task_skills_hub_desc: 'поиск и установка навыков', + settings_aux_task_curator: 'Куратор', + settings_aux_task_curator_desc: 'проверка использования навыков', + settings_aux_task_kanban_decomposer: 'Декомпозиция задач Канбана', + settings_aux_task_kanban_decomposer_desc: 'разбиение задач', + settings_aux_task_profile_describer: 'Описание профиля', + settings_aux_task_profile_describer_desc: 'сводки профилей', + settings_aux_task_triage_specifier: 'Уточнение триажа', + settings_aux_task_triage_specifier_desc: 'триаж issue и задач', + settings_aux_advanced_button_title: 'Дополнительные параметры', + settings_aux_advanced_button_aria: 'Дополнительные параметры для {task}', + settings_aux_advanced_title: 'Параметры {task}', + settings_aux_advanced_subtitle: 'Дополнительная конфигурация вспомогательной задачи.', + settings_aux_advanced_save: 'Сохранить параметры', + settings_aux_advanced_base_url: 'Базовый URL', + settings_aux_advanced_base_url_desc: 'Необязательное переопределение endpoint-а провайдера.', + settings_aux_advanced_timeout: 'Тайм-аут, секунды', + settings_aux_advanced_timeout_desc: 'Тайм-аут запроса для этой вспомогательной задачи. Пустое значение использует настройку Hermes по умолчанию.', + settings_aux_advanced_download_timeout: 'Тайм-аут скачивания, секунды', + settings_aux_advanced_download_timeout_desc: 'Нужен только задачам, которые скачивают медиа или контент, например зрению. Пустое значение использует настройку по умолчанию.', + settings_aux_advanced_max_concurrency: 'Максимум параллельных запусков', + settings_aux_advanced_max_concurrency_desc: 'Необязательный лимит параллельности для этой задачи. Пустое значение использует настройку по умолчанию.', + settings_aux_advanced_extra_body: 'Дополнительное тело JSON', + settings_aux_advanced_extra_body_desc: 'Необязательный JSON-объект, который добавляется в тело запроса к модели.', + settings_aux_advanced_api_key: 'Переопределить API-ключ', + settings_aux_advanced_api_key_set_hint: 'API-ключ задан. Оставьте поле пустым, чтобы сохранить его, или очистите, чтобы удалить.', + settings_aux_advanced_api_key_empty_hint: 'Оставьте пустым, чтобы использовать учётные данные провайдера или значения по умолчанию.', + settings_aux_advanced_api_key_clear: 'Очистить существующее переопределение API-ключа', + settings_aux_advanced_extra_body_invalid_json: 'Дополнительное тело должно быть валидным JSON', + settings_aux_advanced_extra_body_object_required: 'Дополнительное тело должно быть JSON-объектом', + settings_aux_advanced_saved: 'Параметры вспомогательной задачи сохранены', + settings_aux_advanced_save_failed: 'Не удалось сохранить параметры вспомогательной задачи', settings_label_send_key: 'Клавиша отправки', settings_label_theme: 'Тема', settings_label_language: 'Язык', @@ -5692,19 +5780,19 @@ const LOCALES = { settings_desc_quota_chip: 'Отображает фоновый индикатор остатка квоты (например, баланс кредитов OpenRouter) в подвале композитора. По умолчанию отключено. Виден только на широких экранах (≥1400px) при включении, чтобы не загромождать композитор на ноутбуках и стандартных мониторах.', settings_label_conversation_outline: 'Показывать структуру беседы', settings_desc_conversation_outline: 'Показывает на рабочем столе список переходов к вопросам пользователя в текущей беседе. По умолчанию отключено.', - settings_label_hide_suggestions: 'Hide new-chat suggestions', - settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.', - settings_label_virtualize_transcript: 'Virtualize long transcripts', - settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.", + settings_label_hide_suggestions: 'Скрывать подсказки нового чата', + settings_desc_hide_suggestions: 'Скрывает три стандартные кнопки-подсказки на пустом экране нового чата, чтобы избежать случайных нажатий.', + settings_label_virtualize_transcript: 'Оптимизировать длинные транскрипты', + settings_desc_virtualize_transcript: 'Отрисовывает только видимую часть очень длинных транскриптов чата (более 80 сообщений), чтобы прокрутка была плавнее. Отключите, если заметите проблемы с отрисовкой или прокруткой либо хотите, чтобы поиск браузера (Ctrl+F) находил текст во всей беседе — тогда транскрипт будет отрисован целиком.', settings_label_token_usage: 'Показывать использование токенов', settings_label_token_speed: 'Показывать скорость токенов (TPS)', settings_desc_token_speed: 'Отображает количество токенов в секунду в заголовках сообщений ассистента во время потоковой передачи и после завершения ответа. Выключено по умолчанию.', settings_label_sidebar_density: 'Плотность боковой панели', - cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status', + cmd_reasoning: 'Показать или скрыть рассуждения, задать уровень усилия или проверить текущий статус', settings_label_external_sessions: 'Показывать внешние сеансы', - settings_label_claude_code_sessions: 'Show Claude Code sessions', - settings_label_cron_sessions: 'Show cron sessions', - settings_label_webhook_sessions: 'Show webhook sessions', + settings_label_claude_code_sessions: 'Показывать сеансы Claude Code', + settings_label_cron_sessions: 'Показывать cron-сеансы', + settings_label_webhook_sessions: 'Показывать webhook-сеансы', settings_label_previous_messaging_sessions: 'Показывать предыдущие сеансы обмена сообщениями', settings_label_sync_insights: 'Синхронизировать с Insights', settings_label_check_updates: 'Проверять обновления', @@ -5712,8 +5800,8 @@ const LOCALES = { settings_update_channel_stable: 'Стабильный (рекомендуется)', settings_update_channel_experimental: 'Экспериментальный', settings_desc_update_channel: 'Стабильный канал получает проверенные, обкатанные релизы. Экспериментальный получает каждый релиз сразу — быстрее, но возможны шероховатости. При возврате на Стабильный ваша версия сохраняется, пока Стабильный не догонит её. Применяется только к обновлениям WebUI.', - settings_label_ignore_agent_updates: 'Ignore Agent updates', - settings_label_whats_new_summary: "Summarize What's New with AI", + settings_label_ignore_agent_updates: 'Игнорировать обновления Agent', + settings_label_whats_new_summary: 'Делать AI-сводку What\'s New', settings_label_bot_name: 'Имя помощника по умолчанию', settings_label_password: 'Пароль доступа', settings_label_pinned_limit: 'Лимит закреплённых бесед', @@ -5732,7 +5820,7 @@ const LOCALES = { gateway_not_running: 'Шлюз не запущен', gateway_running_label: 'Работает', gateway_last_active: 'Последняя активность: ', - gateway_session_count: (n) => `${n} ${n === 1 ? 'сессия' : n < 5 ? 'сессии' : 'сессий'}`, + gateway_session_count: (n) => `${n} ${_i18nRuPlural(n, 'сеанс', 'сеанса', 'сеансов')}`, gateway_load_failed: 'Не удалось загрузить статус шлюза', settings_saved: 'Настройки сохранены', settings_save_failed: 'Не удалось сохранить: ', @@ -5751,40 +5839,40 @@ const LOCALES = { tab_memory: 'Память', tab_workspaces: 'Рабочие пространства', tab_profiles: 'Профили', - tab_kanban: 'Kanban', - kanban_board: 'Board', - kanban_visible_tasks: '{0} visible tasks', - kanban_search_tasks: 'Search tasks', - kanban_all_assignees: 'All assignees', - kanban_all_tenants: 'All tenants', - kanban_include_archived: 'Include archived', - kanban_no_matching_tasks: 'No matching tasks', - kanban_no_data: 'No Kanban data', - kanban_work_queue_hint: 'This is the Hermes Agent work queue. Create or triage a task, assign it, move it to Ready, then let the dispatcher claim it.', - kanban_tasks_hidden_by_filters: 'Tasks hidden by filters', - kanban_clear_filters: 'Clear filters', - kanban_unavailable: 'Kanban unavailable', - kanban_read_only: 'Read-only view', - kanban_empty: 'Empty', - kanban_task: 'Task', - kanban_no_description: 'No description', - kanban_refresh: 'Refresh', - kanban_status_triage: 'Triage', - kanban_status_todo: 'Todo', - kanban_status_ready: 'Ready', - kanban_status_running: 'Running', - kanban_status_blocked: 'Blocked', - kanban_status_done: 'Done', - kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.', - kanban_comments_count: 'Comments ({0})', - kanban_events_count: 'Events ({0})', - kanban_links: 'Links', - kanban_parents: 'Parents', - kanban_children: 'Children', - kanban_runs_count: 'Runs ({0})', - kanban_no_comments: 'No comments', - kanban_no_events: 'No events', - kanban_no_runs: 'No runs', + tab_kanban: 'Канбан', + kanban_board: 'Доска', + kanban_visible_tasks: 'Видимых задач: {0}', + kanban_search_tasks: 'Искать задачи', + kanban_all_assignees: 'Все исполнители', + kanban_all_tenants: 'Все тенанты', + kanban_include_archived: 'Включая архив', + kanban_no_matching_tasks: 'Подходящих задач нет', + kanban_no_data: 'Нет данных Канбана', + kanban_work_queue_hint: 'Это рабочая очередь Hermes Agent. Создайте или отсортируйте задачу, назначьте исполнителя, переведите её в Ready, затем дайте диспетчеру забрать её.', + kanban_tasks_hidden_by_filters: 'Задачи скрыты фильтрами', + kanban_clear_filters: 'Сбросить фильтры', + kanban_unavailable: 'Канбан недоступен', + kanban_read_only: 'Только просмотр', + kanban_empty: 'Пусто', + kanban_task: 'Задача', + kanban_no_description: 'Нет описания', + kanban_refresh: 'Обновить', + kanban_status_triage: 'Триаж', + kanban_status_todo: 'К работе', + kanban_status_ready: 'Готово к запуску', + kanban_status_running: 'В работе', + kanban_status_blocked: 'Заблокировано', + kanban_status_done: 'Готово', + kanban_status_original_hint: 'Фактический статус: {0}. В этом диалоге можно выбрать только Triage/Todo/Ready.', + kanban_comments_count: 'Комментарии ({0})', + kanban_events_count: 'События ({0})', + kanban_links: 'Ссылки', + kanban_parents: 'Родительские', + kanban_children: 'Дочерние', + kanban_runs_count: 'Запуски ({0})', + kanban_no_comments: 'Комментариев нет', + kanban_no_events: 'Событий нет', + kanban_no_runs: 'Запусков нет', kanban_title: 'Заголовок', kanban_description: 'Описание', kanban_description_placeholder: 'Необязательно — что нужно сделать, критерии приёмки, ссылки', @@ -5813,61 +5901,61 @@ const LOCALES = { kanban_dispatch_auto_blocked: 'автоблок', kanban_dispatch_timed_out: 'таймаут', kanban_dispatch_crashed: 'падение', - kanban_new_task: 'New task', - kanban_view_lanes: 'Lanes by profile', - kanban_view_consolidated: 'Consolidated view', - kanban_view_lanes_saved: 'Kanban view: lanes by profile', - kanban_view_consolidated_saved: 'Kanban view: consolidated', - kanban_view_update_failed: 'Failed to update Kanban view: ', - kanban_add_comment: 'Add comment', - kanban_workspace_kind: 'Workspace kind', - kanban_workspace_path: 'Workspace path', - kanban_workspace_path_placeholder: 'Path to workspace', - kanban_workspace_scratch: 'Scratch', + kanban_new_task: 'Новая задача', + kanban_view_lanes: 'Дорожки по профилям', + kanban_view_consolidated: 'Сводный вид', + kanban_view_lanes_saved: 'Вид Канбана: дорожки по профилям', + kanban_view_consolidated_saved: 'Вид Канбана: сводный', + kanban_view_update_failed: 'Не удалось обновить вид Канбана: ', + kanban_add_comment: 'Добавить комментарий', + kanban_workspace_kind: 'Тип рабочей области', + kanban_workspace_path: 'Путь рабочей области', + kanban_workspace_path_placeholder: 'Путь к рабочей области', + kanban_workspace_scratch: 'Черновая', kanban_workspace_worktree: 'Worktree', - kanban_workspace_dir: 'Directory', - kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', - kanban_skills: 'Skills', - kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', - kanban_max_runtime_seconds: 'Max runtime (seconds)', - kanban_max_runtime_hint: 'Leave empty for unlimited.', - kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', - kanban_parents_modal: 'Parent dependencies', - kanban_parents_placeholder: 'Parent task ID', - kanban_add_dependency: 'Add dependency', - kanban_remove_dependency: 'Remove dependency', - kanban_dependency_placeholder: 'Task ID to link', - kanban_dependency_self: 'A task cannot depend on itself', - kanban_only_mine: 'Only mine', - kanban_bulk_action: 'Bulk action', - kanban_nudge_dispatcher: 'Preview dispatcher', - kanban_stats: 'Stats', - kanban_worker_log: 'Worker log', - kanban_block: 'Block', - kanban_unblock: 'Unblock', - kanban_back_to_board: 'Back to board', - kanban_lanes_by_profile: 'Lanes by profile', - kanban_new_board: 'New board…', - kanban_rename_board: 'Rename current board…', - kanban_archive_board: 'Archive current board…', - kanban_archive_board_confirm: 'Archive board "{name}"? Tasks remain on disk and the board can be restored from kanban/boards/_archived/.', - kanban_board_archived: 'Board archived', - kanban_board_name: 'Name', - kanban_board_slug: 'Slug (lowercase, hyphens)', - kanban_board_description: 'Description (optional)', - kanban_board_icon: 'Icon (emoji, optional)', - kanban_board_color: 'Color (optional)', - kanban_board_name_required: 'Name is required', - kanban_board_slug_required: 'Slug is required', - kanban_card_complete: 'complete', - kanban_card_archive: 'archive', - kanban_unassigned: 'unassigned', - kanban_status_archived: 'Archived', + kanban_workspace_dir: 'Каталог', + kanban_workspace_path_required: 'Для нечерновых рабочих областей нужен путь.', + kanban_skills: 'Навыки', + kanban_skills_placeholder: 'Имена навыков через запятую, например python,git', + kanban_max_runtime_seconds: 'Макс. время выполнения (секунды)', + kanban_max_runtime_hint: 'Оставьте пустым для безлимитного выполнения.', + kanban_max_runtime_invalid: 'Максимальное время выполнения должно быть целым числом секунд больше 0.', + kanban_parents_modal: 'Родительские зависимости', + kanban_parents_placeholder: 'ID родительской задачи', + kanban_add_dependency: 'Добавить зависимость', + kanban_remove_dependency: 'Удалить зависимость', + kanban_dependency_placeholder: 'ID задачи для связи', + kanban_dependency_self: 'Задача не может зависеть от самой себя', + kanban_only_mine: 'Только мои', + kanban_bulk_action: 'Массовое действие', + kanban_nudge_dispatcher: 'Предпросмотр диспетчера', + kanban_stats: 'Статистика', + kanban_worker_log: 'Журнал воркера', + kanban_block: 'Заблокировать', + kanban_unblock: 'Разблокировать', + kanban_back_to_board: 'Назад к доске', + kanban_lanes_by_profile: 'Дорожки по профилям', + kanban_new_board: 'Новая доска…', + kanban_rename_board: 'Переименовать текущую доску…', + kanban_archive_board: 'Архивировать текущую доску…', + kanban_archive_board_confirm: 'Архивировать доску «{name}»? Задачи останутся на диске, а доску можно будет восстановить из kanban/boards/_archived/.', + kanban_board_archived: 'Доска архивирована', + kanban_board_name: 'Имя', + kanban_board_slug: 'Slug (строчные буквы и дефисы)', + kanban_board_description: 'Описание (необязательно)', + kanban_board_icon: 'Иконка (emoji, необязательно)', + kanban_board_color: 'Цвет (необязательно)', + kanban_board_name_required: 'Укажите имя', + kanban_board_slug_required: 'Укажите slug', + kanban_card_complete: 'завершить', + kanban_card_archive: 'архивировать', + kanban_unassigned: 'не назначено', + kanban_status_archived: 'Архив', tab_todos: 'Список дел', tab_insights: 'Аналитика', tab_dashboard: 'Панель Hermes', dashboard_loopback_warning: 'Панель доступна только через loopback на сервере. Откройте её с самого сервера или перезапустите с --host 0.0.0.0 (небезопасно).', - tab_logs: 'Logs', + tab_logs: 'Журналы', tab_settings: 'Настройки', close_menu: 'Закрыть меню', @@ -5911,9 +5999,9 @@ const LOCALES = { personal_memory: 'Личная память', current_task_list: 'Текущий список задач', workspace_desc: 'Добавляйте рабочие пространства и переключайтесь между ними в своих сеансах.', - session_meta_messages: (n) => `${n} сообщ.`, - session_meta_children: (n) => `${n} ${n === 1 ? 'дочерн.' : 'дочерн.'}`, - session_meta_segments: (n) => `${n} сегм.`, + session_meta_messages: (n) => `${n} ${_i18nRuPlural(n, 'сообщение', 'сообщения', 'сообщений')}`, + session_meta_children: (n) => `${n} ${_i18nRuPlural(n, 'дочерний сеанс', 'дочерних сеанса', 'дочерних сеансов')}`, + session_meta_segments: (n) => `${n} ${_i18nRuPlural(n, 'предыдущий ход', 'предыдущих хода', 'предыдущих ходов')}`, session_lineage_toggle_hint: '{0} — предыдущие ходы контекста свернуты здесь. Нажмите, чтобы показать или скрыть их.', session_lineage_static_hint: '{0} — предыдущие ходы контекста свернуты здесь.', session_child_toggle_hint: '{0} — дочерние разговоры, созданные из этого сеанса. Нажмите, чтобы показать или скрыть их.', @@ -5936,12 +6024,12 @@ const LOCALES = { settings_desc_sound: 'Проигрывать звук, когда помощник завершает ответ.', settings_label_notifications: 'Уведомления браузера', settings_desc_notifications: 'Показывать системное уведомление, когда ответ готов, а вкладка находится в фоне.', - notifications_enable_btn: 'Enable notifications', - notifications_test_btn: 'Send test', - notifications_permission_status: (status) => `Permission: ${status}`, - notifications_enabled_toast: 'Notifications enabled.', - notifications_denied: 'Notifications are blocked in this browser. Enable them in browser settings.', - notifications_unsupported: 'Notifications are not supported in this browser.', + notifications_enable_btn: 'Включить уведомления', + notifications_test_btn: 'Тестовое уведомление', + notifications_permission_status: (status) => `Разрешение: ${status}`, + notifications_enabled_toast: 'Уведомления включены.', + notifications_denied: 'Уведомления заблокированы в этом браузере. Разрешите их в настройках браузера.', + notifications_unsupported: 'Этот браузер не поддерживает уведомления.', settings_desc_token_usage: 'Показывает количество входных и выходных токенов под каждым ответом помощника. Также переключается через /usage.', settings_sidebar_density_compact: 'Компактно', settings_sidebar_density_detailed: 'Подробно', @@ -5953,14 +6041,14 @@ const LOCALES = { settings_auto_title_refresh_20: 'Каждые 20 обменов', settings_desc_auto_title_refresh: 'Автоматически переформулирует заголовок сессии на основе последнего обмена, чтобы он оставался актуальным по мере развития беседы. Требует настроенную модель генерации заголовков.', settings_desc_external_sessions: 'Показать беседы из CLI, Telegram, Discord, Slack и других каналов в списке сеансов. Нажмите для импорта и продолжения.', - settings_desc_claude_code_sessions: 'Show Claude Code imported sessions in the sidebar, or hide them while leaving other external sessions visible.', - settings_desc_cron_sessions: 'Surface cron job output as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-frequency jobs can flood the sidebar.', - settings_desc_webhook_sessions: 'Surface webhook runs as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-volume routes can flood the sidebar.', + settings_desc_claude_code_sessions: 'Показывать импортированные сеансы Claude Code в боковой панели или скрывать их, оставляя другие внешние сеансы видимыми.', + settings_desc_cron_sessions: 'Показывать вывод cron-заданий как беседы в боковой панели. Работает только при включённых не-WebUI сеансах. По умолчанию выключено; частые задания могут переполнить боковую панель.', + settings_desc_webhook_sessions: 'Показывать запуски webhook как беседы в боковой панели. Работает только при включённых не-WebUI сеансах. По умолчанию выключено; высокочастотные маршруты могут переполнить боковую панель.', settings_desc_previous_messaging_sessions: 'Показывать предыдущие сеансы Discord, Telegram, Slack и Weixin, замененные сбросом или сжатием.', settings_desc_sync_insights: 'Синхронизирует использование токенов WebUI в state.db, чтобы Hermes /insights включал данные браузерных сеансов. Выключено по умолчанию.', settings_desc_check_updates: 'Показывает баннер, когда доступны более новые версии WebUI или Agent. Периодически выполняет git fetch в фоне.', - settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', - settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", + settings_desc_ignore_agent_updates: 'Оставляет проверку обновлений WebUI включённой, но скрывает уведомления об обновлениях Agent и пропускает их загрузку.', + settings_desc_whats_new_summary: 'Меняет действие What\'s New: сначала генерируется короткая понятная сводка, а обычное сравнение diff остаётся доступным после неё.', settings_desc_bot_name: 'Используется только для профиля по умолчанию. Другие профили используют свои имена.', settings_desc_password: 'Введите новый пароль, чтобы задать или изменить его. Оставьте пустым, чтобы сохранить текущую настройку.', password_placeholder: 'Введите новый пароль…', @@ -5977,32 +6065,32 @@ const LOCALES = { settings_shutdown_confirm_message: 'Остановить сервер Hermes WebUI?', settings_shutdown_confirm_btn: 'Остановить', settings_shutdown_stopped_message: 'Сервер остановлен. Можно закрыть эту вкладку.', - // Providers panel (English fallback — native translations welcome in follow-up PRs) - providers_tab_title: 'Providers', - providers_section_title: 'Providers', - providers_section_meta: 'Manage API keys for AI providers. Changes take effect immediately.', - providers_status_configured: 'API key configured', - providers_status_not_configured: 'No API key', + // Providers panel + providers_tab_title: 'Провайдеры', + providers_section_title: 'Провайдеры', + providers_section_meta: 'Управляйте API-ключами AI-провайдеров. Изменения применяются сразу.', + providers_status_configured: 'API-ключ настроен', + providers_status_not_configured: 'API-ключ не задан', providers_status_oauth: 'OAuth', - providers_status_api_key: 'API key', - providers_status_model: 'Model', - providers_status_not_configured_label: 'Not configured', - providers_oauth_hint: 'Authenticated via OAuth. No API key needed.', - providers_oauth_config_yaml_hint: 'Token configured via config.yaml. To update, edit the providers section in your config.yaml or run hermes auth.', - providers_oauth_not_configured_hint: 'Not authenticated. Run hermes auth in the terminal to configure this provider.', - providers_save: 'Save', - providers_refresh_models: 'Refresh models', - providers_refreshing: 'Refreshing…', - providers_models_refreshed: 'Models refreshed', - providers_remove: 'Remove', - providers_saving: 'Saving…', - providers_removing: 'Removing…', - providers_enter_key: 'Please enter an API key', - providers_empty: 'No configurable providers found.', - providers_key_updated: 'API key saved', - providers_key_removed: 'API key removed', + providers_status_api_key: 'API-ключ', + providers_status_model: 'Модель', + providers_status_not_configured_label: 'Не настроен', + providers_oauth_hint: 'Вход выполнен через OAuth. API-ключ не нужен.', + providers_oauth_config_yaml_hint: 'Токен настроен через config.yaml. Чтобы обновить его, отредактируйте раздел providers в config.yaml или запустите hermes auth.', + providers_oauth_not_configured_hint: 'Авторизация не выполнена. Запустите hermes auth в терминале, чтобы настроить этого провайдера.', + providers_save: 'Сохранить', + providers_refresh_models: 'Обновить модели', + providers_refreshing: 'Обновление…', + providers_models_refreshed: 'Модели обновлены', + providers_remove: 'Удалить', + providers_saving: 'Сохранение…', + providers_removing: 'Удаление…', + providers_enter_key: 'Введите API-ключ', + providers_empty: 'Настраиваемые провайдеры не найдены.', + providers_key_updated: 'API-ключ сохранён', + providers_key_removed: 'API-ключ удалён', providers_key_placeholder_new: 'sk-...', - providers_key_placeholder_replace: 'Enter new key to replace…', + providers_key_placeholder_replace: 'Введите новый ключ для замены…', provider_quota_title: 'Квота активного провайдера', provider_quota_active_provider: 'Активный провайдер', provider_quota_last_checked_after_refresh: 'Последняя проверка после обновления', @@ -6039,12 +6127,12 @@ const LOCALES = { provider_quota_pool_summary_exhausted: '{0} исчерпано', provider_quota_pool_summary_failed: '{0} с ошибкой', provider_quota_pool_summary_checked: '{0} проверено', - provider_cost_budget_label: 'Monthly budget', - provider_cost_budget_placeholder: 'e.g. 50.00', - provider_cost_budget_set: 'Set', - provider_cost_budget_clear: 'Clear', - provider_cost_budget_pct: '{0}% of ${1} budget (monthly pace)', - provider_cost_budget_save_failed: 'Failed to save budget', + provider_cost_budget_label: 'Месячный бюджет', + provider_cost_budget_placeholder: 'например, 50.00', + provider_cost_budget_set: 'Задать', + provider_cost_budget_clear: 'Очистить', + provider_cost_budget_pct: '{0}% месячного бюджета ${1}', + provider_cost_budget_save_failed: 'Не удалось сохранить бюджет', cancel: 'Отмена', create_job: 'Создать задание', save_skill: 'Сохранить навык', @@ -6090,6 +6178,10 @@ const LOCALES = { onboarding_current_provider: 'Текущая конфигурация:', onboarding_missing_imports: 'Отсутствующие импорты:', onboarding_notice_setup_required: 'Выберите здесь простой путь настройки провайдера. Продвинутые OAuth-сценарии пока остаются в Hermes CLI.', + onboarding_notice_provider_choice_required: 'Hermes установлен, но нужно выбрать провайдера и сохранить рабочие учётные данные.', + onboarding_notice_custom_base_url_required: 'В Hermes уже сохранены провайдер и модель, но для пользовательского провайдера всё ещё нужен базовый URL. Добавьте API-ключ, если этот сервер его требует.', + onboarding_notice_provider_auth_required: 'Провайдер «{0}» настроен, но ещё не авторизован. Запустите в терминале "hermes auth" или "hermes model", чтобы завершить настройку, затем перезагрузите страницу Web UI.', + onboarding_notice_provider_api_key_required: 'В Hermes уже сохранены провайдер и модель, но для чата всё ещё нужен API-ключ.', onboarding_notice_setup_already_ready: 'Уже обнаружена рабочая конфигурация провайдера Hermes. Вы можете оставить её или заменить здесь.', onboarding_oauth_provider_ready_title: 'Провайдер уже авторизован', onboarding_oauth_provider_ready_body: 'Этот экземпляр настроен на использование OAuth-провайдера ({provider}), настроенного через Hermes CLI. API-ключ здесь не нужен — нажмите «Продолжить», чтобы завершить настройку.', @@ -6103,7 +6195,7 @@ const LOCALES = { onboarding_provider_label: 'Режим настройки', onboarding_quick_setup_badge: 'Быстрая настройка', provider_category_easy_start: 'Быстрый старт', - provider_category_self_hosted: 'Локальные / Open source', + provider_category_self_hosted: 'Локальные / с открытым кодом', provider_category_specialized: 'Специализированные', onboarding_api_key_label: 'Ключ API', onboarding_api_key_placeholder: 'Оставьте пустым, чтобы сохранить уже сохранённый ключ', @@ -6160,7 +6252,7 @@ const LOCALES = { onboarding_complete: 'Первичная настройка завершена', error_prefix: 'Ошибка: ', default: 'default', - search: 'Search', + search: 'Поиск', not_available: 'н/д', never: 'никогда', add: 'Добавить', @@ -6209,7 +6301,7 @@ const LOCALES = { cron_script_path_hint: 'Ищется в ~/.hermes/scripts/, если не указан абсолютный путь. Логику меняй в файле скрипта на сервере.', cron_script_badge_title: 'Скриптовая задача (без агента)', cron_workdir_label: 'Рабочая директория', - cron_view_full_output: 'View full output', + cron_view_full_output: 'Показать полный вывод', cron_all_runs: 'Все запуски', cron_hide_runs: 'Скрыть запуски', cron_no_runs_yet: '(пока запусков нет)', @@ -6225,7 +6317,7 @@ const LOCALES = { cron_job_updated: 'Задание обновлено', cron_model_label: 'Переопределение модели', cron_model_hint: 'Переопределяет модель по умолчанию для этого задания.', - cron_model_no_agent_hint: 'No-agent jobs run the configured script directly; model is unused.', + cron_model_no_agent_hint: 'Задания без агента запускают настроенный скрипт напрямую; модель не используется.', cron_model_use_default: 'По умолчанию (профиль/системная модель)', cron_delete_confirm_title: 'Удалить cron-задание', cron_delete_confirm_message: 'Это действие нельзя отменить.', @@ -6255,15 +6347,15 @@ const LOCALES = { external_notes_empty: 'Источники заметок или знаний MCP пока не видны. Настройте Joplin, Obsidian, Notion, llm-wiki или другой сервер заметок, чтобы он появился здесь.', external_notes_auto_recall_hint: 'Автоматический вызов контекста сеанса не меняется; эта панель показывает только настроенные источники и доступные инструменты чтения/поиска.', external_notes_no_tools: 'Для этого источника сейчас не видно инструментов чтения/поиска.', - external_notes_tool_count: (count) => `Доступно инструментов заметок: ${count}`, - external_notes_configured_hint: 'От этого настроенного источника ожидаются имена инструментов; живые схемы появятся, когда WebUI runtime их предоставит.', + external_notes_tool_count: (count) => `Доступно: ${count} ${_i18nRuPlural(count, 'инструмент заметок', 'инструмента заметок', 'инструментов заметок')}`, + external_notes_configured_hint: 'От этого настроенного источника ожидаются имена инструментов; живые схемы появятся, когда среда выполнения WebUI их предоставит.', external_notes_recent_ai: 'Недавно использовано AI', - external_notes_auto: 'auto', + external_notes_auto: 'авто', external_notes_recent_ai_reason: 'Автоматический вызов', external_notes_search_placeholder: 'Искать заметки…', external_notes_search_empty: 'Выполните поиск по настроенному источнику заметок, чтобы увидеть предпросмотр здесь.', - source_active: 'active', - source_configured: 'configured', + source_active: 'активен', + source_configured: 'настроен', no_notes_yet: 'Пока нет заметок.', no_profile_yet: 'Пока нет профиля.', agent_soul: 'Душа агента', @@ -6300,16 +6392,7 @@ const LOCALES = { profile_gateway_stopped: 'Gateway остановлен', profile_active: 'АКТИВЕН', profile_no_configuration: 'Нет конфигурации', - profile_skill_count: (count) => { - const mod10 = count % 10; - const mod100 = count % 100; - const word = mod10 === 1 && mod100 !== 11 - ? 'навык' - : (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20) - ? 'навыка' - : 'навыков'); - return `${count} ${word}`; - }, + profile_skill_count: (count) => `${count} ${_i18nRuPlural(count, 'навык', 'навыка', 'навыков')}`, profile_use: 'Использовать', profile_switch_title: 'Переключиться на этот профиль', profile_delete_title: 'Удалить этот профиль', @@ -6332,16 +6415,7 @@ const LOCALES = { profile_delete_confirm_message: 'Все сеансы, конфигурация, навыки и память этого профиля будут удалены безвозвратно. Это действие невозможно отменить.', profile_deleted: (name) => `Профиль удалён: ${name}`, active_conversation_none: 'Активная беседа не выбрана.', - active_conversation_meta: (title, count) => { - const mod10 = count % 10; - const mod100 = count % 100; - const word = mod10 === 1 && mod100 !== 11 - ? 'сообщение' - : (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20) - ? 'сообщения' - : 'сообщений'); - return `${title} · ${count} ${word}`; - }, + active_conversation_meta: (title, count) => `${title} · ${count} ${_i18nRuPlural(count, 'сообщение', 'сообщения', 'сообщений')}`, settings_unsaved_changes: 'У вас есть несохранённые изменения.', sign_out_failed: 'Не удалось выйти: ', disable_auth_confirm_title: 'Отключить защиту паролем', @@ -6394,18 +6468,18 @@ const LOCALES = { cron_schedule_preset_weekly: 'Еженедельно', cron_schedule_preset_monthly: 'Ежемесячно', cron_schedule_preset_custom: 'Пользовательский', - cron_schedule_time_label: 'Time', - cron_schedule_conj_on: 'on', - cron_schedule_conj_on_day: 'on day', - cron_schedule_conj_at: 'at', - cron_schedule_conj_at_minute: 'at minute', - cron_weekday_sun: 'Sunday', - cron_weekday_mon: 'Monday', - cron_weekday_tue: 'Tuesday', - cron_weekday_wed: 'Wednesday', - cron_weekday_thu: 'Thursday', - cron_weekday_fri: 'Friday', - cron_weekday_sat: 'Saturday', + cron_schedule_time_label: 'Время', + cron_schedule_conj_on: 'в', + cron_schedule_conj_on_day: 'в день', + cron_schedule_conj_at: 'в', + cron_schedule_conj_at_minute: 'на минуте', + cron_weekday_sun: 'Воскресенье', + cron_weekday_mon: 'Понедельник', + cron_weekday_tue: 'Вторник', + cron_weekday_wed: 'Среда', + cron_weekday_thu: 'Четверг', + cron_weekday_fri: 'Пятница', + cron_weekday_sat: 'Суббота', cron_schedule_hour_label: 'Час', cron_schedule_minute_label: 'Минута', cron_schedule_weekday_label: 'День недели (0-6, вс-сб)', @@ -6439,56 +6513,56 @@ const LOCALES = { profile_base_url_label: 'Базовый URL', profile_api_key_label: 'API-ключ', - // Session management and settings keys (en fallback — pending translation) - session_archive: 'Archive conversation', + // Session management and settings keys + session_archive: 'Архивировать беседу', session_hide_external: 'Скрыть из списка', - session_archive_desc: 'Hide this conversation until archived is shown', - session_archive_worktree_desc: 'Hide this conversation; keep its worktree on disk', + session_archive_desc: 'Скрыть беседу, пока не включён показ архива', + session_archive_worktree_desc: 'Скрыть беседу; worktree останется на диске', session_hide_external_desc: 'Скрыть эту импортированную сессию из WebUI без удаления исходной истории.', - session_archive_failed: 'Archive failed: ', - session_archived: 'Session archived', - session_archived_worktree: 'Session archived. Worktree remains on disk.', + session_archive_failed: 'Не удалось архивировать: ', + session_archived: 'Беседа архивирована', + session_archived_worktree: 'Беседа архивирована. Worktree остался на диске.', session_hidden: 'Сессия скрыта из списка', - session_delete: 'Delete conversation', - session_delete_desc: 'Permanently remove this conversation', - session_delete_confirm: 'Delete this conversation?', - session_delete_worktree_desc: 'Delete only the WebUI conversation; keep the worktree on disk', - session_delete_worktree_confirm: (path) => `Delete this conversation? The worktree at ${path} will remain on disk.`, - session_deleted: 'Conversation deleted', - session_deleted_worktree: 'Conversation deleted. Worktree remains on disk.', - session_worktree_remove: 'Remove worktree', - session_worktree_remove_desc: (path) => `Delete the git worktree at ${path} from disk`, - session_worktree_remove_confirm: (path) => `Remove git worktree from disk?\n\nPath: ${path}\n\nThis will delete the entire worktree directory. Session data remains in WebUI.`, - session_worktree_remove_not_exists: (path) => `The worktree at ${path} no longer exists on disk.`, - session_worktree_remove_confirm_label: 'Remove', - session_worktree_removed: 'Worktree removed.', - session_worktree_remove_failed: 'Failed to remove worktree: ', - session_worktree_remove_status_failed: 'Failed to read worktree status: ', - session_worktree_remove_locked_by_stream: 'Cannot remove — an active streaming session is using this worktree.', - session_worktree_remove_locked_by_terminal: 'Cannot remove — an active terminal session is using this worktree.', - session_worktree_remove_unsafe_blocked: 'Resolve local changes or unpushed commits before removing this worktree.', - session_worktree_remove_dirty_warning: 'WARNING: This worktree has uncommitted changes which will be lost.', - session_worktree_remove_untracked_warning: (count) => `${count} untracked file(s) will be permanently deleted.`, - session_worktree_remove_ahead_warning: (ahead) => `${ahead} unpushed commit(s) will be lost.`, - session_duplicate: 'Duplicate conversation', - session_duplicate_desc: 'Create a copy with the same workspace and model', - session_export_html: 'Export as HTML', - session_export_html_desc: 'Download this conversation as a self-contained HTML file', - session_duplicate_failed: 'Duplicate failed: ', - session_stop_response: 'Stop response', - session_stop_response_desc: 'Cancel the running response for this conversation', - session_duplicated: 'Session duplicated', - session_move_project: 'Move to project', - session_move_project_desc_has: 'Change the project for this conversation', - session_move_project_desc_none: 'Assign a project to this conversation', - session_pin: 'Pin conversation', - session_pin_desc: 'Keep this conversation at the top', - session_pin_failed: 'Pin failed: ', - session_restore: 'Restore conversation', - session_restore_desc: 'Bring this conversation back into the main list', - session_restored: 'Session restored', - session_unpin: 'Unpin conversation', - session_unpin_desc: 'Remove from pinned', + session_delete: 'Удалить беседу', + session_delete_desc: 'Безвозвратно удалить эту беседу', + session_delete_confirm: 'Удалить эту беседу?', + session_delete_worktree_desc: 'Удалить только беседу WebUI; worktree останется на диске', + session_delete_worktree_confirm: (path) => `Удалить эту беседу? Worktree по пути ${path} останется на диске.`, + session_deleted: 'Беседа удалена', + session_deleted_worktree: 'Беседа удалена. Worktree остался на диске.', + session_worktree_remove: 'Удалить worktree', + session_worktree_remove_desc: (path) => `Удалить git worktree по пути ${path} с диска`, + session_worktree_remove_confirm: (path) => `Удалить git worktree с диска?\n\nПуть: ${path}\n\nБудет удалён весь каталог worktree. Данные сеанса останутся в WebUI.`, + session_worktree_remove_not_exists: (path) => `Worktree по пути ${path} больше не существует на диске.`, + session_worktree_remove_confirm_label: 'Удалить', + session_worktree_removed: 'Worktree удалён.', + session_worktree_remove_failed: 'Не удалось удалить worktree: ', + session_worktree_remove_status_failed: 'Не удалось прочитать статус worktree: ', + session_worktree_remove_locked_by_stream: 'Нельзя удалить — этот worktree использует активный потоковый сеанс.', + session_worktree_remove_locked_by_terminal: 'Нельзя удалить — этот worktree использует активный терминальный сеанс.', + session_worktree_remove_unsafe_blocked: 'Перед удалением worktree разберите локальные изменения или незапушенные коммиты.', + session_worktree_remove_dirty_warning: 'ВНИМАНИЕ: в этом worktree есть незакоммиченные изменения, они будут потеряны.', + session_worktree_remove_untracked_warning: (count) => `${count} ${_i18nRuPlural(count, 'неотслеживаемый файл будет удалён безвозвратно', 'неотслеживаемых файла будут удалены безвозвратно', 'неотслеживаемых файлов будет удалено безвозвратно')}.`, + session_worktree_remove_ahead_warning: (ahead) => `${ahead} ${_i18nRuPlural(ahead, 'незапушенный коммит будет потерян', 'незапушенных коммита будут потеряны', 'незапушенных коммитов будет потеряно')}.`, + session_duplicate: 'Дублировать беседу', + session_duplicate_desc: 'Создать копию с той же рабочей областью и моделью', + session_export_html: 'Экспортировать в HTML', + session_export_html_desc: 'Скачать эту беседу как автономный HTML-файл', + session_duplicate_failed: 'Не удалось дублировать: ', + session_stop_response: 'Остановить ответ', + session_stop_response_desc: 'Отменить текущий ответ в этой беседе', + session_duplicated: 'Беседа дублирована', + session_move_project: 'Переместить в проект', + session_move_project_desc_has: 'Изменить проект этой беседы', + session_move_project_desc_none: 'Назначить проект этой беседе', + session_pin: 'Закрепить беседу', + session_pin_desc: 'Держать эту беседу вверху', + session_pin_failed: 'Не удалось закрепить: ', + session_restore: 'Восстановить беседу', + session_restore_desc: 'Вернуть беседу в основной список', + session_restored: 'Беседа восстановлена', + session_unpin: 'Открепить беседу', + session_unpin_desc: 'Убрать из закреплённых', session_select_mode: 'Выбрать', session_select_mode_desc: 'Выберите беседы для массового управления', session_select_all: 'Выбрать все', @@ -6502,22 +6576,31 @@ const LOCALES = { session_batch_delete_worktree_confirm: 'Удалить {0} бесед(ы)? У {1} бесед с worktree каталоги worktree останутся на диске.', session_batch_archive_worktree_confirm: 'Архивировать {0} бесед(ы)? У {1} бесед с worktree каталоги worktree останутся на диске.', session_no_selection: 'Ничего не выбрано', - settings_dropdown_appearance: 'Appearance', - settings_dropdown_conversation: 'Conversation', - settings_dropdown_preferences: 'Preferences', - settings_dropdown_providers: 'Providers', - settings_dropdown_system: 'System', - settings_heading_subtitle: 'Preferences, conversation tools, and system controls.', - settings_heading_title: 'Control Center', - settings_section_appearance_meta: 'Theme, accent colors, and visual style.', - settings_section_appearance_title: 'Appearance', - settings_section_conversation_title: 'Conversation', - settings_section_preferences_meta: 'Defaults and UI behavior for Hermes Web UI.', - settings_section_preferences_title: 'Preferences', - settings_section_system_meta: 'Instance version and access controls.', + settings_dropdown_appearance: 'Внешний вид', + settings_dropdown_conversation: 'Беседа', + settings_dropdown_preferences: 'Параметры', + settings_dropdown_providers: 'Провайдеры', + settings_dropdown_system: 'Система', + settings_heading_subtitle: 'Настройки, инструменты беседы и системные элементы управления.', + settings_heading_title: 'Центр управления', + settings_section_appearance_meta: 'Тема, акцентные цвета и визуальный стиль.', + settings_section_appearance_title: 'Внешний вид', + settings_section_conversation_title: 'Беседа', + settings_section_preferences_meta: 'Значения по умолчанию и поведение интерфейса Hermes WebUI.', + settings_section_preferences_title: 'Параметры', + settings_section_system_meta: 'Версия экземпляра и управление доступом.', settings_check_now: 'Проверить', settings_checking: 'Проверка\u2026', settings_up_to_date: 'Актуально \u2713', + update_later: 'Позже', + update_now: 'Обновить сейчас', + update_updating: 'Обновление…', + update_force: 'Обновить принудительно', + update_clear_lock_retry: 'Снять блокировку и повторить', + update_hard_refresh_now: 'Обновить без кэша', + update_no_target: 'Цель обновления не выбрана. Обновите статус и повторите.', + update_failed_network: 'Не удалось обновить: сервер WebUI недоступен. Возможно, он перезапустился или соединение прервалось. Подождите несколько секунд, перезагрузите страницу и проверьте сервер, если он не вернётся.', + update_failed_prefix: 'Не удалось обновить: ', settings_updates_available: 'Доступно обновлений: {count}', settings_updates_disabled: 'Проверка обновлений отключена', settings_update_check_failed: 'Ошибка проверки обновлений', @@ -6536,82 +6619,82 @@ const LOCALES = { settings_desc_session_endless_scroll: 'Если включено, старые сообщения загружаются автоматически при прокрутке вверх. Если выключено, используйте кнопку загрузки старых сообщений.', settings_label_auto_scroll_follow: 'Автопрокрутка к новому содержимому', settings_desc_auto_scroll_follow: 'Если включено, область прокручивается вниз по мере поступления новых токенов. Если выключено, вы сами управляете прокруткой.', - settings_label_render_user_markdown: 'Render markdown in user messages', + settings_label_render_user_markdown: 'Отображать Markdown в сообщениях пользователя', settings_label_show_titlebar_profile: 'Показывать переключатель профилей в заголовке окна', settings_desc_show_titlebar_profile: 'Если включено, в заголовке окна в левом верхнем углу появляется кнопка переключения профилей, позволяющая менять профиль с любой вкладки. По умолчанию выключено; в нижней части редактора всегда есть переключатель профилей независимо от этой настройки.', - settings_desc_render_user_markdown: 'When enabled, bold, italic, links, and other markdown in your own messages are rendered. Off by default; fenced code blocks and math always render regardless of this setting.', - settings_label_large_text_paste_as_attachment: 'Attach large pasted text as file', - settings_desc_large_text_paste_as_attachment: 'When enabled, long pasted text becomes a .md attachment instead of filling the composer.', - settings_label_project_quick_create: 'Show per-project new-conversation buttons', - settings_desc_project_quick_create: 'When enabled, each project chip in the sidebar gets a + button to start a conversation in that project.', - settings_label_structured_code: 'JSON/YAML code blocks', - settings_option_structured_code_auto: 'Auto: Tree for long blocks', - settings_option_structured_code_on: 'Tree by default', - settings_option_structured_code_off: 'Raw by default', - settings_label_structured_code_auto_lines: 'Auto threshold lines', - settings_desc_structured_code: 'When Auto is selected, JSON/YAML blocks with at least this many lines open in Tree view. Tree always opens the structured viewer; Raw keeps the syntax-highlighted text. You can still switch Tree/Raw on each block.', - settings_label_worklog_details_expanded_default: 'Open Worklog details automatically', - settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.', - settings_label_chat_activity_display_mode: 'Activity display', - settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row. Final answer only hides activity rows while keeping the final answer visible.', - first_token_time: 'Time to first token', - processed_elapsed: _i18nProcessedElapsedEn, - worklog_thinking: 'Thinking', - tool_target_skill_suffix: 'skill', - tool_action_label: _i18nToolActionLabelEn, - tool_worklog_summary: _i18nToolWorklogSummaryEn, - tool_summary_join: _i18nToolSummaryJoinEn, - done: 'Done', - settings_option_compact_worklog: 'Compact Worklog', - settings_option_transparent_stream: 'Transparent Stream', - settings_option_final_answer_only: 'Final answer only', + settings_desc_render_user_markdown: 'Если включено, жирный текст, курсив, ссылки и другой Markdown в ваших сообщениях будут отрисованы. По умолчанию выключено; блоки кода и математика отображаются всегда.', + settings_label_large_text_paste_as_attachment: 'Прикреплять большой вставленный текст как файл', + settings_desc_large_text_paste_as_attachment: 'Если включено, длинный вставленный текст становится .md-вложением, а не заполняет поле ввода.', + settings_label_project_quick_create: 'Показывать кнопки новой беседы у проектов', + settings_desc_project_quick_create: 'Если включено, у каждого проекта в боковой панели появляется кнопка + для запуска беседы в этом проекте.', + settings_label_structured_code: 'Блоки кода JSON/YAML', + settings_option_structured_code_auto: 'Авто: дерево для длинных блоков', + settings_option_structured_code_on: 'Дерево по умолчанию', + settings_option_structured_code_off: 'Исходный текст по умолчанию', + settings_label_structured_code_auto_lines: 'Порог строк для авто', + settings_desc_structured_code: 'В режиме «Авто» блоки JSON/YAML с таким числом строк или больше открываются в виде дерева. «Дерево» всегда открывает структурный просмотр; «Исходный текст» оставляет подсвеченный код. На каждом блоке можно переключаться между деревом и исходным текстом.', + settings_label_worklog_details_expanded_default: 'Автоматически раскрывать детали Worklog', + settings_desc_worklog_details_expanded_default: 'Если включено, новые детали Worklog открываются раскрытыми, чтобы инструменты, рассуждения и прогресс были видны без дополнительного клика. Если выключено, Worklog по умолчанию остаётся свёрнутым; ручной выбор для конкретного хода сохраняется.', + settings_label_chat_activity_display_mode: 'Отображение активности', + settings_desc_chat_activity_display_mode: 'Компактный Worklog группирует вспомогательную активность в спокойную сводку. Прозрачный поток оставляет каждое рассуждение и событие инструмента отдельной хронологической раскрываемой строкой. Режим «Только финальный ответ» скрывает строки активности, оставляя видимым финальный ответ.', + first_token_time: 'Время до первого токена', + processed_elapsed: _i18nProcessedElapsedRu, + worklog_thinking: 'Размышление', + tool_target_skill_suffix: 'навык', + tool_action_label: _i18nToolActionLabelRu, + tool_worklog_summary: _i18nToolWorklogSummaryRu, + tool_summary_join: _i18nToolSummaryJoinRu, + done: 'Готово', + settings_option_compact_worklog: 'Компактный Worklog', + settings_option_transparent_stream: 'Прозрачный поток', + settings_option_final_answer_only: 'Только финальный ответ', settings_label_tab_visibility: 'Вкладки боковой панели', settings_desc_tab_visibility: 'Выберите, какие вкладки отображаются на боковой панели и в рейле. Чат и настройки всегда видны.', - settings_label_composer_controls: 'Chat footer controls', - settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Drag chips to reorder each footer group.', - settings_label_composer_situational_controls: 'Situational footer controls', - settings_desc_composer_situational_controls: 'These controls do not always appear, depending on viewport, mode, or runtime state. You can still toggle them off if needed.', - composer_control_attach: 'Attach', - composer_control_saved_prompts: 'Saved prompts', - composer_control_mic: 'Mic', - composer_control_profile: 'Profile', - composer_control_workspace: 'Workspace', - composer_control_model: 'Model', - composer_control_reasoning: 'Reasoning', - composer_control_context: 'Context', - composer_control_voice_mode: 'Voice mode', + settings_label_composer_controls: 'Элементы подвала чата', + settings_desc_composer_controls: 'Выберите, какие элементы показывать в подвале чата. Перетаскивайте чипы, чтобы менять порядок в каждой группе.', + settings_label_composer_situational_controls: 'Контекстные элементы подвала', + settings_desc_composer_situational_controls: 'Эти элементы появляются не всегда: это зависит от ширины экрана, режима или состояния среды выполнения. При необходимости их всё равно можно отключить.', + composer_control_attach: 'Вложение', + composer_control_saved_prompts: 'Сохранённые промпты', + composer_control_mic: 'Микрофон', + composer_control_profile: 'Профиль', + composer_control_workspace: 'Рабочая область', + composer_control_model: 'Модель', + composer_control_reasoning: 'Рассуждение', + composer_control_context: 'Контекст', + composer_control_voice_mode: 'Голосовой режим', composer_control_yolo: 'YOLO', - composer_control_bg_badge: 'Background badge', - composer_control_mobile_config: 'Mobile config', - composer_control_quota_chip: 'Quota chip', - composer_control_toolsets: 'Toolsets', - composer_control_status: 'Status', + composer_control_bg_badge: 'Фоновый индикатор', + composer_control_mobile_config: 'Мобильные настройки', + composer_control_quota_chip: 'Чип квоты', + composer_control_toolsets: 'Наборы инструментов', + composer_control_status: 'Статус', open_in_browser: 'Открыть в браузере', - settings_section_system_title: 'System', - settings_tab_appearance: 'Appearance', - settings_tab_conversation: 'Conversation', - settings_tab_preferences: 'Preferences', + settings_section_system_title: 'Система', + settings_tab_appearance: 'Внешний вид', + settings_tab_conversation: 'Беседа', + settings_tab_preferences: 'Параметры', settings_tab_plugins: 'Плагины', settings_tab_extensions: 'Расширения', - settings_extensions_gallery_tab: 'Gallery', - settings_extensions_installed_tab: 'Installed', - settings_extensions_diagnostics_tab: 'Diagnostics', - settings_extensions_gallery_loading: 'Loading gallery…', - settings_extensions_gallery_empty: 'No extensions found in the registry.', - settings_extensions_gallery_error: 'Failed to load gallery.', - ext_gallery_install: 'Install', - ext_gallery_installing: 'Installing…', - ext_gallery_installed: 'Installed', - ext_gallery_uninstall: 'Uninstall', - ext_gallery_uninstalling: 'Uninstalling…', - ext_gallery_restart_required: 'Restart required', - ext_gallery_permissions_show: 'Permissions', - ext_gallery_permissions_hide: 'Hide permissions', + settings_extensions_gallery_tab: 'Галерея', + settings_extensions_installed_tab: 'Установленные', + settings_extensions_diagnostics_tab: 'Диагностика', + settings_extensions_gallery_loading: 'Загрузка галереи…', + settings_extensions_gallery_empty: 'В реестре расширения не найдены.', + settings_extensions_gallery_error: 'Не удалось загрузить галерею.', + ext_gallery_install: 'Установить', + ext_gallery_installing: 'Установка…', + ext_gallery_installed: 'Установлено', + ext_gallery_uninstall: 'Удалить', + ext_gallery_uninstalling: 'Удаление…', + ext_gallery_restart_required: 'Требуется перезапуск', + ext_gallery_permissions_show: 'Разрешения', + ext_gallery_permissions_hide: 'Скрыть разрешения', ext_gallery_permissions_empty: 'Дополнительные разрешения не указаны.', - ext_gallery_version: 'Version', - ext_gallery_author: 'Author', - ext_gallery_capabilities: 'Capabilities', + ext_gallery_version: 'Версия', + ext_gallery_author: 'Автор', + ext_gallery_capabilities: 'Возможности', ext_gallery_next_step: 'Следующий шаг', ext_gallery_after_install: 'После установки', ext_gallery_local_component_required: 'У этого расширения есть локальный компонент. После установки запустите требуемое локальное приложение или sidecar.', @@ -6622,8 +6705,8 @@ const LOCALES = { ext_gallery_open_setup_guide: 'Открыть руководство по настройке', ext_gallery_install_restart_required: 'Расширение установлено. Для применения требуется перезапуск.', ext_gallery_install_followup: 'Расширение установлено. Следующие шаги смотрите на карточке.', - ext_gallery_install_ok: 'Extension installed successfully.', - ext_gallery_uninstall_ok: 'Extension uninstalled.', + ext_gallery_install_ok: 'Расширение установлено.', + ext_gallery_uninstall_ok: 'Расширение удалено.', settings_plugins_title: 'Плагины', plugins_enable_toggle: 'Включить', settings_plugins_meta: 'Просмотр установленных плагинов Hermes и регистрируемых ими хуков жизненного цикла. Эта панель доступна только для чтения.', @@ -6637,29 +6720,46 @@ const LOCALES = { plugins_active_provider: 'Активен (провайдер)', plugins_provider_no_hooks: 'Плагин провайдера — нет хуков видимости для агента', plugins_load_failed: 'Не удалось загрузить плагины: ', - settings_tab_system: 'System', - settings_tab_help: 'Help', - settings_help_meta: 'Resources and support for Hermes WebUI.', - settings_help_docs_label: 'Documentation', - settings_help_docs_desc: 'Guides, configuration reference, and the full Hermes WebUI README.', - settings_help_docs_link: 'Open Documentation', - settings_help_issue_label: 'Having an issue?', - settings_help_issue_desc: 'Search existing reports or open a new one on GitHub.', - settings_help_issue_link: 'Open GitHub Issues', - status_updated: 'Updated', - status_ephemeral: 'Ephemeral snapshot — not saved to transcript history.', - status_no_tokens: 'No token data', - status_profile: 'Profile', - status_hermes_home: 'Hermes home', - status_started: 'Started', - status_tokens: 'Tokens', - status_unknown: 'Unknown', - cmd_branch:'Fork this conversation into a new session', - cmd_branch_usage:'/branch [name] — fork conversation (optionally with a name)', - branch_forked:'Forked into new session', - branch_failed:'Fork failed: ', - fork_from_here:'Fork from here', - forked_from:'Forked from', + settings_tab_system: 'Система', + settings_tab_help: 'Справка', + settings_help_meta: 'Ресурсы и поддержка Hermes WebUI.', + settings_help_docs_label: 'Документация', + settings_help_docs_desc: 'Руководства, справочник конфигурации и полный README Hermes WebUI.', + settings_help_docs_link: 'Открыть документацию', + settings_help_issue_label: 'Возникла проблема?', + settings_help_issue_desc: 'Поищите существующие отчёты или создайте новый на GitHub.', + settings_help_issue_link: 'Открыть GitHub Issues', + status_updated: 'Обновлено', + status_ephemeral: 'Временный снимок — не сохраняется в истории беседы.', + status_no_tokens: 'Нет данных о токенах', + status_profile: 'Профиль', + status_hermes_home: 'Домашний каталог Hermes', + status_started: 'Запущено', + status_tokens: 'Токены', + status_unknown: 'Неизвестно', + status_yes: 'Да', + status_no: 'Нет', + status_load_failed: 'Не удалось загрузить статус: ', + title_current: 'Текущее название', + title_change_hint: 'Используйте `/title <новое имя>`, чтобы переименовать.', + title_set: 'Название изменено на', + cmd_webui_only_session: 'Эта команда недоступна для сеансов, импортированных из CLI.', + cmd_voice_use_mic: 'Нажмите кнопку микрофона в поле ввода.', + usage_heading: 'Использование токенов', + usage_default_model: 'по умолчанию', + usage_unknown: 'неизвестно', + usage_input_tokens: 'Входные токены', + usage_output_tokens: 'Выходные токены', + usage_total: 'Всего токенов', + usage_estimated_cost: 'Оценочная стоимость', + usage_settings_tip: 'Примечание: оценка стоимости приблизительная.', + usage_load_failed: 'Не удалось загрузить использование: ', + cmd_branch: 'Ответвить эту беседу в новый сеанс', + cmd_branch_usage: '/branch [name] — ответвить беседу (с необязательным именем)', + branch_forked: 'Создан новый сеанс-ответвление', + branch_failed: 'Не удалось создать ответвление: ', + fork_from_here: 'Ответвить отсюда', + forked_from: 'Ответвлено от', cmd_yolo: 'Переключить YOLO режим (без подтверждений)', yolo_no_session: 'Нет активного сеанса', yolo_enabled: '⚡ YOLO режим ВКЛ — подтверждения пропущены', @@ -6676,7 +6776,7 @@ const LOCALES = { composer_stop: 'Остановить генерацию', composer_disabled_clarify: 'Ответьте на запрос о разъяснении', composer_disabled_compression: 'Ожидание завершения сжатия', - composer_compression_will_queue: 'Type a message — it will queue and send after compression', + composer_compression_will_queue: 'Введите сообщение — оно попадёт в очередь и отправится после сжатия', composer_disabled_empty: 'Введите сообщение для отправки', composer_mobile_workspace: 'Рабочее пространство', composer_mobile_model: 'Модель', @@ -6706,7 +6806,7 @@ const LOCALES = { pdf_no_pages: 'Не удалось отобразить предварительный просмотр PDF', pdf_error: 'Не удалось загрузить PDF', pdf_download: 'Скачать PDF', - pdf_truncated: 'Showing the first {0} of {1} pages — download for the full document', + pdf_truncated: 'Показаны первые {0} из {1} страниц — скачайте файл, чтобы увидеть документ целиком', html_loading: 'Загрузка предпросмотра HTML…', html_too_large: 'HTML слишком большой для встроенного просмотра', html_error: 'Не удалось загрузить предпросмотр HTML', @@ -6734,7 +6834,7 @@ const LOCALES = { settings_desc_tts_engine: 'Выберите движок синтеза речи. Edge TTS использует нейронные голоса Microsoft через сервер.', checkpoint_date: 'Дата', - checkpoint_diff_files_changed: (n) => `${n} файл${n === 1 ? '' : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 'а' : 'ов')} изменено`, + checkpoint_diff_files_changed: (n) => `${n} ${_i18nRuPlural(n, 'файл изменён', 'файла изменено', 'файлов изменено')}`, checkpoint_diff_no_changes: 'Различий между этой контрольной точкой и текущим рабочим пространством не найдено.', checkpoint_diff_title: 'Изменения в контрольной точке', checkpoint_empty: 'Для этого рабочего пространства контрольные точки не найдены.', @@ -6751,19 +6851,19 @@ const LOCALES = { insights_activity_by_day: 'Активность по дням', insights_activity_by_hour: 'Активность по часам', insights_cost: 'Оценочная стоимость', - insights_daily_tokens: 'Daily Tokens', - insights_model_name: 'Model', - insights_model_sessions: 'Sessions', - insights_model_tokens: 'Tokens', - insights_model_cost: 'Cost', + insights_daily_tokens: 'Токены по дням', + insights_model_name: 'Модель', + insights_model_sessions: 'Сеансы', + insights_model_tokens: 'Токены', + insights_model_cost: 'Стоимость', insights_model_cache: 'Кэш', insights_cache_hit: 'Доля попаданий в кэш промптов', - insights_model_share: 'Share', - insights_model_health_title: 'Model health comparison', - insights_model_health_provider: 'Provider', - insights_model_health_replacement: 'Replacement guide', - insights_model_health_cost_per_m: 'Cost / 1M', - insights_no_usage_data: 'No usage data yet', + insights_model_share: 'Доля', + insights_model_health_title: 'Сравнение состояния моделей', + insights_model_health_provider: 'Провайдер', + insights_model_health_replacement: 'Когда использовать / чем заменить', + insights_model_health_cost_per_m: 'Стоимость / 1 млн', + insights_no_usage_data: 'Данных об использовании пока нет', insights_footer: 'Показаны данные за последние {days} дн.', insights_skill_usage_title: 'Использование навыков', insights_skill_usage_sub: 'Частота вызова инструментов', @@ -6791,13 +6891,42 @@ const LOCALES = { settings_desc_api_redact: 'Пользователи с собственным хостингом могут отключить для прозрачности (не рекомендуется для общих экземпляров).', settings_label_api_redact: 'Скрывать конфиденциальные данные в ответах API', subagent_children: 'Сессии субагентов', + btw_asking: 'Задаю побочный вопрос…', + btw_label: 'Побочный вопрос — не сохраняется в истории', + btw_done: 'Побочный вопрос обработан', + btw_no_answer: 'Ответ не получен.', + btw_failed: 'Не удалось задать побочный вопрос: ', + bg_running: 'Выполняется в фоне…', + bg_complete: 'Фоновая задача завершена', + bg_label: 'Фоновый результат:', + bg_no_answer: '(нет ответа)', + bg_failed: 'Фоновая задача завершилась ошибкой: ', + undo_exchange: 'Отменить последний обмен', + cmd_status: 'Показать сведения о сеансе', + cmd_voice: 'Включить или выключить ввод с микрофона', + stream_stopped: 'Ответ остановлен.', + no_active_task: 'Нет активной задачи для остановки.', + cancel_unavailable: 'Отмена недоступна.', + retry_failed: 'Не удалось повторить: ', + undo_failed: 'Не удалось отменить: ', + undid_n_messages: 'Удалено', + undid_messages_suffix: 'сообщ.', + status_heading: 'Статус сеанса', + status_session_id: 'ID сеанса', + status_title: 'Название', + status_model: 'Модель', + status_provider: 'Провайдер', + status_workspace: 'Рабочая область', + status_personality: 'Личность', + status_messages: 'Сообщения', + status_agent_running: 'Агент работает', outline_title: 'Структура', outline_empty: 'Пока нет вопросов.', outline_loading: 'Загрузка…', - wiki_browse: 'Browse wiki', - wiki_search_placeholder: 'Search pages...', - wiki_no_pages: 'No wiki pages found', - wiki_not_configured: 'Wiki not configured', + wiki_browse: 'Просмотреть wiki', + wiki_search_placeholder: 'Искать страницы…', + wiki_no_pages: 'Страницы wiki не найдены', + wiki_not_configured: 'Wiki не настроена', }, es: { @@ -7147,6 +7276,16 @@ const LOCALES = { workspace_empty_dir: 'Este espacio de trabajo está vacío.', workspace_show_hidden_files: 'Mostrar archivos ocultos', workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.', + workspace_panel_show: 'Show workspace panel', + workspace_panel_hide: 'Hide workspace panel', + workspace_panel_close: 'Close workspace panel', + workspace_panel_toggle: 'Toggle workspace files panel', + workspace_panel_views: 'Workspace panel views', + workspace_parent_directory: 'Parent directory', + workspace_upload_file: 'Upload file', + workspace_close_preview: 'Close preview', + workspace_files_tab: 'Files', + workspace_artifacts_tab: 'Artifacts', workspace_hidden_files_visible: 'hidden visible', workspace_hidden_files_visible_title: 'Hidden files are visible — click for options', workspace_options: 'Workspace options', @@ -7740,6 +7879,10 @@ const LOCALES = { onboarding_current_provider: 'Configuración actual:', onboarding_missing_imports: 'Importaciones faltantes:', onboarding_notice_setup_required: 'Elige aquí una ruta simple de proveedor. Los flujos OAuth avanzados siguen siendo del CLI de Hermes por ahora.', + onboarding_notice_provider_choice_required: 'Hermes está instalado, pero aún debes elegir un proveedor y guardar credenciales válidas.', + onboarding_notice_custom_base_url_required: 'Hermes ya tiene guardada una selección de proveedor/modelo, pero el proveedor personalizado todavía necesita una URL base. Añade también la clave API si ese servidor la requiere.', + onboarding_notice_provider_auth_required: 'El proveedor "{0}" está configurado pero aún no autenticado. Ejecuta "hermes auth" o "hermes model" en una terminal para completar la configuración y luego recarga Web UI.', + onboarding_notice_provider_api_key_required: 'Hermes ya tiene guardada una selección de proveedor/modelo, pero todavía necesita la clave API requerida para chatear.', onboarding_notice_setup_already_ready: 'Ya se detectó una configuración funcional del proveedor de Hermes. Puedes conservarla o reemplazarla aquí.', onboarding_oauth_provider_ready_title: 'Proveedor ya autenticado', onboarding_oauth_provider_ready_body: 'Esta instancia está configurada para usar un proveedor OAuth ({provider}) configurado mediante la CLI de Hermes. No se necesita clave API aquí — haz clic en Continuar para finalizar la configuración.', @@ -8153,6 +8296,15 @@ const LOCALES = { settings_check_now: 'Comprobar ahora', settings_checking: 'Comprobando\u2026', settings_up_to_date: 'Actualizado \u2713', + update_later: 'Later', + update_now: 'Update Now', + update_updating: 'Updating…', + update_force: 'Force update', + update_clear_lock_retry: 'Clear lock and retry update', + update_hard_refresh_now: 'Hard refresh now', + update_no_target: 'No update target selected. Refresh update status and retry.', + update_failed_network: 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.', + update_failed_prefix: 'Update failed: ', settings_updates_available: '{count} actualización(es) disponible(s)', settings_updates_disabled: 'Comprobación de actualizaciones desactivada', settings_update_check_failed: 'Error al comprobar actualizaciones', @@ -8289,6 +8441,16 @@ const LOCALES = { status_started: 'Started', status_tokens: 'Tokens', status_unknown: 'Unknown', + slash_skill_badge: 'Habilidad', + slash_skill_desc: 'Aplicar esta habilidad', + cmd_stop: 'Detener la respuesta actual', + cmd_title: 'Mostrar o cambiar el título de la sesión', + cmd_retry: 'Reenviar el último mensaje', + cmd_undo: 'Eliminar el último intercambio', + cmd_btw: 'Hacer una pregunta lateral (temporal)', + cmd_btw_usage: '/btw — hacer una pregunta lateral usando el contexto de la sesión', + cmd_background: 'Ejecutar un prompt en segundo plano', + cmd_background_usage: '/background — ejecutar en paralelo sin bloquear el chat', cmd_yolo: 'Alternar modo YOLO (saltar aprobaciones)', cmd_branch:'Fork this conversation into a new session', cmd_branch_usage:'/branch [name] — fork conversation (optionally with a name)', @@ -8762,6 +8924,16 @@ const LOCALES = { workspace_empty_dir: 'Dieser Workspace ist leer.', workspace_show_hidden_files: 'Versteckte Dateien anzeigen', workspace_show_hidden_files_desc: '.DS_Store, .git, node_modules und weitere versteckte / Systemdateien im Dateibaum anzeigen.', + workspace_panel_show: 'Show workspace panel', + workspace_panel_hide: 'Hide workspace panel', + workspace_panel_close: 'Close workspace panel', + workspace_panel_toggle: 'Toggle workspace files panel', + workspace_panel_views: 'Workspace panel views', + workspace_parent_directory: 'Parent directory', + workspace_upload_file: 'Upload file', + workspace_close_preview: 'Close preview', + workspace_files_tab: 'Files', + workspace_artifacts_tab: 'Artifacts', workspace_hidden_files_visible: 'versteckt sichtbar', workspace_hidden_files_visible_title: 'Versteckte Dateien sind sichtbar — Klicken für Optionen', workspace_options: 'Arbeitsbereich-Optionen', @@ -9466,6 +9638,15 @@ const LOCALES = { settings_check_now: 'Jetzt prüfen', settings_checking: 'Prüfung\u2026', settings_up_to_date: 'Aktuell \u2713', + update_later: 'Later', + update_now: 'Update Now', + update_updating: 'Updating…', + update_force: 'Force update', + update_clear_lock_retry: 'Clear lock and retry update', + update_hard_refresh_now: 'Hard refresh now', + update_no_target: 'No update target selected. Refresh update status and retry.', + update_failed_network: 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.', + update_failed_prefix: 'Update failed: ', settings_updates_available: '{count} Update(s) verfügbar', settings_updates_disabled: 'Update-Prüfung deaktiviert', settings_update_check_failed: 'Update-Prüfung fehlgeschlagen', @@ -9711,6 +9892,10 @@ const LOCALES = { onboarding_current_provider: 'Aktueller Anbieter', onboarding_missing_imports: 'Fehlende Abhängigkeiten', onboarding_notice_setup_required: 'Einrichtung erforderlich', + onboarding_notice_provider_choice_required: 'Hermes ist installiert, aber Sie müssen noch einen Anbieter wählen und funktionierende Zugangsdaten speichern.', + onboarding_notice_custom_base_url_required: 'Hermes hat bereits eine Anbieter-/Modellauswahl gespeichert, aber der benutzerdefinierte Anbieter benötigt noch eine Basis-URL. Fügen Sie auch den API-Schlüssel hinzu, falls dieser Server ihn verlangt.', + onboarding_notice_provider_auth_required: 'Anbieter "{0}" ist konfiguriert, aber noch nicht authentifiziert. Führen Sie im Terminal "hermes auth" oder "hermes model" aus, schließen Sie die Einrichtung ab und laden Sie Web UI neu.', + onboarding_notice_provider_api_key_required: 'Hermes hat bereits eine Anbieter-/Modellauswahl gespeichert, benötigt aber noch den API-Schlüssel für den Chat.', onboarding_notice_setup_already_ready: 'Bereits eingerichtet', onboarding_oauth_provider_ready_title: 'Anbieter bereit', onboarding_oauth_provider_ready_body: '{provider} ist konfiguriert. Kein API-Schlüssel erforderlich.', @@ -10431,6 +10616,16 @@ const LOCALES = { workspace_empty_dir: '此工作区为空。', workspace_show_hidden_files: '显示隐藏文件', workspace_show_hidden_files_desc: '将 .DS_Store、.git、node_modules 以及其他隐藏文件/系统文件包含在文件树中。', + workspace_panel_show: 'Show workspace panel', + workspace_panel_hide: 'Hide workspace panel', + workspace_panel_close: 'Close workspace panel', + workspace_panel_toggle: 'Toggle workspace files panel', + workspace_panel_views: 'Workspace panel views', + workspace_parent_directory: 'Parent directory', + workspace_upload_file: 'Upload file', + workspace_close_preview: 'Close preview', + workspace_files_tab: 'Files', + workspace_artifacts_tab: 'Artifacts', workspace_hidden_files_visible: '隐藏 显示', workspace_hidden_files_visible_title: '隐藏文件已显示 — 点击查看选项', workspace_options: '工作区选项', @@ -11046,6 +11241,10 @@ const LOCALES = { onboarding_current_provider: '当前配置:', onboarding_missing_imports: '缺失导入:', onboarding_notice_setup_required: '请先在此选择一个简单的提供商路径。高级 OAuth 流程暂时仍建议在 Hermes CLI 中完成。', + onboarding_notice_provider_choice_required: 'Hermes 已安装,但仍需选择提供商并保存可用凭据。', + onboarding_notice_custom_base_url_required: 'Hermes 已保存提供商/模型选择,但自定义提供商仍需要 Base URL。如果该服务器要求 API 密钥,也请一并添加。', + onboarding_notice_provider_auth_required: '提供商“{0}”已配置但尚未认证。请在终端运行 "hermes auth" 或 "hermes model" 完成设置,然后重新加载 Web UI。', + onboarding_notice_provider_api_key_required: 'Hermes 已保存提供商/模型选择,但聊天仍需要相应的 API 密钥。', onboarding_notice_setup_already_ready: '已检测到可用的 Hermes 提供商配置。你可以保留它,或在这里替换。', onboarding_oauth_provider_ready_title: '提供商已完成认证', onboarding_oauth_provider_ready_body: '此实例已配置为使用通过 Hermes CLI 设置的 OAuth 提供商({provider})。这里不需要 API key,点击继续即可完成设置。', @@ -11458,6 +11657,15 @@ const LOCALES = { settings_check_now: '立即检查', settings_checking: '检查中\u2026', settings_up_to_date: '已是最新 \u2713', + update_later: 'Later', + update_now: 'Update Now', + update_updating: 'Updating…', + update_force: 'Force update', + update_clear_lock_retry: 'Clear lock and retry update', + update_hard_refresh_now: 'Hard refresh now', + update_no_target: 'No update target selected. Refresh update status and retry.', + update_failed_network: 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.', + update_failed_prefix: 'Update failed: ', settings_updates_available: '有 {count} 个更新可用', settings_updates_disabled: '更新检查已禁用', settings_update_check_failed: '更新检查失败', @@ -12186,6 +12394,16 @@ const LOCALES = { workspace_empty_dir: '此工作區為空。', workspace_show_hidden_files: '顯示隱藏檔案', workspace_show_hidden_files_desc: '在檔案樹中包含 .DS_Store、.git、node_modules 與其他隱藏/系統檔案。', + workspace_panel_show: 'Show workspace panel', + workspace_panel_hide: 'Hide workspace panel', + workspace_panel_close: 'Close workspace panel', + workspace_panel_toggle: 'Toggle workspace files panel', + workspace_panel_views: 'Workspace panel views', + workspace_parent_directory: 'Parent directory', + workspace_upload_file: 'Upload file', + workspace_close_preview: 'Close preview', + workspace_files_tab: 'Files', + workspace_artifacts_tab: 'Artifacts', workspace_hidden_files_visible: '顯示隱藏檔', workspace_hidden_files_visible_title: '隱藏檔已顯示,點選可檢視選項', workspace_options: '工作區選項', @@ -12349,6 +12567,15 @@ const LOCALES = { settings_check_now: '立即檢查', settings_checking: '檢查中…', settings_up_to_date: '已是最新 ✓', + update_later: 'Later', + update_now: 'Update Now', + update_updating: 'Updating…', + update_force: 'Force update', + update_clear_lock_retry: 'Clear lock and retry update', + update_hard_refresh_now: 'Hard refresh now', + update_no_target: 'No update target selected. Refresh update status and retry.', + update_failed_network: 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.', + update_failed_prefix: 'Update failed: ', settings_updates_available: '有 {count} 個更新可用', settings_updates_disabled: '更新檢查已停用', settings_update_check_failed: '更新檢查失敗', @@ -13054,6 +13281,10 @@ const LOCALES = { onboarding_current_provider: '目前設定:', onboarding_missing_imports: '缺少的匯入內容:', onboarding_notice_setup_required: '請在這裡選擇簡易供應商路徑。進階 OAuth 流程目前仍需使用 Hermes CLI 處理。', + onboarding_notice_provider_choice_required: 'Hermes 已安裝,但仍需要選擇供應商並儲存可用憑證。', + onboarding_notice_custom_base_url_required: 'Hermes 已儲存供應商/模型選擇,但自訂供應商仍需要 Base URL。如果該伺服器要求 API 金鑰,也請一併新增。', + onboarding_notice_provider_auth_required: '供應商「{0}」已設定但尚未驗證。請在終端機執行 "hermes auth" 或 "hermes model" 完成設定,然後重新載入 Web UI。', + onboarding_notice_provider_api_key_required: 'Hermes 已儲存供應商/模型選擇,但聊天仍需要對應的 API 金鑰。', onboarding_notice_setup_already_ready: '已偵測到可用的 Hermes 供應商設定。可以保留或在這裡替換。', onboarding_oauth_provider_ready_title: '供應商已完成驗證', onboarding_oauth_provider_ready_body: '這個執行個體已設定為使用 Hermes CLI 設定的 OAuth 供應商({provider})。這裡不需要 API 金鑰,請點選 [繼續] 完成設定。', @@ -13793,6 +14024,16 @@ const LOCALES = { workspace_empty_dir: 'Este workspace está vazio.', workspace_show_hidden_files: 'Mostrar arquivos ocultos', workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.', + workspace_panel_show: 'Show workspace panel', + workspace_panel_hide: 'Hide workspace panel', + workspace_panel_close: 'Close workspace panel', + workspace_panel_toggle: 'Toggle workspace files panel', + workspace_panel_views: 'Workspace panel views', + workspace_parent_directory: 'Parent directory', + workspace_upload_file: 'Upload file', + workspace_close_preview: 'Close preview', + workspace_files_tab: 'Files', + workspace_artifacts_tab: 'Artifacts', workspace_hidden_files_visible: 'hidden visible', workspace_hidden_files_visible_title: 'Hidden files are visible — click for options', workspace_options: 'Workspace options', @@ -13956,6 +14197,15 @@ const LOCALES = { settings_check_now: 'Verificar agora', settings_checking: 'Verificando…', settings_up_to_date: 'Atualizado ✓', + update_later: 'Later', + update_now: 'Update Now', + update_updating: 'Updating…', + update_force: 'Force update', + update_clear_lock_retry: 'Clear lock and retry update', + update_hard_refresh_now: 'Hard refresh now', + update_no_target: 'No update target selected. Refresh update status and retry.', + update_failed_network: 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.', + update_failed_prefix: 'Update failed: ', settings_updates_available: '{count} atualização(ões) disponível(is)', settings_updates_disabled: 'Verificação de updates desativada', settings_update_check_failed: 'Falha ao verificar updates', @@ -14585,6 +14835,10 @@ const LOCALES = { onboarding_current_provider: 'Config atual:', onboarding_missing_imports: 'Imports ausentes:', onboarding_notice_setup_required: 'Escolha caminho simples de provedor aqui. OAuth avançado ainda pertence ao Hermes CLI.', + onboarding_notice_provider_choice_required: 'Hermes está instalado, mas ainda é preciso escolher um provedor e salvar credenciais funcionais.', + onboarding_notice_custom_base_url_required: 'Hermes já tem uma seleção de provedor/modelo salva, mas o provedor personalizado ainda precisa de uma URL base. Adicione também a chave de API se esse servidor exigir.', + onboarding_notice_provider_auth_required: 'O provedor "{0}" está configurado, mas ainda não autenticado. Execute "hermes auth" ou "hermes model" em um terminal para concluir a configuração e recarregue o Web UI.', + onboarding_notice_provider_api_key_required: 'Hermes já tem uma seleção de provedor/modelo salva, mas ainda precisa da chave de API para conversar.', onboarding_notice_setup_already_ready: 'Setup de provedor Hermes já detectado. Pode manter ou substituir.', onboarding_oauth_provider_ready_title: 'Provedor já autenticado', onboarding_oauth_provider_ready_body: 'Esta instância usa provedor OAuth ({provider}) configurado via CLI. Sem API key necessária.', @@ -15394,6 +15648,16 @@ const LOCALES = { workspace_empty_dir: 'This workspace is empty.', workspace_show_hidden_files: '숨김 파일 표시', workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.', + workspace_panel_show: 'Show workspace panel', + workspace_panel_hide: 'Hide workspace panel', + workspace_panel_close: 'Close workspace panel', + workspace_panel_toggle: 'Toggle workspace files panel', + workspace_panel_views: 'Workspace panel views', + workspace_parent_directory: 'Parent directory', + workspace_upload_file: 'Upload file', + workspace_close_preview: 'Close preview', + workspace_files_tab: 'Files', + workspace_artifacts_tab: 'Artifacts', workspace_hidden_files_visible: 'hidden visible', workspace_hidden_files_visible_title: 'Hidden files are visible — click for options', workspace_options: 'Workspace options', @@ -15565,6 +15829,15 @@ const LOCALES = { settings_check_now: '지금 확인', settings_checking: '확인 중\u2026', settings_up_to_date: '최신 상태 \u2713', + update_later: 'Later', + update_now: 'Update Now', + update_updating: 'Updating…', + update_force: 'Force update', + update_clear_lock_retry: 'Clear lock and retry update', + update_hard_refresh_now: 'Hard refresh now', + update_no_target: 'No update target selected. Refresh update status and retry.', + update_failed_network: 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.', + update_failed_prefix: 'Update failed: ', settings_updates_available: '{count} update(s) available', settings_updates_disabled: 'Update checks disabled', settings_update_check_failed: 'Update check failed', @@ -16193,6 +16466,10 @@ const LOCALES = { onboarding_current_provider: 'Current setup:', onboarding_missing_imports: 'Missing imports:', onboarding_notice_setup_required: 'Choose a simple provider path here. Advanced OAuth flows still belong in the Hermes CLI for now.', + onboarding_notice_provider_choice_required: 'Hermes is installed, but you still need to choose a provider and save working credentials.', + onboarding_notice_custom_base_url_required: 'Hermes has a saved provider/model selection, but the custom provider still needs a base URL. Add the API key too if that server requires one.', + onboarding_notice_provider_auth_required: 'Provider "{0}" is configured but not yet authenticated. Run "hermes auth" or "hermes model" in a terminal to complete setup, then reload the Web UI.', + onboarding_notice_provider_api_key_required: 'Hermes has a saved provider/model selection but still needs the API key required to chat.', onboarding_notice_setup_already_ready: 'A working Hermes provider setup is already detected. You can keep it or replace it here.', onboarding_oauth_provider_ready_title: 'Provider already authenticated', onboarding_oauth_provider_ready_body: 'This instance is configured to use an OAuth provider ({provider}) that was set up via the Hermes CLI. No API key is needed here — click Continue to finish setup.', @@ -17118,6 +17395,16 @@ const LOCALES = { workspace_empty_dir: 'Cet espace de travail est vide.', workspace_show_hidden_files: 'Afficher les fichiers cachés', workspace_show_hidden_files_desc: 'Inclure .DS_Store, .git, node_modules et autres fichiers cachés/système dans l\'arborescence.', + workspace_panel_show: 'Show workspace panel', + workspace_panel_hide: 'Hide workspace panel', + workspace_panel_close: 'Close workspace panel', + workspace_panel_toggle: 'Toggle workspace files panel', + workspace_panel_views: 'Workspace panel views', + workspace_parent_directory: 'Parent directory', + workspace_upload_file: 'Upload file', + workspace_close_preview: 'Close preview', + workspace_files_tab: 'Files', + workspace_artifacts_tab: 'Artifacts', workspace_hidden_files_visible: 'cachés visibles', workspace_hidden_files_visible_title: 'Les fichiers cachés sont visibles — cliquez pour les options', workspace_options: 'Options de l\'espace de travail', @@ -17287,6 +17574,15 @@ const LOCALES = { settings_check_now: 'Vérifier maintenant', settings_checking: 'Vérification\u2026', settings_up_to_date: 'À jour ✓', + update_later: 'Later', + update_now: 'Update Now', + update_updating: 'Updating…', + update_force: 'Force update', + update_clear_lock_retry: 'Clear lock and retry update', + update_hard_refresh_now: 'Hard refresh now', + update_no_target: 'No update target selected. Refresh update status and retry.', + update_failed_network: 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.', + update_failed_prefix: 'Update failed: ', settings_updates_available: '{count} mise(s) à jour disponible(s)', settings_updates_disabled: 'Vérifications des mises à jour désactivées', settings_update_check_failed: 'Échec de la vérification des mises à jour', @@ -17980,6 +18276,10 @@ const LOCALES = { onboarding_current_provider: 'Configuration actuelle :', onboarding_missing_imports: 'Importations manquantes :', onboarding_notice_setup_required: 'Choisissez un chemin de fournisseur simple ici. Les flux OAuth avancés appartiennent encore à la CLI Hermes pour l\'instant.', + onboarding_notice_provider_choice_required: 'Hermes est installé, mais vous devez encore choisir un fournisseur et enregistrer des identifiants valides.', + onboarding_notice_custom_base_url_required: 'Hermes a déjà une sélection fournisseur/modèle enregistrée, mais le fournisseur personnalisé a encore besoin d’une URL de base. Ajoutez aussi la clé API si ce serveur l’exige.', + onboarding_notice_provider_auth_required: 'Le fournisseur "{0}" est configuré, mais pas encore authentifié. Exécutez "hermes auth" ou "hermes model" dans un terminal pour terminer la configuration, puis rechargez Web UI.', + onboarding_notice_provider_api_key_required: 'Hermes a déjà une sélection fournisseur/modèle enregistrée, mais la clé API nécessaire au chat manque encore.', onboarding_notice_setup_already_ready: 'Une configuration de fournisseur Hermes fonctionnelle est déjà détectée. Vous pouvez la conserver ou la remplacer ici.', onboarding_oauth_provider_ready_title: 'Fournisseur déjà authentifié', onboarding_oauth_provider_ready_body: 'Cette instance est configurée pour utiliser un fournisseur OAuth ({provider}) qui a été configuré via la CLI Hermes. Aucune clé API n\'est nécessaire ici — cliquez sur Continuer pour terminer la configuration.', @@ -18920,6 +19220,10 @@ const LOCALES = { onboarding_notice_password_recommended: 'Volitelné ale doporučené pokud budete vystavovat UI mimo localhost.', onboarding_notice_setup_already_ready: 'Je detekováno funkční nastavení poskytovatele Herma. Můžete ho zachovat nebo nahradit zde.', onboarding_notice_setup_required: 'Zvolte jednoduchou cestu poskytovatele zde. Pokročilé OAuth toky stále patří do CLI Herma.', + onboarding_notice_provider_choice_required: 'Hermes je nainstalovaný, ale ještě musíte vybrat poskytovatele a uložit funkční přihlašovací údaje.', + onboarding_notice_custom_base_url_required: 'Hermes má uloženou volbu poskytovatele/modelu, ale vlastní poskytovatel stále potřebuje základní URL. Pokud ji tento server vyžaduje, přidejte také API klíč.', + onboarding_notice_provider_auth_required: 'Poskytovatel "{0}" je nastavený, ale ještě není ověřený. Spusťte v terminálu "hermes auth" nebo "hermes model", dokončete nastavení a znovu načtěte Web UI.', + onboarding_notice_provider_api_key_required: 'Hermes má uloženou volbu poskytovatele/modelu, ale pro chat stále potřebuje API klíč.', onboarding_notice_system_ready: 'Hermes Agent se jeví jako dosažitelný z Web UI.', onboarding_notice_system_unavailable: 'Hermes Agent ještě není plně dostupný. Bootstrap ho může nainstalovat, ale nastavení poskytovatele může stále vyžadovat terminál.', onboarding_notice_workspace: 'Tyto hodnoty znovu používají stejná nastavení API jako normální aplikace.', @@ -19437,6 +19741,15 @@ const LOCALES = { settings_up_to_date: 'Aktuální ✓', settings_update_check_failed: 'Kontrola aktualizací selhala', settings_update_no_git: 'Nelze kontrolovat aktualizace (nejde o git checkout)', + update_later: 'Later', + update_now: 'Update Now', + update_updating: 'Updating…', + update_force: 'Force update', + update_clear_lock_retry: 'Clear lock and retry update', + update_hard_refresh_now: 'Hard refresh now', + update_no_target: 'No update target selected. Refresh update status and retry.', + update_failed_network: 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.', + update_failed_prefix: 'Update failed: ', settings_updates_available: 'Dostupných {count} aktualizací', settings_updates_disabled: 'Kontroly aktualizací zakázány', sign_out: 'Odhlásit se', @@ -20013,6 +20326,16 @@ const LOCALES = { voice_thinking: 'myšlení', workspace_empty_dir: 'Tento pracovní prostor je prázdný.', workspace_empty_no_path: 'Není vybrán žádný pracovní prostor. Nastavte pracovní prostor v Nastavení \u2192 Pracovní prostor pro procházení souborů.', + workspace_panel_show: 'Show workspace panel', + workspace_panel_hide: 'Hide workspace panel', + workspace_panel_close: 'Close workspace panel', + workspace_panel_toggle: 'Toggle workspace files panel', + workspace_panel_views: 'Workspace panel views', + workspace_parent_directory: 'Parent directory', + workspace_upload_file: 'Upload file', + workspace_close_preview: 'Close preview', + workspace_files_tab: 'Files', + workspace_artifacts_tab: 'Artifacts', workspace_hidden_files_visible: 'skryté viditelné', workspace_hidden_files_visible_title: 'Skryté soubory jsou viditelné — klikněte pro možnosti', workspace_new_worktree_conversation: 'Nová konverzace ve worktree', @@ -20465,6 +20788,16 @@ const LOCALES = { workspace_empty_dir: 'Bu çalışma alanı boş.', workspace_show_hidden_files: 'Gizli dosyaları göster', workspace_show_hidden_files_desc: 'Dosya ağacına .DS_Store, .git, node_modules ve diğer gizli / sistem dosyalarını ekleyin.', + workspace_panel_show: 'Show workspace panel', + workspace_panel_hide: 'Hide workspace panel', + workspace_panel_close: 'Close workspace panel', + workspace_panel_toggle: 'Toggle workspace files panel', + workspace_panel_views: 'Workspace panel views', + workspace_parent_directory: 'Parent directory', + workspace_upload_file: 'Upload file', + workspace_close_preview: 'Close preview', + workspace_files_tab: 'Files', + workspace_artifacts_tab: 'Artifacts', workspace_hidden_files_visible: 'gizli görünür', workspace_hidden_files_visible_title: 'Gizli dosyalar görünür — seçenekler için tıklayın', workspace_options: 'Çalışma alanı seçenekleri', @@ -20634,6 +20967,15 @@ const LOCALES = { settings_check_now: 'Şimdi kontrol et', settings_checking: 'Kontrol ediliyor\u2026', settings_up_to_date: 'Güncel \u2713', + update_later: 'Later', + update_now: 'Update Now', + update_updating: 'Updating…', + update_force: 'Force update', + update_clear_lock_retry: 'Clear lock and retry update', + update_hard_refresh_now: 'Hard refresh now', + update_no_target: 'No update target selected. Refresh update status and retry.', + update_failed_network: 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.', + update_failed_prefix: 'Update failed: ', settings_updates_available: '{count} güncelleme mevcut', settings_updates_disabled: 'Güncelleme kontrolleri devre dışı bırakıldı', settings_update_check_failed: 'Güncelleme kontrolü başarısız oldu', @@ -21260,6 +21602,10 @@ const LOCALES = { onboarding_current_provider: 'Mevcut kurulum:', onboarding_missing_imports: 'Eksik içe aktarmalar:', onboarding_notice_setup_required: 'Burada basit bir sağlayıcı yolu seçin. Gelişmiş OAuth akışları şimdilik Hermes CLI\'ye ait olmaya devam ediyor.', + onboarding_notice_provider_choice_required: 'Hermes kurulu, ancak hâlâ bir sağlayıcı seçip çalışan kimlik bilgilerini kaydetmeniz gerekiyor.', + onboarding_notice_custom_base_url_required: 'Hermes kayıtlı bir sağlayıcı/model seçimine sahip, ancak özel sağlayıcının hâlâ bir temel URL’ye ihtiyacı var. Bu sunucu gerektiriyorsa API anahtarını da ekleyin.', + onboarding_notice_provider_auth_required: '"{0}" sağlayıcısı yapılandırıldı ancak henüz kimliği doğrulanmadı. Kurulumu tamamlamak için terminalde "hermes auth" veya "hermes model" çalıştırın, ardından Web UI’yi yeniden yükleyin.', + onboarding_notice_provider_api_key_required: 'Hermes kayıtlı bir sağlayıcı/model seçimine sahip, ancak sohbet için gereken API anahtarı hâlâ eksik.', onboarding_notice_setup_already_ready: 'Çalışan bir Hermes sağlayıcı kurulumu zaten algılandı. Burada saklayabilir veya değiştirebilirsiniz.', onboarding_oauth_provider_ready_title: 'Sağlayıcının kimliği zaten doğrulandı', onboarding_oauth_provider_ready_body: 'Bu örnek, Hermes CLI aracılığıyla ayarlanmış bir OAuth sağlayıcısını ({provider}) kullanacak şekilde yapılandırılmıştır. Burada API anahtarına gerek yoktur; kurulumu tamamlamak için Devam\'a tıklayın.', @@ -22189,6 +22535,16 @@ const LOCALES = { workspace_empty_dir: 'Ten obszar roboczy jest pusty.', workspace_show_hidden_files: 'Pokaż ukryte pliki', workspace_show_hidden_files_desc: 'Dołącz .DS_Store, .git, node_modules i inne ukryte/systemowe pliki w drzewie plików.', + workspace_panel_show: 'Show workspace panel', + workspace_panel_hide: 'Hide workspace panel', + workspace_panel_close: 'Close workspace panel', + workspace_panel_toggle: 'Toggle workspace files panel', + workspace_panel_views: 'Workspace panel views', + workspace_parent_directory: 'Parent directory', + workspace_upload_file: 'Upload file', + workspace_close_preview: 'Close preview', + workspace_files_tab: 'Files', + workspace_artifacts_tab: 'Artifacts', workspace_hidden_files_visible: 'ukryte widoczne', workspace_hidden_files_visible_title: 'Ukryte pliki są widoczne — kliknij, aby przejść do opcji', workspace_options: 'Opcje obszaru roboczego', @@ -22360,6 +22716,15 @@ const LOCALES = { settings_check_now: 'Sprawdź teraz', settings_checking: 'Sprawdzanie…', settings_up_to_date: 'Aktualny ✓', + update_later: 'Later', + update_now: 'Update Now', + update_updating: 'Updating…', + update_force: 'Force update', + update_clear_lock_retry: 'Clear lock and retry update', + update_hard_refresh_now: 'Hard refresh now', + update_no_target: 'No update target selected. Refresh update status and retry.', + update_failed_network: 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.', + update_failed_prefix: 'Update failed: ', settings_updates_available: 'Dostępne aktualizacje ({count})', settings_updates_disabled: 'Sprawdzanie aktualizacji wyłączone', settings_update_check_failed: 'Sprawdzanie aktualizacji nie powiodło się', @@ -23055,6 +23420,10 @@ const LOCALES = { onboarding_current_provider: 'Bieżąca konfiguracja:', onboarding_missing_imports: 'Brakujące importy:', onboarding_notice_setup_required: 'Wybierz tutaj prostą ścieżkę dostawcy. Zaawansowane przepływy OAuth na razie pozostają w CLI Hermes.', + onboarding_notice_provider_choice_required: 'Hermes jest zainstalowany, ale nadal trzeba wybrać dostawcę i zapisać działające dane uwierzytelniające.', + onboarding_notice_custom_base_url_required: 'Hermes ma już zapisany wybór dostawcy/modelu, ale niestandardowy dostawca nadal wymaga bazowego URL-a. Dodaj też klucz API, jeśli ten serwer go wymaga.', + onboarding_notice_provider_auth_required: 'Dostawca „{0}” jest skonfigurowany, ale nie został jeszcze uwierzytelniony. Uruchom w terminalu "hermes auth" albo "hermes model", aby dokończyć konfigurację, a potem odśwież Web UI.', + onboarding_notice_provider_api_key_required: 'Hermes ma już zapisany wybór dostawcy/modelu, ale do czatu nadal potrzebny jest klucz API.', onboarding_notice_setup_already_ready: 'Działająca konfiguracja dostawcy Hermes została już wykryta. Możesz ją zachować lub zastąpić tutaj.', onboarding_oauth_provider_ready_title: 'Dostawca został już uwierzytelniony', onboarding_oauth_provider_ready_body: 'Ta instancja jest skonfigurowana do korzystania z dostawcy OAuth ({provider}), który został skonfigurowany za pomocą CLI Hermes. Klucz API nie jest tutaj wymagany — kliknij Dalej, aby ukończyć konfigurację.', @@ -23861,6 +24230,16 @@ const LOCALES = { workspace_empty_dir: 'Không gian làm việc này đang trống.', workspace_show_hidden_files: 'Hiện file ẩn', workspace_show_hidden_files_desc: 'Bao gồm .DS_Store, .git, node_modules và các file hệ thống / ẩn khác trong cây thư mục.', + workspace_panel_show: 'Show workspace panel', + workspace_panel_hide: 'Hide workspace panel', + workspace_panel_close: 'Close workspace panel', + workspace_panel_toggle: 'Toggle workspace files panel', + workspace_panel_views: 'Workspace panel views', + workspace_parent_directory: 'Parent directory', + workspace_upload_file: 'Upload file', + workspace_close_preview: 'Close preview', + workspace_files_tab: 'Files', + workspace_artifacts_tab: 'Artifacts', workspace_hidden_files_visible: 'đang hiện file ẩn', workspace_hidden_files_visible_title: 'File ẩn đang hiển thị — bấm để xem tùy chọn', workspace_options: 'Tùy chọn không gian làm việc', @@ -24606,6 +24985,10 @@ const LOCALES = { onboarding_current_provider: 'Thiết lập hiện tại:', onboarding_missing_imports: 'Thiếu các import sau:', onboarding_notice_setup_required: 'Hãy chọn cách thiết lập provider đơn giản tại đây. Các luồng OAuth nâng cao hiện vẫn nên thực hiện trong Hermes CLI.', + onboarding_notice_provider_choice_required: 'Hermes đã được cài đặt, nhưng bạn vẫn cần chọn provider và lưu thông tin xác thực hoạt động được.', + onboarding_notice_custom_base_url_required: 'Hermes đã lưu lựa chọn provider/model, nhưng provider tùy chỉnh vẫn cần Base URL. Hãy thêm cả API key nếu máy chủ đó yêu cầu.', + onboarding_notice_provider_auth_required: 'Provider "{0}" đã được cấu hình nhưng chưa xác thực. Chạy "hermes auth" hoặc "hermes model" trong terminal để hoàn tất thiết lập, rồi tải lại Web UI.', + onboarding_notice_provider_api_key_required: 'Hermes đã lưu lựa chọn provider/model, nhưng vẫn cần API key để chat.', onboarding_notice_setup_already_ready: 'Đã phát hiện cấu hình provider Hermes đang hoạt động. Bạn có thể giữ nguyên hoặc thay thế tại đây.', onboarding_oauth_provider_ready_title: 'Provider đã xác thực', onboarding_oauth_provider_ready_body: 'Instance này được cấu hình dùng provider OAuth ({provider}) đã được thiết lập qua Hermes CLI. Không cần API key tại đây — nhấn Tiếp tục để hoàn tất thiết lập.', @@ -25068,6 +25451,15 @@ const LOCALES = { settings_check_now: 'Kiểm tra ngay', settings_checking: 'Đang kiểm tra…', settings_up_to_date: 'Đã cập nhật ✓', + update_later: 'Later', + update_now: 'Update Now', + update_updating: 'Updating…', + update_force: 'Force update', + update_clear_lock_retry: 'Clear lock and retry update', + update_hard_refresh_now: 'Hard refresh now', + update_no_target: 'No update target selected. Refresh update status and retry.', + update_failed_network: 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.', + update_failed_prefix: 'Update failed: ', settings_updates_available: '{count} bản cập nhật khả dụng', settings_updates_disabled: 'Đã tắt kiểm tra cập nhật', settings_update_check_failed: 'Kiểm tra cập nhật thất bại', @@ -25178,6 +25570,38 @@ const _I18N_TOOL_SUMMARY_TEXT_EN = { delegate: { running: ['Delegating a task', 'Delegating {n} tasks'], done: ['Delegated a task', 'Delegated {n} tasks'] }, unknown: { running: ['Running a tool', 'Running {n} tools'], done: ['Ran a tool', 'Ran {n} tools'] }, }; +function _i18nRuPlural(n, one, few, many) { + const value = Math.abs(Number(n) || 0); + const mod10 = value % 10; + const mod100 = value % 100; + if (mod10 === 1 && mod100 !== 11) return one; + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few; + return many; +} +const _I18N_TOOL_ACTION_TEXT_RU = { + shell: { running: 'Выполняю', done: 'Выполнена', fail: 'выполнить', fallback: 'команду' }, + read: { running: 'Читаю', done: 'Прочитан', fail: 'прочитать', fallback: 'файл' }, + list: { running: 'Показываю список', done: 'Показан список', fail: 'показать список', fallback: 'файлов' }, + search: { running: 'Ищу', done: 'Поиск выполнен', fail: 'найти', fallback: 'в рабочей области' }, + web: { running: 'Проверяю', done: 'Проверено', fail: 'проверить', fallback: 'веб-данные' }, + write: { running: 'Обновляю', done: 'Обновлён', fail: 'обновить', fallback: 'файл' }, + skill: { running: 'Загружаю', done: 'Загружен', fail: 'загрузить', fallback: 'навык' }, + memory: { running: 'Сохраняю', done: 'Сохранена', fail: 'сохранить', fallback: 'память' }, + delegate: { running: 'Делегирую', done: 'Делегировано', fail: 'делегировать', fallback: 'задачу' }, + unknown: { running: 'Выполняю', done: 'Выполнен', fail: 'выполнить', fallback: 'инструмент' }, +}; +const _I18N_TOOL_SUMMARY_TEXT_RU = { + shell: { running: ['Выполняется команда', 'Выполняются {n} команды', 'Выполняется {n} команд'], done: ['Выполнена команда', 'Выполнены {n} команды', 'Выполнено {n} команд'] }, + read: { running: ['Читается файл', 'Читаются {n} файла', 'Читается {n} файлов'], done: ['Прочитан файл', 'Прочитаны {n} файла', 'Прочитано {n} файлов'] }, + list: { running: ['Показывается список файлов', 'Показываются {n} списка', 'Показывается {n} списков'], done: ['Показан список файлов', 'Показаны {n} списка', 'Показано {n} списков'] }, + search: { running: ['Идёт поиск в рабочей области', 'Идут {n} поиска в рабочей области', 'Идёт {n} поисков в рабочей области'], done: ['Поиск в рабочей области выполнен', 'Выполнены {n} поиска в рабочей области', 'Выполнено {n} поисков в рабочей области'] }, + web: { running: ['Выполняется проверка веба', 'Выполняются {n} проверки веба', 'Выполняется {n} проверок веба'], done: ['Проверка веба выполнена', 'Выполнены {n} проверки веба', 'Выполнено {n} проверок веба'] }, + write: { running: ['Обновляется файл', 'Обновляются {n} файла', 'Обновляется {n} файлов'], done: ['Обновлён файл', 'Обновлены {n} файла', 'Обновлено {n} файлов'] }, + skill: { running: ['Загружается навык', 'Загружаются {n} навыка', 'Загружается {n} навыков'], done: ['Загружен навык', 'Загружены {n} навыка', 'Загружено {n} навыков'] }, + memory: { running: ['Сохраняется память', 'Сохраняются {n} обновления памяти', 'Сохраняется {n} обновлений памяти'], done: ['Память сохранена', 'Сохранены {n} обновления памяти', 'Сохранено {n} обновлений памяти'] }, + delegate: { running: ['Делегируется задача', 'Делегируются {n} задачи', 'Делегируется {n} задач'], done: ['Задача делегирована', 'Делегированы {n} задачи', 'Делегировано {n} задач'] }, + unknown: { running: ['Выполняется инструмент', 'Выполняются {n} инструмента', 'Выполняется {n} инструментов'], done: ['Инструмент выполнен', 'Выполнены {n} инструмента', 'Выполнено {n} инструментов'] }, +}; const _I18N_TOOL_ACTION_TEXT_ZH = { shell: { running: '正在运行', done: '已运行', fail: '运行', fallback: '命令' }, read: { running: '正在读取', done: '已读取', fail: '读取', fallback: '文件' }, @@ -25232,6 +25656,9 @@ function _i18nProcessedElapsed(prefix, duration) { function _i18nProcessedElapsedEn(duration) { return _i18nProcessedElapsed('Processed', duration); } +function _i18nProcessedElapsedRu(duration) { + return _i18nProcessedElapsed('Обработано', duration); +} function _i18nProcessedElapsedZh(duration) { return _i18nProcessedElapsed('已处理', duration); } @@ -25247,6 +25674,12 @@ function _i18nToolActionLabelFromMap(map, kind, state, target, display, failed) function _i18nToolActionLabelEn(kind, state, target, display, failed) { return _i18nToolActionLabelFromMap(_I18N_TOOL_ACTION_TEXT_EN, kind, state, target, display, failed); } +function _i18nToolActionLabelRu(kind, state, target, display, failed) { + const verbs = _I18N_TOOL_ACTION_TEXT_RU[kind] || _I18N_TOOL_ACTION_TEXT_RU.unknown; + const object = target || verbs.fallback || display || 'инструмент'; + if (failed) return `Не удалось ${verbs.fail || 'выполнить'} ${object}`; + return `${verbs[state] || verbs.running} ${object}`; +} function _i18nToolActionLabelZh(kind, state, target, display, failed) { if (failed) { const verbs = _I18N_TOOL_ACTION_TEXT_ZH[kind] || _I18N_TOOL_ACTION_TEXT_ZH.unknown; @@ -25269,6 +25702,13 @@ function _i18nToolWorklogSummaryFromMap(map, kind, state, count) { function _i18nToolWorklogSummaryEn(kind, state, count) { return _i18nToolWorklogSummaryFromMap(_I18N_TOOL_SUMMARY_TEXT_EN, kind, state, count); } +function _i18nToolWorklogSummaryRu(kind, state, count) { + const n = Math.max(1, Number(count) || 1); + const form = (_I18N_TOOL_SUMMARY_TEXT_RU[kind] || _I18N_TOOL_SUMMARY_TEXT_RU.unknown)[state] + || _I18N_TOOL_SUMMARY_TEXT_RU.unknown.running; + const picked = _i18nRuPlural(n, form[0], form[1], form[2]); + return picked.replace('{n}', String(n)); +} function _i18nToolWorklogSummaryZh(kind, state, count) { return _i18nToolWorklogSummaryFromMap(_I18N_TOOL_SUMMARY_TEXT_ZH, kind, state, count); } @@ -25278,6 +25718,9 @@ function _i18nToolWorklogSummaryZhHant(kind, state, count) { function _i18nToolSummaryJoinEn(parts) { return Array.isArray(parts) ? parts.filter(Boolean).join(', ') : ''; } +function _i18nToolSummaryJoinRu(parts) { + return Array.isArray(parts) ? parts.filter(Boolean).join(', ') : ''; +} function _i18nToolSummaryJoinCjk(parts) { const items = Array.isArray(parts) ? parts.filter(Boolean) : []; if (items.length <= 1) return items[0] || ''; @@ -25541,6 +25984,7 @@ function applyLocaleToDOM() { const val = t(key); if (val && val !== key) el.setAttribute('aria-label', val); }); + if (typeof syncWorkspacePanelUI === 'function') syncWorkspacePanelUI(); if (typeof syncAppTitlebar === 'function') syncAppTitlebar(); } diff --git a/static/index.html b/static/index.html index 02f0d181..c27b9f0b 100644 --- a/static/index.html +++ b/static/index.html @@ -412,10 +412,10 @@
- - - - + + + +
@@ -424,7 +424,7 @@
- +
@@ -586,7 +586,7 @@ Drop files to attach
- + -
- - +
+ +
diff --git a/static/onboarding.js b/static/onboarding.js index d75e9d48..9a43e252 100644 --- a/static/onboarding.js +++ b/static/onboarding.js @@ -235,6 +235,16 @@ function _providerStatusLabel(system){ return t('onboarding_check_provider_pending'); } +function _localizedOnboardingProviderNote(system){ + const key=system&&system.provider_note_key; + if(key){ + const args=Array.isArray(system&&system.provider_note_args)?system.provider_note_args:[]; + const localized=t(key,...args); + if(localized&&localized!==key&&!/\{\d+\}/.test(localized))return localized; + } + return (system&&system.provider_note)||''; +} + function _renderOnboardingBody(){ const body=$('onboardingBody'); if(!body||!ONBOARDING.status)return; @@ -250,7 +260,8 @@ function _renderOnboardingBody(){ if(key==='system'){ const hermesOk=system.hermes_found&&system.imports_ok; const setupOk=!!system.chat_ready; - _setOnboardingNotice(system.provider_note|| (setupOk?t('onboarding_notice_system_ready'):t('onboarding_notice_system_unavailable')),setupOk?'success':(hermesOk?'info':'warn')); + const providerNote=_localizedOnboardingProviderNote(system); + _setOnboardingNotice(providerNote|| (setupOk?t('onboarding_notice_system_ready'):t('onboarding_notice_system_unavailable')),setupOk?'success':(hermesOk?'info':'warn')); body.innerHTML=`
${t('onboarding_check_agent')}${hermesOk?t('onboarding_check_agent_ready'):t('onboarding_check_agent_missing')}
@@ -260,7 +271,7 @@ function _renderOnboardingBody(){

${t('onboarding_config_file')} ${esc(system.config_path||t('onboarding_unknown'))}

${t('onboarding_env_file')} ${esc(system.env_path||t('onboarding_unknown'))}

-

${esc(system.provider_note||'')}

+ ${providerNote?`

${esc(providerNote)}

`:''} ${system.current_provider?`

${t('onboarding_current_provider')} ${esc(system.current_provider)}${system.current_model?` — ${esc(system.current_model)}`:''}

`:''} ${system.current_base_url?`

${t('onboarding_base_url_label')} ${esc(system.current_base_url)}

`:''} ${system.missing_modules&&system.missing_modules.length?`

${t('onboarding_missing_imports')} ${esc(system.missing_modules.join(', '))}

`:''} diff --git a/static/panels.js b/static/panels.js index 3bfa0fd0..23e0d39f 100644 --- a/static/panels.js +++ b/static/panels.js @@ -2658,7 +2658,7 @@ function _kanbanLooksLikeStaleClientError(err){ function _kanbanUnavailableHtml(err){ const raw = String((err && err.message) || err || ''); if (_kanbanLooksLikeStaleClientError(err)) { - return `
Kanban needs a hard refresh
The server rejected an obsolete Kanban endpoint. This usually means the browser or Mac app is still running a stale cached WebUI bundle after an update.
Original error: ${esc(raw || 'not found')}
`; + return `
Kanban needs a hard refresh
The server rejected an obsolete Kanban endpoint. This usually means the browser or Mac app is still running a stale cached WebUI bundle after an update.
Original error: ${esc(raw || 'not found')}
`; } const msg = `${esc(t('kanban_unavailable'))}: ${esc(raw)}`; return `
${msg}
`; @@ -6920,7 +6920,7 @@ async function switchToProfile(name) { // already on this profile, so paths like activateCurrentProfile() (which // doesn't pre-check) can't flash a skeleton→restore for a click that changes // nothing. (#4662 Opus gate) - if (name && name === S.activeProfile) return; + if (name && name === S.activeProfile) return true; S._pendingSessionToolsets=null; // Profile switches are per-client cookie/TLS scoped, so a running stream in // the current session can safely continue while this tab moves to another @@ -7001,7 +7001,7 @@ async function switchToProfile(name) { // the single source of truth for switch failure and is gated on _switchGen, so the // error surfaces ONLY when the CURRENT switch genuinely fails (@rodboev review, #4662). const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }), timeoutToast: false }); - if (_switchGen !== _profileSwitchGeneration) return; + if (_switchGen !== _profileSwitchGeneration) return false; S.activeProfile = data.active || name; S.activeProfileIsDefault = !!data.is_default; const targetActiveProfile = S.activeProfile || 'default'; @@ -7017,13 +7017,6 @@ async function switchToProfile(name) { sessionInProgress = true; } } - // #4650 review: a profile switch can change agent.reasoning_effort (and other - // reasoning inputs like base_url) WITHOUT changing the default model/provider, - // which is all the reasoning-chip cache key tracks. Force exactly one reasoning - // refetch for the new profile so the chip reflects the new profile's effort - // (the syncTopbar() calls below route through syncReasoningChip()). - if (typeof _lastReasoningFetchKey !== 'undefined') _lastReasoningFetchKey = null; - // Reconnect the gateway SSE to the NEW profile's watcher. The backend watcher // registry is now profile-keyed (#3629), but this tab's existing EventSource is // still subscribed to the PREVIOUS profile's watcher — and the probe-based @@ -7087,6 +7080,9 @@ async function switchToProfile(name) { if (S.session && !sessionInProgress) { S.session.profile = data.active || name; } + if (typeof refreshProfileTransitionReasoningChip === 'function') { + refreshProfileTransitionReasoningChip(data.default_model, data.default_model_provider); + } // ── Apply workspace ──────────────────────────────────────────────────── if (data.default_workspace) { @@ -7122,7 +7118,7 @@ async function switchToProfile(name) { const workspaceVisible = typeof _workspacePanelMode !== 'undefined' && _workspacePanelMode !== 'closed'; if (typeof _setProfileSwitchListEmbargo === 'function') _setProfileSwitchListEmbargo(false); await renderSessionList(); - if (_switchGen !== _profileSwitchGeneration) return; + if (_switchGen !== _profileSwitchGeneration) return false; if (workspaceVisible && typeof clearWorkspaceTreeSkeleton === 'function') clearWorkspaceTreeSkeleton(); showToast(t('profile_switched', name)); } else if (sessionInProgress) { @@ -7130,7 +7126,7 @@ async function switchToProfile(name) { // Start a new session for the new profile so nothing gets cross-tagged. const workspaceVisible = typeof _workspacePanelMode !== 'undefined' && _workspacePanelMode !== 'closed'; await newSession(false, {awaitWorkspaceLoad: workspaceVisible}); - if (_switchGen !== _profileSwitchGeneration) return; + if (_switchGen !== _profileSwitchGeneration) return false; // Keep topbar chips (workspace/profile) in sync after creating the // new profile-scoped session. syncTopbar(); @@ -7144,7 +7140,7 @@ async function switchToProfile(name) { // the superseded switch would clear the newer switch's workspace skeleton // and pop a stale toast. Mirrors the no-messages branch guard below. // (@rodboev/greptile review, #4662) - if (_switchGen !== _profileSwitchGeneration) return; + if (_switchGen !== _profileSwitchGeneration) return false; if (typeof _openProfileSwitchSessionBrowser === 'function') _openProfileSwitchSessionBrowser(); // Safety net: if the new session has no workspace, newSession() won't have // painted the file tree — clear the up-front skeleton so it can't strand @@ -7185,6 +7181,7 @@ async function switchToProfile(name) { await _profileSwitchPanelLoad(); _refreshProfileSwitchBackground(_switchGen); + return true; } catch (e) { // Revert the optimistic name update on error @@ -7211,6 +7208,7 @@ async function switchToProfile(name) { clearWorkspaceTreeSkeleton(); } } + return false; } finally { // Always remove loading indicator regardless of success or failure if (_switchGen === _profileSwitchGeneration && _chip) { _chip.classList.remove('switching'); _chip.disabled = false; } diff --git a/static/sessions.js b/static/sessions.js index 36c82fb6..ddaade19 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1441,6 +1441,9 @@ async function _switchProfileForSessionLoad(profile){ else localStorage.removeItem('hermes-webui-model'); if(data.default_model) window._defaultModel=data.default_model; if(data.default_model_provider) window._activeProvider=data.default_model_provider; + if(typeof refreshProfileTransitionReasoningChip==='function'){ + refreshProfileTransitionReasoningChip(data.default_model,data.default_model_provider); + } if(typeof startGatewaySSE==='function') startGatewaySSE(); if(typeof syncTopbar==='function') syncTopbar(); if(typeof _setProfileSwitchListEmbargo==='function') _setProfileSwitchListEmbargo(false); @@ -3799,6 +3802,32 @@ function _composerPrefillIntentFromLocation(){ }; }catch(_e){return empty;} } +function _profileQueryIntentFromLocation(){ + const empty={hasParam:false,valid:false,name:''}; + if(typeof window==='undefined'||!window.location) return empty; + try{ + const qs=new URLSearchParams(window.location.search||''); + if(!qs.has('profile')) return empty; + const name=String(qs.get('profile')||''); + return { + hasParam:true, + valid:/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name), + name + }; + }catch(_e){return empty;} +} +function _consumeProfileQueryParamFromLocation(){ + if(typeof window==='undefined'||!window.location||!window.history||typeof window.history.replaceState!=='function') return; + try{ + const current=new URL(window.location.href); + const before=current.searchParams.toString(); + current.searchParams.delete('profile'); + const after=current.searchParams.toString(); + if(after===before) return; + const next=current.pathname+(after?`?${after}`:'')+(current.hash||''); + window.history.replaceState(window.history.state||null,'',next); + }catch(_e){} +} function _consumeComposerPrefillParamsFromLocation(){ if(typeof window==='undefined'||!window.location||!window.history||typeof window.history.replaceState!=='function') return; try{ diff --git a/static/ui.js b/static/ui.js index c1d007c2..ccf2bc9b 100644 --- a/static/ui.js +++ b/static/ui.js @@ -4468,6 +4468,7 @@ if(document.readyState==='loading'){ // ── Reasoning effort chip ──────────────────────────────────────────────────── let _currentReasoningEffort=null; let _currentReasoningEffortsSupported=null; +let _profileTransitionReasoningContext=null; function _normalizeReasoningEffort(eff){ return String(eff||'').trim().toLowerCase(); @@ -4486,6 +4487,14 @@ function _formatReasoningEffortLabel(effort){ } function _reasoningEffortContext(){ + const transition=_profileTransitionReasoningContext; + const session=S&&S.session; + if(transition&&(!session||session.profile!==transition.profile)){ + const ctx={}; + if(transition.model) ctx.model=transition.model; + if(transition.provider) ctx.provider=transition.provider; + return ctx; + } const sel=$('modelSelect'); const model=(S&&S.session&&S.session.model)||(sel&&sel.value)||''; let provider=(S&&S.session&&S.session.model_provider)||''; @@ -4575,11 +4584,11 @@ let _lastReasoningFetchKey=null; // a different agent.reasoning_effort) — #4650 review. let _reasoningFetchSeq=0; -function fetchReasoningChip(){ +function fetchReasoningChip(keyOverride){ // Set the cache key OPTIMISTICALLY before the request so rapid routine syncs // while this GET is in flight short-circuit instead of re-dispatching (that // in-flight window is exactly where the #4650 storm lived). - const key=_reasoningEffortQuery(); + const key=keyOverride===undefined?_reasoningEffortQuery():keyOverride; const seq=++_reasoningFetchSeq; _lastReasoningFetchKey=key; api('/api/reasoning'+key).then(function(st){ @@ -4597,6 +4606,23 @@ function fetchReasoningChip(){ }); } +function refreshProfileTransitionReasoningChip(model, provider){ + _profileTransitionReasoningContext={profile:(S&&S.activeProfile)||'default',model,provider}; + _currentReasoningEffort=null; + _currentReasoningEffortsSupported=null; + _lastReasoningFetchKey=null; + ++_reasoningFetchSeq; + _applyReasoningChip('', {supported_efforts:[]}); + const params=new URLSearchParams(); + if(model) params.set('model',model); + if(provider) params.set('provider',provider); + fetchReasoningChip(params.size?'?'+params.toString():undefined); +} + +function clearProfileTransitionReasoningContext(){ + _profileTransitionReasoningContext=null; +} + function syncReasoningChip(){ // #4650: syncTopbar() calls this on every routine UI refresh, and during // streaming those fire at high frequency. Before a9ce2889 this served the @@ -9440,6 +9466,13 @@ function _showUpdateBanner(data){ const summaryMode=window._whatsNewSummaryEnabled===true?'summary':'diff'; _renderUpdateWhatsNewLinks(data,{mode:summaryMode}); } +function _i18nUpdateText(key, fallback){ + if(typeof t==='function'){ + const val=t(key); + if(val&&val!==key) return val; + } + return fallback; +} function dismissUpdate(){ const b=$('updateBanner');if(b)b.classList.remove('visible'); sessionStorage.setItem('hermes-update-dismissed','1'); @@ -9451,24 +9484,25 @@ function _isUpdateApplyNetworkError(error){ } function _formatUpdateApplyExceptionMessage(error){ if(_isUpdateApplyNetworkError(error)){ - return 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.'; + return _i18nUpdateText('update_failed_network','Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.'); } const message=(error&&error.message)||String(error||'unknown error'); - return 'Update failed: '+message; + return _i18nUpdateText('update_failed_prefix','Update failed: ')+message; } async function applyUpdates(){ if(window._updateApplyInFlight) return; window._updateApplyInFlight=true; + const updateText=(key,fallback)=>(typeof _i18nUpdateText==='function'?_i18nUpdateText(key,fallback):fallback); const btn=$('btnApplyUpdate'); const resetApplyButton=(delayMs)=>{ const reset=()=>{ window._updateApplyInFlight=false; - if(btn){btn.disabled=false;btn.textContent='Update Now';} + if(btn){btn.disabled=false;btn.textContent=updateText('update_now','Update Now');} }; if(delayMs>0) setTimeout(reset,delayMs); else reset(); }; - if(btn){btn.disabled=true;btn.textContent='Updating\u2026';} + if(btn){btn.disabled=true;btn.textContent=updateText('update_updating','Updating\u2026');} const errEl=$('updateError'); if(errEl){errEl.style.display='none';errEl.textContent='';} // Hide any leftover force-update button from a prior conflict so a fresh @@ -9479,7 +9513,7 @@ async function applyUpdates(){ if(window._updateData?.agent?.behind>0) targets.push('agent'); if(window._updateData?.webui?.behind>0) targets.push('webui'); if(!targets.length){ - const msg='No update target selected. Refresh update status and retry.'; + const msg=updateText('update_no_target','No update target selected. Refresh update status and retry.'); if(errEl){errEl.textContent=msg;errEl.style.display='block';} else showToast(msg,5000,'error'); resetApplyButton(0); diff --git a/tests/test_5682_profile_query_switch.py b/tests/test_5682_profile_query_switch.py new file mode 100644 index 00000000..d6fdddcb --- /dev/null +++ b/tests/test_5682_profile_query_switch.py @@ -0,0 +1,832 @@ +"""Regression tests for #5682 profile query boot switching.""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent.resolve() +SESSIONS_JS_PATH = REPO_ROOT / "static" / "sessions.js" +BOOT_JS_PATH = REPO_ROOT / "static" / "boot.js" +PANELS_JS_PATH = REPO_ROOT / "static" / "panels.js" +UI_JS_PATH = REPO_ROOT / "static" / "ui.js" +SESSIONS_JS = SESSIONS_JS_PATH.read_text(encoding="utf-8") +BOOT_JS = BOOT_JS_PATH.read_text(encoding="utf-8") +PANELS_JS = PANELS_JS_PATH.read_text(encoding="utf-8") +UI_JS = UI_JS_PATH.read_text(encoding="utf-8") +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +def _run_node(source: str) -> str: + result = subprocess.run( + [NODE], + input=source, + cwd=str(REPO_ROOT), + capture_output=True, + encoding="utf-8", + text=True, + timeout=30, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr) + return result.stdout.strip() + + +def _node_prelude() -> str: + return f""" +const sessionsSrc = {SESSIONS_JS!r}; +const bootSrc = {BOOT_JS!r}; +function extractFunc(src, name) {{ + const re = new RegExp('(?:async\\\\s+)?function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +function evalSession(name) {{ + globalThis[name] = (0, eval)('(' + extractFunc(sessionsSrc, name) + ')'); +}} +function evalBoot(name) {{ + globalThis[name] = (0, eval)('(' + extractFunc(bootSrc, name) + ')'); +}} +""" + + +def test_valid_profile_query_switches_before_restore_and_cleans_url(): + source = _node_prelude() + """ +function applyUrl(rel) { + const next = new URL(rel, 'https://example.test'); + window.location.href = next.href; + window.location.pathname = next.pathname; + window.location.search = next.search; + window.location.hash = next.hash; +} +global.window = { + location: {}, + history: { + state: { from: 'test' }, + calls: [], + replaceState(state, title, url) { + this.calls.push({ state, title, url }); + this.state = state; + applyUrl(url); + } + } +}; +global.document = { baseURI: 'https://example.test/app/' }; +console.warn = (...args) => { throw new Error('unexpected warn: ' + args.join(' ')); }; +applyUrl('/app/?profile=vops&q=hello&prompt=hi&send=1&keep=1#frag'); +global.localStorage = { + store: { 'hermes-webui-session': 'saved-local' }, + getItem(key) { + return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null; + }, + setItem(key, value) { + this.store[key] = String(value); + }, + removeItem(key) { + delete this.store[key]; + } +}; +evalSession('_profileQueryIntentFromLocation'); +evalSession('_consumeProfileQueryParamFromLocation'); +evalSession('_consumeComposerPrefillParamsFromLocation'); +evalSession('_sessionUrlForSid'); +evalBoot('_profileQueryBlocksSavedLocalRestore'); +const intent = _profileQueryIntentFromLocation(); +global.S = { activeProfile: 'default', activeProfileIsDefault: true }; +const switched = []; +global.switchToProfile = async (name) => { + switched.push(name); + S.activeProfile = name; + S.activeProfileIsDefault = false; + localStorage.setItem('hermes-webui-session', 'fresh-local'); + return true; +}; +(async () => { + const savedLocalBefore = localStorage.getItem('hermes-webui-session'); + const profileSwitchProfileBefore = S.activeProfile || 'default'; + const profileSwitchIsDefaultBefore = !!S.activeProfileIsDefault; + let profileSwitchCompleted = false; + let profileSwitchChangedProfile = false; + if (intent && intent.hasParam) { + try { + if (intent.valid) { + if (typeof switchToProfile === 'function') { + profileSwitchCompleted = await switchToProfile(intent.name) === true; + if (profileSwitchCompleted) { + profileSwitchChangedProfile = (S.activeProfile || 'default') !== profileSwitchProfileBefore || !!S.activeProfileIsDefault !== profileSwitchIsDefaultBefore; + if (typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation(); + } + } + } else { + console.warn('[boot] ignored invalid profile query', intent.name); + } + } catch (e) { + console.warn('[boot] profile query switch failed', e); + } + } + const blocksSavedLocal = _profileQueryBlocksSavedLocalRestore(intent, null); + if (blocksSavedLocal && profileSwitchCompleted && profileSwitchChangedProfile && localStorage.getItem('hermes-webui-session') === savedLocalBefore) localStorage.removeItem('hermes-webui-session'); + const savedLocalAfterSuppress = localStorage.getItem('hermes-webui-session'); + const savedLocalAfterReload = localStorage.getItem('hermes-webui-session'); + const keepsExplicitSession = _profileQueryBlocksSavedLocalRestore(intent, 'session-123'); + const afterProfile = window.location.pathname + window.location.search + window.location.hash; + const promoted = _sessionUrlForSid('abc 123'); + _consumeComposerPrefillParamsFromLocation(); + const afterPrefill = window.location.pathname + window.location.search + window.location.hash; + const profilePos = bootSrc.indexOf("const profileIntent=(typeof _profileQueryIntentFromLocation==='function')?_profileQueryIntentFromLocation():null;"); + const renderPos = bootSrc.indexOf("await renderSessionList();", profilePos); + const savedPos = bootSrc.indexOf("const saved=urlSession||savedLocal;", profilePos); + const loadPos = bootSrc.indexOf("await loadSession(saved, {preserveActiveInput:true});", profilePos); + const consumePos = bootSrc.indexOf("if(typeof _consumeProfileQueryParamFromLocation==='function') _consumeProfileQueryParamFromLocation();", profilePos); + const completedPos = bootSrc.indexOf("_profileSwitchCompleted=await switchToProfile(profileIntent.name)===true;", profilePos); + const changedPos = bootSrc.indexOf("_profileSwitchChangedProfile=", completedPos); + const cleanupGuardPos = bootSrc.indexOf("if(_profileQueryBlocksSavedLocal&&_profileSwitchCompleted&&_profileSwitchChangedProfile){", profilePos); + const initialReasoningFetchPos = bootSrc.indexOf("if(typeof fetchReasoningChip==='function'&&(!_profileSwitchCompleted||!_profileSwitchChangedProfile)) fetchReasoningChip();", profilePos); + console.log(JSON.stringify({ intent, switched, promoted, afterProfile, afterPrefill, historyCalls: window.history.calls, profilePos, renderPos, savedPos, loadPos, consumePos, completedPos, changedPos, cleanupGuardPos, initialReasoningFetchPos, savedLocalBefore, savedLocalAfterSuppress, savedLocalAfterReload, blocksSavedLocal, keepsExplicitSession })); +})().catch(err => { + console.error(err); + process.exit(1); +}); +""" + payload = json.loads(_run_node(source)) + assert payload["intent"] == {"hasParam": True, "valid": True, "name": "vops"} + assert payload["switched"] == ["vops"] + assert payload["promoted"] == "/app/session/abc%20123?keep=1#frag" + assert payload["afterProfile"] == "/app/?q=hello&prompt=hi&send=1&keep=1#frag" + assert payload["afterPrefill"] == "/app/?keep=1#frag" + assert payload["historyCalls"][0]["url"] == "/app/?q=hello&prompt=hi&send=1&keep=1#frag" + assert payload["historyCalls"][1]["url"] == "/app/?keep=1#frag" + assert payload["profilePos"] >= 0 + assert payload["renderPos"] > payload["profilePos"] + assert payload["savedPos"] > payload["profilePos"] + assert payload["loadPos"] > payload["savedPos"] + assert payload["consumePos"] > payload["profilePos"] + assert payload["completedPos"] > payload["profilePos"] + assert payload["changedPos"] > payload["completedPos"] + assert payload["cleanupGuardPos"] > payload["consumePos"] + assert payload["initialReasoningFetchPos"] > payload["completedPos"] + assert payload["savedLocalBefore"] == "saved-local" + assert payload["savedLocalAfterSuppress"] == "fresh-local" + assert payload["savedLocalAfterReload"] == "fresh-local" + assert payload["blocksSavedLocal"] is True + assert payload["keepsExplicitSession"] is False + + +def test_noop_profile_query_switch_keeps_saved_local_state(): + source = _node_prelude() + """ +function applyUrl(rel) { + const next = new URL(rel, 'https://example.test'); + window.location.href = next.href; + window.location.pathname = next.pathname; + window.location.search = next.search; + window.location.hash = next.hash; +} +global.window = { + location: {}, + history: { + state: { from: 'test' }, + calls: [], + replaceState(state, title, url) { + this.calls.push({ state, title, url }); + this.state = state; + applyUrl(url); + } + } +}; +global.document = { baseURI: 'https://example.test/app/' }; +applyUrl('/app/?profile=default&q=hello&keep=1#frag'); +global.S = { activeProfile: 'default', activeProfileIsDefault: true }; +global.localStorage = { + store: { 'hermes-webui-session': 'saved-local' }, + getItem(key) { + return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null; + }, + removeItem(key) { + delete this.store[key]; + } +}; +evalSession('_profileQueryIntentFromLocation'); +evalSession('_consumeProfileQueryParamFromLocation'); +evalSession('_consumeComposerPrefillParamsFromLocation'); +evalSession('_sessionUrlForSid'); +evalBoot('_profileQueryBlocksSavedLocalRestore'); +const intent = _profileQueryIntentFromLocation(); +global.switchToProfile = async () => true; +(async () => { + const savedLocalBefore = localStorage.getItem('hermes-webui-session'); + const profileSwitchProfileBefore = S.activeProfile || 'default'; + const profileSwitchIsDefaultBefore = !!S.activeProfileIsDefault; + let profileSwitchCompleted = false; + let profileSwitchChangedProfile = false; + if (intent && intent.hasParam) { + try { + if (intent.valid) { + if (typeof switchToProfile === 'function') { + profileSwitchCompleted = await switchToProfile(intent.name) === true; + if (profileSwitchCompleted) { + profileSwitchChangedProfile = (S.activeProfile || 'default') !== profileSwitchProfileBefore || !!S.activeProfileIsDefault !== profileSwitchIsDefaultBefore; + if (typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation(); + } + } + } else { + console.warn('[boot] ignored invalid profile query', intent.name); + } + } catch (e) { + console.warn('[boot] profile query switch failed', e); + } + } + const blocksSavedLocal = _profileQueryBlocksSavedLocalRestore(intent, null); + if (blocksSavedLocal && profileSwitchCompleted && profileSwitchChangedProfile && localStorage.getItem('hermes-webui-session') === savedLocalBefore) localStorage.removeItem('hermes-webui-session'); + const cleanupGuardPos = bootSrc.indexOf("if(_profileQueryBlocksSavedLocal&&_profileSwitchCompleted&&_profileSwitchChangedProfile){", bootSrc.indexOf("const profileIntent=(typeof _profileQueryIntentFromLocation==='function')?_profileQueryIntentFromLocation():null;")); + console.log(JSON.stringify({ + intent, + blocksSavedLocal, + profileSwitchCompleted, + profileSwitchChangedProfile, + savedLocalBefore, + savedLocalAfter: localStorage.getItem('hermes-webui-session'), + cleanupGuardPos, + })); +})().catch(err => { + console.error(err); + process.exit(1); +}); +""" + payload = json.loads(_run_node(source)) + assert payload["intent"] == {"hasParam": True, "valid": True, "name": "default"} + assert payload["blocksSavedLocal"] is True + assert payload["profileSwitchCompleted"] is True + assert payload["profileSwitchChangedProfile"] is False + assert payload["savedLocalBefore"] == "saved-local" + assert payload["savedLocalAfter"] == "saved-local" + assert payload["cleanupGuardPos"] >= 0 + + +def test_failed_profile_query_switch_keeps_saved_local_state(): + source = _node_prelude() + """ +function applyUrl(rel) { + const next = new URL(rel, 'https://example.test'); + window.location.href = next.href; + window.location.pathname = next.pathname; + window.location.search = next.search; + window.location.hash = next.hash; +} +global.window = { + location: {}, + history: { + state: null, + calls: [], + replaceState(state, title, url) { + this.calls.push({ state, title, url }); + this.state = state; + applyUrl(url); + } + } +}; +global.document = { baseURI: 'https://example.test/app/' }; +const warns = []; +console.warn = (...args) => { warns.push(args.map(String)); }; +applyUrl('/app/?profile=vops&q=hello&keep=1#frag'); +global.localStorage = { + store: { 'hermes-webui-session': 'saved-local' }, + getItem(key) { + return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null; + }, + removeItem(key) { + delete this.store[key]; + } +}; +evalSession('_profileQueryIntentFromLocation'); +evalSession('_consumeProfileQueryParamFromLocation'); +evalSession('_consumeComposerPrefillParamsFromLocation'); +evalSession('_sessionUrlForSid'); +evalBoot('_profileQueryBlocksSavedLocalRestore'); +const intent = _profileQueryIntentFromLocation(); +global.switchToProfile = async () => { throw new Error('boom'); }; +(async () => { + const savedLocalBefore = localStorage.getItem('hermes-webui-session'); + let profileSwitchCompleted = false; + if (intent && intent.hasParam) { + try { + if (intent.valid) { + if (typeof switchToProfile === 'function') { + profileSwitchCompleted = await switchToProfile(intent.name) === true; + if (profileSwitchCompleted && typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation(); + } + } else { + console.warn('[boot] ignored invalid profile query', intent.name); + if (typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation(); + } + } catch (e) { + console.warn('[boot] profile query switch failed', e); + } + } + const blocksSavedLocal = _profileQueryBlocksSavedLocalRestore(intent, null); + if (blocksSavedLocal && profileSwitchCompleted && localStorage.getItem('hermes-webui-session') === savedLocalBefore) localStorage.removeItem('hermes-webui-session'); + const afterBoot = window.location.pathname + window.location.search + window.location.hash; + _consumeComposerPrefillParamsFromLocation(); + const afterPrefill = window.location.pathname + window.location.search + window.location.hash; + const promoted = _sessionUrlForSid('abc 123'); + console.log(JSON.stringify({ + blocksSavedLocal, + profileSwitchCompleted, + savedLocalAfter: localStorage.getItem('hermes-webui-session'), + afterBoot, + afterPrefill, + promoted, + warns, + })); +})().catch(err => { + console.error(err); + process.exit(1); +}); +""" + payload = json.loads(_run_node(source)) + assert payload["blocksSavedLocal"] is True + assert payload["profileSwitchCompleted"] is False + assert payload["savedLocalAfter"] == "saved-local" + assert payload["afterBoot"] == "/app/?profile=vops&q=hello&keep=1#frag" + assert payload["afterPrefill"] == "/app/?profile=vops&keep=1#frag" + assert payload["promoted"] == "/app/session/abc%20123?profile=vops&keep=1#frag" + assert payload["warns"] == [["[boot] profile query switch failed", "Error: boom"]] + + +def test_unsuccessful_profile_query_result_keeps_retry_url_without_warning(): + source = _node_prelude() + """ +function applyUrl(rel) { + const next = new URL(rel, 'https://example.test'); + window.location.href = next.href; + window.location.pathname = next.pathname; + window.location.search = next.search; + window.location.hash = next.hash; +} +global.window = { + location: {}, + history: { + state: null, + calls: [], + replaceState(state, title, url) { + this.calls.push({ state, title, url }); + this.state = state; + applyUrl(url); + } + } +}; +global.document = { baseURI: 'https://example.test/app/' }; +const warns = []; +console.warn = (...args) => { warns.push(args.map(String)); }; +applyUrl('/app/?profile=vops&q=hello&keep=1#frag'); +global.localStorage = { + store: { 'hermes-webui-session': 'saved-local' }, + getItem(key) { + return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null; + }, + removeItem(key) { + delete this.store[key]; + } +}; +evalSession('_profileQueryIntentFromLocation'); +evalSession('_consumeProfileQueryParamFromLocation'); +evalBoot('_profileQueryBlocksSavedLocalRestore'); +const intent = _profileQueryIntentFromLocation(); +global.switchToProfile = async () => false; +(async () => { + const savedLocalBefore = localStorage.getItem('hermes-webui-session'); + let profileSwitchCompleted = false; + if (intent && intent.hasParam) { + try { + if (intent.valid) { + if (typeof switchToProfile === 'function') { + profileSwitchCompleted = await switchToProfile(intent.name) === true; + if (profileSwitchCompleted && typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation(); + } + } else { + console.warn('[boot] ignored invalid profile query', intent.name); + if (typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation(); + } + } catch (e) { + console.warn('[boot] profile query switch failed', e); + } + } + const blocksSavedLocal = _profileQueryBlocksSavedLocalRestore(intent, null); + if (blocksSavedLocal && profileSwitchCompleted && localStorage.getItem('hermes-webui-session') === savedLocalBefore) localStorage.removeItem('hermes-webui-session'); + console.log(JSON.stringify({ + blocksSavedLocal, + profileSwitchCompleted, + savedLocalAfter: localStorage.getItem('hermes-webui-session'), + url: window.location.pathname + window.location.search + window.location.hash, + historyCalls: window.history.calls, + warns, + })); +})().catch(err => { + console.error(err); + process.exit(1); +}); +""" + payload = json.loads(_run_node(source)) + assert payload["blocksSavedLocal"] is True + assert payload["profileSwitchCompleted"] is False + assert payload["savedLocalAfter"] == "saved-local" + assert payload["url"] == "/app/?profile=vops&q=hello&keep=1#frag" + assert payload["historyCalls"] == [] + assert payload["warns"] == [] + + +def test_invalid_profile_query_warns_and_skips_switch(): + source = _node_prelude() + """ +function applyUrl(rel) { + const next = new URL(rel, 'https://example.test'); + window.location.href = next.href; + window.location.pathname = next.pathname; + window.location.search = next.search; + window.location.hash = next.hash; +} +global.window = { + location: {}, + history: { + state: null, + calls: [], + replaceState(state, title, url) { + this.calls.push({ state, title, url }); + this.state = state; + applyUrl(url); + } + } +}; +global.document = { baseURI: 'https://example.test/app/' }; +const warns = []; +console.warn = (...args) => { warns.push(args); }; +applyUrl('/app/?profile=../bad&q=hello&keep=1#frag'); +evalSession('_profileQueryIntentFromLocation'); +evalSession('_consumeProfileQueryParamFromLocation'); +const intent = _profileQueryIntentFromLocation(); +const switched = []; +global.switchToProfile = async (name) => { switched.push(name); }; +(async () => { + if (intent && intent.hasParam) { + try { + if (intent.valid) { + if (typeof switchToProfile === 'function') await switchToProfile(intent.name); + } else { + console.warn('[boot] ignored invalid profile query', intent.name); + if (typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation(); + } + } catch (e) { + console.warn('[boot] profile query switch failed', e); + } + } + console.log(JSON.stringify({ + intent, + switched, + warns, + url: window.location.pathname + window.location.search + window.location.hash, + historyCalls: window.history.calls, + })); +})().catch(err => { + console.error(err); + process.exit(1); +}); +""" + payload = json.loads(_run_node(source)) + assert payload["intent"] == {"hasParam": True, "valid": False, "name": "../bad"} + assert payload["switched"] == [] + assert payload["warns"] == [["[boot] ignored invalid profile query", "../bad"]] + assert payload["url"] == "/app/?q=hello&keep=1#frag" + assert payload["historyCalls"] == [{"state": None, "title": "", "url": "/app/?q=hello&keep=1#frag"}] + + +def test_prefill_cleanup_still_strips_q_prompt_and_send(): + source = _node_prelude() + """ +function applyUrl(rel) { + const next = new URL(rel, 'https://example.test'); + window.location.href = next.href; + window.location.pathname = next.pathname; + window.location.search = next.search; + window.location.hash = next.hash; +} +global.window = { + location: {}, + history: { + state: null, + calls: [], + replaceState(state, title, url) { + this.calls.push({ state, title, url }); + this.state = state; + applyUrl(url); + } + } +}; +global.document = { baseURI: 'https://example.test/app/' }; +applyUrl('/app/?q=hello&prompt=hi&send=1&keep=1#frag'); +evalSession('_consumeComposerPrefillParamsFromLocation'); +_consumeComposerPrefillParamsFromLocation(); +console.log(JSON.stringify({ + url: window.location.pathname + window.location.search + window.location.hash, + historyCalls: window.history.calls, +})); +""" + payload = json.loads(_run_node(source)) + assert payload["url"] == "/app/?keep=1#frag" + assert payload["historyCalls"] == [{"state": None, "title": "", "url": "/app/?keep=1#frag"}] + + +def test_profile_query_blocks_only_implicit_saved_local_restore(): + source = _node_prelude() + """ +evalBoot('_profileQueryBlocksSavedLocalRestore'); +global.localStorage = { + store: { 'hermes-webui-session': 'saved-local' }, + getItem(key) { + return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null; + }, + setItem(key, value) { + this.store[key] = String(value); + }, + removeItem(key) { + delete this.store[key]; + } +}; +const validProfile = { hasParam: true, valid: true }; +const invalidProfile = { hasParam: true, valid: false }; +const blocksImplicit = _profileQueryBlocksSavedLocalRestore(validProfile, null); +if (blocksImplicit) localStorage.removeItem('hermes-webui-session'); +const implicitAfter = localStorage.getItem('hermes-webui-session'); +localStorage.setItem('hermes-webui-session', 'saved-local'); +const allowsExplicit = _profileQueryBlocksSavedLocalRestore(validProfile, 'session-123'); +if (allowsExplicit) localStorage.removeItem('hermes-webui-session'); +const explicitAfter = localStorage.getItem('hermes-webui-session'); +console.log(JSON.stringify({ + blocksImplicit, + allowsExplicit, + ignoresInvalid: _profileQueryBlocksSavedLocalRestore(invalidProfile, null), + implicitAfter, + explicitAfter, +})); +""" + payload = json.loads(_run_node(source)) + assert payload == { + "blocksImplicit": True, + "allowsExplicit": False, + "ignoresInvalid": False, + "implicitAfter": None, + "explicitAfter": "saved-local", + } + + +def test_profile_transition_reasoning_refresh_hides_stale_chip_until_destination_resolves(): + source = f""" +const uiSrc = {UI_JS!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = uiSrc.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = uiSrc.indexOf('{{', start), depth = 1; i++; + while (depth > 0 && i < uiSrc.length) {{ + if (uiSrc[i] === '{{') depth++; + else if (uiSrc[i] === '}}') depth--; + i++; + }} + return uiSrc.slice(start, i); +}} +function makeEl() {{ + return {{ + style: {{}}, classList: {{ toggle(){{}}, add(){{}}, remove(){{}} }}, + setAttribute(){{}}, querySelectorAll(){{ return []; }}, + }}; +}} +const els = {{ + composerReasoningWrap: makeEl(), composerReasoningLabel: makeEl(), + composerReasoningChip: makeEl(), composerMobileReasoningAction: makeEl(), +}}; +global.$ = id => els[id] || null; +global.S = {{ session: {{ model: 'gpt-5', model_provider: 'openai' }} }}; +global._highlightReasoningOption = () => {{}}; +global._applyReasoningOptions = () => {{}}; +global._reasoningEffortQuery = () => '?model=gpt-5'; +const pending = []; +let calls = 0; +global.api = () => {{ + calls++; + return {{ then(ok) {{ pending.push(ok); return {{ catch() {{}} }}; }} }}; +}}; +var _currentReasoningEffort = 'low'; +var _currentReasoningEffortsSupported = ['low', 'high']; +var _lastReasoningFetchKey = '?model=gpt-5'; +var _reasoningFetchSeq = 7; +eval(extractFunc('_normalizeReasoningEffort')); +eval(extractFunc('_formatReasoningEffortLabel')); +eval(extractFunc('_applyReasoningChip')); +eval(extractFunc('fetchReasoningChip')); +eval(extractFunc('refreshProfileTransitionReasoningChip')); +fetchReasoningChip(); +refreshProfileTransitionReasoningChip(); +const beforeDestination = {{ + effort: _currentReasoningEffort, + cachedKey: _lastReasoningFetchKey, + wrapDisplay: els.composerReasoningWrap.style.display, + calls, +}}; +pending[0]({{ reasoning_effort: 'low', supported_efforts: ['low', 'high'] }}); +const afterPreviousResponse = {{ effort: _currentReasoningEffort, wrapDisplay: els.composerReasoningWrap.style.display }}; +pending[1]({{ reasoning_effort: 'high', supported_efforts: ['low', 'high'] }}); +console.log(JSON.stringify({{ beforeDestination, afterPreviousResponse, afterDestination: _currentReasoningEffort, calls }})); +""" + payload = json.loads(_run_node(source)) + assert payload["beforeDestination"] == { + "effort": "", + "cachedKey": "?model=gpt-5", + "wrapDisplay": "none", + "calls": 2, + } + assert payload["afterPreviousResponse"] == {"effort": "", "wrapDisplay": "none"} + assert payload["afterDestination"] == "high" + assert payload["calls"] == 2 + assert "refreshProfileTransitionReasoningChip" in PANELS_JS + assert PANELS_JS.index("refreshProfileTransitionReasoningChip") > PANELS_JS.index("S.activeProfile = data.active || name") + background = PANELS_JS[PANELS_JS.index("function _refreshProfileSwitchBackground"):PANELS_JS.index("async function loadProfilesPanel")] + for refresh in ( + "_ensureComposerControlVisibilityState", + "_setComposerControlOrder", + "_renderComposerControlChips", + "_renderComposerSituationalControlChips", + "_applyComposerFooterVisibilitySettings", + "_applyTitlebarProfileVisibility", + ): + assert refresh in background + + +def test_profile_transitions_fetch_destination_reasoning_after_hiding_stale_chip(): + source = f""" +const uiSrc = {UI_JS!r}; +const panelsSrc = {PANELS_JS!r}; +const sessionsSrc = {SESSIONS_JS!r}; +function extractFunc(src, name) {{ + const re = new RegExp('(?:async\\\\s+)?function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start), depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +function makeEl() {{ return {{ style: {{}}, disabled: false, classList: {{ add(){{}}, remove(){{}}, toggle(){{}} }}, setAttribute(){{}}, querySelectorAll(){{ return []; }} }}; }} +const els = {{ composerReasoningWrap: makeEl(), composerReasoningLabel: makeEl(), composerReasoningChip: makeEl(), composerMobileReasoningAction: makeEl() }}; +global.$ = id => els[id] || null; +global.window = {{}}; +global.document = {{ title: '' }}; +global.localStorage = {{ removeItem(){{}} }}; +global.S = {{ activeProfile: 'default', activeProfileIsDefault: true, session: null, messages: [] }}; +global._highlightReasoningOption = () => {{}}; +global._applyReasoningOptions = () => {{}}; +global._applyModelToDropdown = model => model; +global._modelStateForSelect = (_, model) => ({{ model, model_provider: 'openai' }}); +global.renderSessionList = async () => {{}}; +global.startGatewaySSE = () => {{}}; +global.showToast = () => {{}}; +global.t = value => value; +global.assistantDisplayName = () => 'Hermes'; +global._profileSwitchPanelLoad = async () => {{}}; +global._refreshProfileSwitchBackground = () => {{}}; +var _profileSwitchGeneration = 0; +var _skillsData = null, _workspaceList = null; +var _currentReasoningEffort = 'low'; +var _currentReasoningEffortsSupported = ['low', 'high']; +var _profileTransitionReasoningContext = null; +var _lastReasoningFetchKey = null; +var _reasoningFetchSeq = 0; +eval(extractFunc(uiSrc, '_normalizeReasoningEffort')); +eval(extractFunc(uiSrc, '_formatReasoningEffortLabel')); +eval(extractFunc(uiSrc, '_reasoningEffortContext')); +eval(extractFunc(uiSrc, '_reasoningEffortQuery')); +eval(extractFunc(uiSrc, '_applyReasoningChip')); +eval(extractFunc(uiSrc, 'fetchReasoningChip')); +eval(extractFunc(uiSrc, 'refreshProfileTransitionReasoningChip')); +eval(extractFunc(uiSrc, 'syncTopbar')); +eval(extractFunc(panelsSrc, 'switchToProfile')); +eval(extractFunc(sessionsSrc, '_switchProfileForSessionLoad')); +const pending = []; +const reasoningUrls = []; +global.api = (url) => {{ + if (url === '/api/profile/switch') return Promise.resolve({{ active: 'vops', is_default: false, default_model: 'gpt-high', default_model_provider: 'openai' }}); + if (url.startsWith('/api/reasoning')) {{ + reasoningUrls.push(url); + return {{ then(ok) {{ pending.push(ok); return {{ catch() {{}} }}; }} }}; + }} + throw new Error('unexpected API ' + url); +}}; +fetchReasoningChip(); +(async () => {{ + await switchToProfile('vops'); + const blankBoot = {{ hidden: els.composerReasoningWrap.style.display, urls: reasoningUrls.slice() }}; + pending[0]({{ reasoning_effort: 'low', supported_efforts: ['low', 'high'] }}); + const blankBootAfterOld = _currentReasoningEffort; + pending[1]({{ reasoning_effort: 'high', supported_efforts: ['low', 'high'] }}); + const blankBootAfterNew = _currentReasoningEffort; + S.activeProfile = 'default'; S.activeProfileIsDefault = true; + S.session = {{ model: 'old-model', model_provider: 'old-provider', profile: 'default' }}; + _currentReasoningEffort = 'low'; _currentReasoningEffortsSupported = ['low', 'high']; _profileTransitionReasoningContext = null; _lastReasoningFetchKey = null; + fetchReasoningChip(); + await _switchProfileForSessionLoad('vops'); + const directLoad = {{ hidden: els.composerReasoningWrap.style.display, urls: reasoningUrls.slice(2) }}; + pending[2]({{ reasoning_effort: 'low', supported_efforts: ['low', 'high'] }}); + const directLoadAfterOld = _currentReasoningEffort; + pending[3]({{ reasoning_effort: 'high', supported_efforts: ['low', 'high'] }}); + console.log(JSON.stringify({{ blankBoot, blankBootAfterOld, blankBootAfterNew, directLoad, directLoadAfterOld, directLoadAfterNew: _currentReasoningEffort }})); +}})().catch(err => {{ console.error(err); process.exit(1); }}); +""" + payload = json.loads(_run_node(source)) + assert payload["blankBoot"] == { + "hidden": "none", + "urls": ["/api/reasoning", "/api/reasoning?model=gpt-high&provider=openai"], + } + assert payload["blankBootAfterOld"] == "" + assert payload["blankBootAfterNew"] == "high" + assert payload["directLoad"] == { + "hidden": "none", + "urls": ["/api/reasoning?model=old-model&provider=old-provider", "/api/reasoning?model=gpt-high&provider=openai"], + } + assert payload["directLoadAfterOld"] == "" + assert payload["directLoadAfterNew"] == "high" + + +def test_blank_profile_transition_context_clears_before_explicit_model_change(): + source = f""" +const uiSrc = {UI_JS!r}; +const bootSrc = {BOOT_JS!r}; +function extractFunc(src, name) {{ + const re = new RegExp('(?:async\\\\s+)?function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start), depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +function makeEl() {{ return {{ style: {{}}, classList: {{ toggle(){{}} }}, setAttribute(){{}}, querySelectorAll(){{ return []; }} }}; }} +const els = {{ + modelSelect: {{ value: 'claude-sonnet' }}, + composerReasoningWrap: makeEl(), + composerReasoningLabel: makeEl(), + composerReasoningChip: makeEl(), + composerMobileReasoningAction: makeEl() +}}; +global.$ = id => els[id] || null; +global.S = {{ activeProfile: 'vops', session: null }}; +global._highlightReasoningOption = () => {{}}; +global._applyReasoningOptions = () => {{}}; +global._modelStateForSelect = (_, model) => ({{ model, model_provider: 'anthropic' }}); +var _currentReasoningEffort = 'high'; +var _currentReasoningEffortsSupported = ['low', 'high']; +var _profileTransitionReasoningContext = {{ profile: 'vops', model: 'gpt-high', provider: 'openai' }}; +var _lastReasoningFetchKey = '?model=gpt-high&provider=openai'; +var _reasoningFetchSeq = 1; +const urls = []; +global.api = (url) => {{ + urls.push(url); + return Promise.resolve({{ reasoning_effort: 'medium', supported_efforts: ['medium'] }}); +}}; +eval(extractFunc(uiSrc, '_normalizeReasoningEffort')); +eval(extractFunc(uiSrc, '_formatReasoningEffortLabel')); +eval(extractFunc(uiSrc, '_reasoningEffortContext')); +eval(extractFunc(uiSrc, '_reasoningEffortQuery')); +eval(extractFunc(uiSrc, '_applyReasoningChip')); +eval(extractFunc(uiSrc, 'fetchReasoningChip')); +eval(extractFunc(uiSrc, 'refreshProfileTransitionReasoningChip')); +eval(extractFunc(uiSrc, 'clearProfileTransitionReasoningContext')); +eval(extractFunc(uiSrc, 'syncReasoningChip')); +clearProfileTransitionReasoningContext(); +syncReasoningChip(); +const handler = bootSrc.slice(bootSrc.indexOf("$('modelSelect').onchange=async()=>"), bootSrc.indexOf("$('msg').addEventListener", bootSrc.indexOf("$('modelSelect').onchange=async()=>"))); +const clearPos = handler.indexOf("clearProfileTransitionReasoningContext"); +const rememberBlankPos = handler.indexOf("_rememberEmptyComposerModelOverride"); +const noSessionSyncPos = handler.indexOf("syncReasoningChip()", rememberBlankPos); +console.log(JSON.stringify({{ + urls, + context: _profileTransitionReasoningContext, + lastKey: _lastReasoningFetchKey, + clearPos, + noSessionSyncPos +}})); +""" + payload = json.loads(_run_node(source)) + assert payload["urls"] == ["/api/reasoning?model=claude-sonnet&provider=anthropic"] + assert payload["context"] is None + assert payload["lastKey"] == "?model=claude-sonnet&provider=anthropic" + assert payload["clearPos"] >= 0 + assert payload["clearPos"] < payload["noSessionSyncPos"] diff --git a/tests/test_issue1103_reasoning_chip_visibility.py b/tests/test_issue1103_reasoning_chip_visibility.py index 1c3660fd..71e96a66 100644 --- a/tests/test_issue1103_reasoning_chip_visibility.py +++ b/tests/test_issue1103_reasoning_chip_visibility.py @@ -16,12 +16,9 @@ def test_boot_call_before_session_load(): """fetchReasoningChip() should be called before session load in boot sequence.""" with open("static/boot.js") as f: src = f.read() - # Find the boot session load; URL-anchored tabs may prefer a URL session id - # before falling back to the stored session id. - boot_marker = "localStorage.getItem('hermes-webui-session')" + boot_marker = "await loadSession(saved, {preserveActiveInput:true});" boot_pos = src.index(boot_marker) - fetch_pos = src.index("fetchReasoningChip()") - # fetchReasoningChip must be called just before the saved session load + fetch_pos = src.index("fetchReasoningChip()", src.index("_profileQueryIntentFromLocation")) assert fetch_pos < boot_pos, \ "fetchReasoningChip() should be called before saved session load in boot.js" @@ -60,12 +57,12 @@ def test_ui_js_passes_model_context_to_reasoning_api(): # the query into a local `key` first (for the in-flight storm + stale-success # guards), so accept either the inlined form or the captured-key form — both # pass _reasoningEffortQuery()'s output to the endpoint. - fetch_match = re.search(r"function fetchReasoningChip\(\)\{(.+?)\n\}", src, re.DOTALL) + fetch_match = re.search(r"function fetchReasoningChip\([^)]*\)\{(.+?)\n\}", src, re.DOTALL) assert fetch_match, "fetchReasoningChip function must exist" fetch_body = fetch_match.group(1) inlined = "api('/api/reasoning'+_reasoningEffortQuery())" in src captured = ( - re.search(r"const\s+key\s*=\s*_reasoningEffortQuery\(\)", fetch_body) + "_reasoningEffortQuery()" in fetch_body and "api('/api/reasoning'+key)" in fetch_body ) assert inlined or captured, ( @@ -79,7 +76,7 @@ def test_fetchReasoningChip_calls_apply(): with open("static/ui.js") as f: src = f.read() # Find fetchReasoningChip function - func_match = re.search(r"function fetchReasoningChip\(\)\{(.+?)\}", src, re.DOTALL) + func_match = re.search(r"function fetchReasoningChip\([^)]*\)\{(.+?)\}", src, re.DOTALL) assert func_match, "fetchReasoningChip function must exist" func_body = func_match.group(1) assert "_applyReasoningChip" in func_body, \ diff --git a/tests/test_issue4650_reasoning_chip_no_storm.py b/tests/test_issue4650_reasoning_chip_no_storm.py index ed826369..293465dd 100644 --- a/tests/test_issue4650_reasoning_chip_no_storm.py +++ b/tests/test_issue4650_reasoning_chip_no_storm.py @@ -81,6 +81,7 @@ function extractFunc(name) { // declare them in this eval scope so the extracted functions can see them. var _currentReasoningEffort = null; var _currentReasoningEffortsSupported = null; +var _profileTransitionReasoningContext = null; var _lastReasoningFetchKey = null; var _reasoningFetchSeq = 0; diff --git a/tests/test_issue4662_profile_switch_skeleton_static.py b/tests/test_issue4662_profile_switch_skeleton_static.py index 1b79e79b..218d3422 100644 --- a/tests/test_issue4662_profile_switch_skeleton_static.py +++ b/tests/test_issue4662_profile_switch_skeleton_static.py @@ -56,7 +56,7 @@ class TestSwitchWiring: # (Codex gate #4662). The skeletons shown up front already provide the # immediate cross-surface feedback. body = _switch_body() - guard = "if (_switchGen !== _profileSwitchGeneration) return;" + guard = "if (_switchGen !== _profileSwitchGeneration) return false;" # In the non-sessionInProgress branch, the loadDir('.') call must come # after an occurrence of the guard. dir_idx = body.index("const dirLoad = loadDir('.');") @@ -88,7 +88,7 @@ class TestSwitchWiring: body = _switch_body() head = body[: _show_session_skeleton_call_idx(body)] assert "name === S.activeProfile" in head, "missing no-op self-switch early-return" - assert "return;" in head + assert "return true;" in head def test_dismisses_rename_and_menu_before_skeleton(self): # Opus gate #4662: renderSessionListFromCache() early-returns while @@ -128,7 +128,7 @@ class TestSwitchWiring: # rapid second switch could have its workspace skeleton cleared / a stale # toast popped by the slower earlier switch. Mirror the no-messages guard. body = _switch_body() - guard = "if (_switchGen !== _profileSwitchGeneration) return;" + guard = "if (_switchGen !== _profileSwitchGeneration) return false;" # Inside the sessionInProgress branch: locate its "await renderSessionList()" # then the profile_switched_new_conversation toast; a guard must sit between. new_toast_idx = body.index("profile_switched_new_conversation") diff --git a/tests/test_mobile_layout.py b/tests/test_mobile_layout.py index 8a0d09a4..2b476f0d 100644 --- a/tests/test_mobile_layout.py +++ b/tests/test_mobile_layout.py @@ -1443,7 +1443,7 @@ def test_reasoning_chip_updates_desktop_and_mobile_controls(): """Reasoning chip sync should keep both footer and mobile overflow labels current.""" ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8") chip_start = ui_js.index("function _applyReasoningChip(eff)") - chip_end = ui_js.index("function fetchReasoningChip()", chip_start) + chip_end = ui_js.index("function fetchReasoningChip(", chip_start) chip_body = ui_js[chip_start:chip_end] for expected in ( "composerReasoningWrap", diff --git a/tests/test_onboarding_static.py b/tests/test_onboarding_static.py index 52196eaa..8fc1eb53 100644 --- a/tests/test_onboarding_static.py +++ b/tests/test_onboarding_static.py @@ -49,6 +49,18 @@ def test_onboarding_uses_i18n_helpers(): assert "onboarding_title: 'Bienvenido a Hermes Web UI'" in i18n +def test_onboarding_provider_notice_uses_i18n_key(): + js = read("static/onboarding.js") + py = read("api/onboarding.py") + assert '"provider_note_key": note_key' in py + assert '"provider_note_args": note_args' in py + assert "function _localizedOnboardingProviderNote(system)" in js + assert "Array.isArray(system&&system.provider_note_args)" in js + assert "t(key,...args)" in js + assert "!/\\{\\d+\\}/.test(localized)" in js + assert "system.provider_note|| (setupOk?" not in js + + def test_bootstrap_script_contains_official_installer_and_windows_guard(): src = read("bootstrap.py") assert ( diff --git a/tests/test_reasoning_chip_js_behaviour.py b/tests/test_reasoning_chip_js_behaviour.py index 5d6faf37..e070edbb 100644 --- a/tests/test_reasoning_chip_js_behaviour.py +++ b/tests/test_reasoning_chip_js_behaviour.py @@ -68,6 +68,7 @@ global.document = { }; global.$ = id => els[id] || null; global.api = () => ({ then: () => ({ catch: () => {} }), catch: () => {} }); +var _profileTransitionReasoningContext = null; function extractFunc(name) { const re = new RegExp('function\\s+' + name + '\\s*\\('); diff --git a/tests/test_russian_locale.py b/tests/test_russian_locale.py index c1f37116..a17966cc 100644 --- a/tests/test_russian_locale.py +++ b/tests/test_russian_locale.py @@ -93,6 +93,11 @@ def test_russian_locale_includes_representative_translations(): "profile_clone_label: '\u0421\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e \u0438\u0437 \u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0433\u043e \u043f\u0440\u043e\u0444\u0438\u043b\u044f'", "profile_base_url_placeholder: '\u0411\u0430\u0437\u043e\u0432\u044b\u0439 URL \u0028\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e, \u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440 http://localhost:11434\u0029'", "profile_api_key_placeholder: 'API-\u043a\u043b\u044e\u0447 \u0028\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0029'", + "mcp_tools_title: '\u0418\u043d\u0441\u0442\u0440\u0443\u043c\u0435\u043d\u0442\u044b MCP'", + "goal_status_none: '\u0410\u043a\u0442\u0438\u0432\u043d\u043e\u0439 \u0446\u0435\u043b\u0438 \u043d\u0435\u0442. \u0417\u0430\u0434\u0430\u0439\u0442\u0435 \u0435\u0451 \u043a\u043e\u043c\u0430\u043d\u0434\u043e\u0439 /goal <\u0442\u0435\u043a\u0441\u0442>.'", + "settings_heading_title: '\u0426\u0435\u043d\u0442\u0440 \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f'", + "providers_tab_title: '\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440\u044b'", + "wiki_not_configured: 'Wiki \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430'", ] for entry in expected: assert entry in src @@ -100,7 +105,7 @@ def test_russian_locale_includes_representative_translations(): def test_russian_locale_covers_english_keys(): src = read(REPO / "static" / "i18n.js") - key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE) + key_pattern = re.compile(r"^\s+([a-zA-Z0-9_]+):", re.MULTILINE) en_keys = set(key_pattern.findall(extract_locale_block(src, "en"))) ru_keys = set(key_pattern.findall(extract_locale_block(src, "ru"))) @@ -110,7 +115,13 @@ def test_russian_locale_covers_english_keys(): def test_russian_locale_has_no_duplicate_keys(): src = read(REPO / "static" / "i18n.js") - key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE) + key_pattern = re.compile(r"^\s+([a-zA-Z0-9_]+):", re.MULTILINE) keys = key_pattern.findall(extract_locale_block(src, "ru")) duplicates = sorted(k for k, count in Counter(keys).items() if count > 1) assert not duplicates, f"Russian locale has duplicate keys: {duplicates}" + + +def test_russian_locale_has_no_cjk_fallback_text(): + src = read(REPO / "static" / "i18n.js") + ru_block = extract_locale_block(src, "ru") + assert not re.search(r"[\u3400-\u9fff\u3040-\u30ff]", ru_block) diff --git a/tests/test_sprint34.py b/tests/test_sprint34.py index a7f06f1c..ba09f18d 100644 --- a/tests/test_sprint34.py +++ b/tests/test_sprint34.py @@ -181,6 +181,8 @@ class TestStatusFromRuntimeOAuth: assert result["provider_configured"] is True assert result["provider_ready"] is False assert result["setup_state"] == "provider_incomplete" + assert result["provider_note_key"] == "onboarding_notice_provider_auth_required" + assert result["provider_note_args"] == ["copilot"] def test_oauth_incomplete_note_mentions_hermes_auth(self, tmp_path): """When OAuth provider is incomplete, note should mention hermes auth/model.""" @@ -198,11 +200,20 @@ class TestStatusFromRuntimeOAuth: f"Note misleadingly mentions 'API key' for OAuth provider: {note!r}" ) + def test_custom_provider_missing_base_url_gets_specific_note_key(self, tmp_path): + """Custom provider setup must not collapse into the generic setup notice.""" + result = self._call("custom", "local-model", tmp_path) + assert result["provider_ready"] is False + assert result["provider_note_key"] == "onboarding_notice_custom_base_url_required" + assert result["provider_note_args"] == [] + assert "base URL" in result["provider_note"] + def test_standard_provider_incomplete_note_still_says_api_key(self, tmp_path): """For a standard API-key provider (openrouter), note should still say API key.""" # openrouter with no .env result = self._call("openrouter", "anthropic/claude-sonnet-4.6", tmp_path) assert result["provider_ready"] is False + assert result["provider_note_key"] == "onboarding_notice_provider_api_key_required" note = result["provider_note"] assert "API key" in note, ( f"Expected 'API key' in note for openrouter, got: {note!r}" diff --git a/tests/test_workspace_panel_persists_on_empty_boot.py b/tests/test_workspace_panel_persists_on_empty_boot.py index b48993b0..cc690067 100644 --- a/tests/test_workspace_panel_persists_on_empty_boot.py +++ b/tests/test_workspace_panel_persists_on_empty_boot.py @@ -30,6 +30,18 @@ import pathlib REPO = pathlib.Path(__file__).parent.parent BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8") +INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8") +I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8") + + +def _html_tag_by_id(element_id: str) -> str: + marker = f'id="{element_id}"' + idx = INDEX_HTML.find(marker) + assert idx > 0, f"{element_id} not found in index.html" + start = INDEX_HTML.rfind("", idx) + assert start >= 0 and end > idx, f"{element_id} button tag not found" + return INDEX_HTML[start : end + 1] # ── 1. syncWorkspacePanelState preserves browse mode without a session ────── @@ -136,3 +148,35 @@ class TestToggleStaysEnabledWithProfileWorkspace: "early-return guard so users can open the panel via the toggle " "button when a profile workspace is configured" ) + + +class TestWorkspacePanelStatefulLocalization: + def test_locale_pass_resyncs_stateful_panel_tooltips(self): + """Locale re-stamping must finish by restoring open/closed panel labels.""" + assert "if (typeof syncWorkspacePanelUI === 'function') syncWorkspacePanelUI();" in I18N_JS + + def test_stateful_toggle_buttons_do_not_carry_static_title_keys(self): + """The show/hide tooltip belongs to syncWorkspacePanelUI(), not static DOM i18n.""" + for element_id in ("btnWorkspacePanelToggle", "btnWorkspacePanelEdgeToggle"): + tag = _html_tag_by_id(element_id) + assert 'data-i18n-title="workspace_panel_show"' not in tag + assert 'data-i18n-title="workspace_panel_hide"' not in tag + + def test_stateful_toggle_buttons_do_not_carry_static_aria_keys(self): + """The final accessible label should match the current show/hide action.""" + for element_id in ("btnWorkspacePanelToggle", "btnWorkspacePanelEdgeToggle"): + tag = _html_tag_by_id(element_id) + assert 'data-i18n-aria-label="workspace_panel_toggle"' not in tag + assert 'data-i18n-aria-label="workspace_panel_show"' not in tag + assert 'data-i18n-aria-label="workspace_panel_hide"' not in tag + assert "toggleBtn.setAttribute('aria-label', label);" in BOOT_JS + assert "edgeToggleBtn.setAttribute('aria-label', label);" in BOOT_JS + + def test_clear_preview_accessible_label_matches_stateful_tooltip(self): + """The close/preview action should expose the same localized label everywhere.""" + idx = BOOT_JS.find("const clearBtn=$('btnClearPreview');") + assert idx > 0, "btnClearPreview sync block not found" + body = BOOT_JS[idx:idx + 600] + assert "const label=hasPreview?" in body + assert "_setButtonTooltip(clearBtn, label);" in body + assert "clearBtn.setAttribute('aria-label', label);" in body