diff --git a/CHANGELOG.md b/CHANGELOG.md index ad50b6df..e42347fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ ### Fixed +## v0.50.209 — 2026-04-25 + +### Added +- **Codex-style message queue flyout** — messages typed while a stream is running now appear as a flyout card above the composer (same pattern as approval/clarify cards). Supports drag-to-reorder, inline edit, per-item model badge, Combine/Clear actions, and a collapsed pill outside the composer. Per-session DOM isolation via `_queueRenderKeys[sid]`/`_queueCollapsed[sid]` prevents cross-session bleed. Titlebar `#appTitlebarSub` chip shows live queue count. (`static/ui.js`, `static/messages.js`, `static/style.css`, `static/i18n.js`, `static/index.html`) Closes #965 [#1040 @24601] +- **Inline HTML preview in workspace panel** — `.html` and `.htm` files now render as live sandboxed iframes (`sandbox="allow-scripts"`, no `allow-same-origin`) in the workspace file browser. A `?inline=1` parameter on `/api/file/raw` bypasses the usual attachment disposition; the server adds `Content-Security-Policy: sandbox allow-scripts` on inline HTML responses to prevent XSS when the URL is opened directly in a browser tab. (`static/workspace.js`, `api/routes.py`, `static/index.html`) Closes #779 [#1035 @bergeouss] +- **Provider categories in setup wizard** — the onboarding provider dropdown groups 10 providers into Easy Start / Open & Self-hosted / Specialized with `` sections. Includes Google Gemini, DeepSeek, Mistral, and xAI/Grok with correct current model defaults. (`api/onboarding.py`, `static/onboarding.js`) Closes #603 [#1036 @bergeouss] + +### Fixed +- **Manual "Check for Updates" button in System settings** — users can now trigger an update check immediately instead of waiting for the periodic background fetch. Error messages are sanitized before display. (`static/panels.js`, `static/index.html`, `static/style.css`) Closes #785 [#1033 @bergeouss] +- **"Keep workspace panel open" toggle in Appearance settings** — adds a persistent preference so the workspace panel opens automatically on each session if preferred. The toolbar X no longer clears the preference. (`static/panels.js`, `static/boot.js`) Closes #999 [#1034 @bergeouss] + +### Changed +- **CSP allowlist for Cloudflare Access deployments** — `default-src` and `manifest-src` now include `https://*.cloudflareaccess.com`, and `script-src` now includes `https://static.cloudflareinsights.com`. This unblocks Agent37-style deployments running behind Cloudflare Access without affecting vanilla self-hosters (the new origins are unreachable in non-Cloudflare environments). (`api/helpers.py`) [#1040 follow-up] + ## v0.50.207 — 2026-04-25 ### Added diff --git a/TESTING.md b/TESTING.md index 88de9d4d..7eddf6e7 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,7 +8,7 @@ > Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser. > Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}. > -> Automated coverage: 2169 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation. +> Automated coverage: 2212 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation. > Run: `pytest tests/ -v --timeout=60` > > Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash. diff --git a/api/onboarding.py b/api/onboarding.py index d0ac572a..89e15648 100644 --- a/api/onboarding.py +++ b/api/onboarding.py @@ -29,6 +29,7 @@ logger = logging.getLogger(__name__) _SUPPORTED_PROVIDER_SETUPS = { + # ── Easy start ────────────────────────────────────────────────────── "openrouter": { "label": "OpenRouter", "env_var": "OPENROUTER_API_KEY", @@ -37,6 +38,8 @@ _SUPPORTED_PROVIDER_SETUPS = { "models": [ {"id": model["id"], "label": model["label"]} for model in _FALLBACK_MODELS ], + "category": "easy_start", + "quick": True, }, "anthropic": { "label": "Anthropic", @@ -44,6 +47,7 @@ _SUPPORTED_PROVIDER_SETUPS = { "default_model": "claude-sonnet-4.6", "requires_base_url": False, "models": list(_PROVIDER_MODELS.get("anthropic", [])), + "category": "easy_start", }, "openai": { "label": "OpenAI", @@ -52,6 +56,26 @@ _SUPPORTED_PROVIDER_SETUPS = { "default_base_url": "https://api.openai.com/v1", "requires_base_url": False, "models": list(_PROVIDER_MODELS.get("openai", [])), + "category": "easy_start", + }, + # ── Open / self-hosted ───────────────────────────────────────────── + "ollama": { + "label": "Ollama", + "env_var": "OLLAMA_API_KEY", + "default_model": "qwen3:32b", + "default_base_url": "http://localhost:11434/v1", + "requires_base_url": True, + "models": [], + "category": "self_hosted", + }, + "lmstudio": { + "label": "LM Studio", + "env_var": "LMSTUDIO_API_KEY", + "default_model": "gpt-4o-mini", + "default_base_url": "http://localhost:1234/v1", + "requires_base_url": True, + "models": [], + "category": "self_hosted", }, "custom": { "label": "Custom OpenAI-compatible", @@ -59,9 +83,59 @@ _SUPPORTED_PROVIDER_SETUPS = { "default_model": "gpt-4o-mini", "requires_base_url": True, "models": [], + "category": "self_hosted", + }, + # ── Specialized / extended ────────────────────────────────────────── + "gemini": { + "label": "Google Gemini", + "env_var": "GOOGLE_API_KEY", + "default_model": "gemini-3.1-pro-preview", + "default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai", + "requires_base_url": False, + # _PROVIDER_MODELS in api/config.py is keyed under "google" even though + # the agent's alias map normalizes "google" → "gemini". Use the catalog + # key here so the wizard surfaces the actual model list. + "models": list(_PROVIDER_MODELS.get("google", [])), + "category": "specialized", + }, + "deepseek": { + "label": "DeepSeek", + "env_var": "DEEPSEEK_API_KEY", + "default_model": "deepseek-chat-v3-0324", + "default_base_url": "https://api.deepseek.com/v1", + "requires_base_url": False, + "models": list(_PROVIDER_MODELS.get("deepseek", [])), + "category": "specialized", + }, + "mistralai": { + "label": "Mistral", + "env_var": "MISTRAL_API_KEY", + "default_model": "mistral-large-latest", + "default_base_url": "https://api.mistral.ai/v1", + "requires_base_url": False, + # No catalog entry for mistralai today — wizard shows a free-text input. + "models": list(_PROVIDER_MODELS.get("mistralai", [])), + "category": "specialized", + }, + "x-ai": { + "label": "xAI (Grok)", + "env_var": "XAI_API_KEY", + "default_model": "grok-4.20", + "default_base_url": "https://api.x.ai/v1", + "requires_base_url": False, + # Agent normalizes "x-ai" → "xai"; _PROVIDER_MODELS is also keyed "xai" + # when populated, so check both keys for forward-compatibility. + "models": list(_PROVIDER_MODELS.get("xai", []) or _PROVIDER_MODELS.get("x-ai", [])), + "category": "specialized", }, } +_PROVIDER_CATEGORIES = [ + {"id": "easy_start", "label": "Easy start", "order": 0}, + {"id": "self_hosted", "label": "Open / self-hosted", "order": 1}, + {"id": "specialized", "label": "Specialized", "order": 2}, +] + _UNSUPPORTED_PROVIDER_NOTE = ( "OAuth and advanced provider flows such as Nous Portal, OpenAI Codex, and GitHub " "Copilot are still terminal-first. Use `hermes model` for those flows." @@ -384,10 +458,24 @@ def _build_setup_catalog(cfg: dict) -> dict: "default_base_url": meta.get("default_base_url") or "", "requires_base_url": bool(meta.get("requires_base_url")), "models": list(meta.get("models", [])), - "quick": provider_id == "openrouter", + "category": meta.get("category", "easy_start"), + "quick": meta.get("quick", False), } ) + # Sort providers by category order, then alphabetically within each category. + cat_order = {c["id"]: c["order"] for c in _PROVIDER_CATEGORIES} + providers.sort(key=lambda p: (cat_order.get(p["category"], 99), p["label"])) + + # Group providers by category for the frontend. + categories = [] + for cat in sorted(_PROVIDER_CATEGORIES, key=lambda c: c["order"]): + categories.append({ + "id": cat["id"], + "label": cat["label"], + "providers": [p["id"] for p in providers if p["category"] == cat["id"]], + }) + # Flag whether the currently-configured provider is OAuth-based (not in the # API-key flow). The frontend uses this to show a confirmation card instead # of a key input when the user has already authenticated via 'hermes auth'. @@ -397,6 +485,7 @@ def _build_setup_catalog(cfg: dict) -> dict: return { "providers": providers, + "categories": categories, "unsupported_note": _UNSUPPORTED_PROVIDER_NOTE, "current_is_oauth": current_is_oauth, "current": { @@ -542,12 +631,10 @@ def apply_onboarding_setup(body: dict) -> dict: model_cfg["provider"] = provider model_cfg["default"] = _normalize_model_for_provider(provider, model) - if provider == "custom": + if provider_meta.get("requires_base_url"): model_cfg["base_url"] = base_url - elif provider == "openai": - model_cfg["base_url"] = ( - provider_meta.get("default_base_url") or "https://api.openai.com/v1" - ) + elif provider_meta.get("default_base_url"): + model_cfg["base_url"] = provider_meta["default_base_url"] else: model_cfg.pop("base_url", None) diff --git a/api/routes.py b/api/routes.py index b6105dfd..2515de63 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2148,9 +2148,14 @@ def _handle_file_raw(handler, parsed): handler.send_header("Content-Type", mime) handler.send_header("Content-Length", str(len(raw_bytes))) handler.send_header("Cache-Control", "no-store") - # Security: force download for dangerous MIME types to prevent XSS + # Security: force download for dangerous MIME types to prevent XSS. + # Exception: ?inline=1 permits text/html to be served inline for the + # sandboxed workspace HTML preview iframe (sandbox="allow-scripts" with no + # allow-same-origin, so the iframe cannot access parent cookies/storage). + inline_preview = qs.get("inline", [""])[0] == "1" dangerous_types = {"text/html", "application/xhtml+xml", "image/svg+xml"} - if force_download or mime in dangerous_types: + html_inline_ok = inline_preview and mime == "text/html" + if force_download or (mime in dangerous_types and not html_inline_ok): handler.send_header( "Content-Disposition", _content_disposition_value("attachment", target.name), @@ -2160,6 +2165,18 @@ def _handle_file_raw(handler, parsed): "Content-Disposition", _content_disposition_value("inline", target.name), ) + # Defense-in-depth for ?inline=1 HTML: even though the workspace.js iframe + # sets sandbox="allow-scripts", a user could be tricked into opening the + # ?inline=1 URL directly in a top-level tab (e.g. via a chat link), which + # would render the HTML in the WebUI's origin without iframe sandbox. The + # CSP sandbox directive applies the same isolation server-side: without + # allow-same-origin, the document is treated as a unique opaque origin and + # cannot read WebUI cookies, localStorage, or postMessage to the parent. + if html_inline_ok: + # Match the iframe sandbox="allow-scripts" exactly: scripts allowed, + # but no allow-same-origin → unique opaque origin (no cookie/storage + # access even when accessed via direct URL outside the iframe). + handler.send_header("Content-Security-Policy", "sandbox allow-scripts") handler.end_headers() handler.wfile.write(raw_bytes) return True diff --git a/static/boot.js b/static/boot.js index 789d0fae..dcda6ecf 100644 --- a/static/boot.js +++ b/static/boot.js @@ -42,6 +42,8 @@ function _setWorkspacePanelMode(mode){ const open=_workspacePanelMode!=='closed'; document.documentElement.dataset.workspacePanel=open?'open':'closed'; // Persist open/closed across refreshes (browse/preview → open; closed → closed) + // Do NOT overwrite the user's "keep open" preference — only track runtime state + // so that toggleWorkspacePanel(false) from the toolbar doesn't clear the setting. localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed'); layout.classList.toggle('workspace-panel-collapsed',!open); if(_isCompactWorkspaceViewport()){ @@ -870,9 +872,12 @@ function applyBotName(){ if(saved){ try{ await loadSession(saved); - // Only restore the panel from localStorage when the session actually has a workspace. - // Without this guard, sessions without a workspace snap open then immediately closed. - if(S.session&&S.session.workspace&&localStorage.getItem('hermes-webui-workspace-panel')==='open'){ + // Restore the panel from localStorage when the session has a workspace. + // Preference key takes priority over runtime state so that closing + // the panel via toolbar X doesn't suppress the "keep open" setting. + const panelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open' + || localStorage.getItem('hermes-webui-workspace-panel')==='open'; + if(S.session&&S.session.workspace&&panelPref){ _workspacePanelMode='browse'; } S._bootReady=true; diff --git a/static/i18n.js b/static/i18n.js index b2b16da8..44230b3a 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -239,6 +239,15 @@ const LOCALES = { settings_section_preferences_meta: 'Defaults and UI behavior for Hermes Web UI.', settings_section_system_title: 'System', settings_section_system_meta: 'Instance version and access controls.', + settings_check_now: 'Check now', + settings_checking: 'Checking\u2026', + settings_up_to_date: 'Up to date \u2713', + settings_updates_available: '{count} update(s) available', + settings_updates_disabled: 'Update checks disabled', + settings_update_check_failed: 'Update check failed', + settings_label_workspace_panel_open: 'Keep workspace panel open by default', + settings_desc_workspace_panel_open: 'When enabled, the workspace / file browser panel opens automatically with each new session. You can still close it manually at any time.', + open_in_browser: 'Open in browser', settings_dropdown_conversation: 'Conversation', settings_dropdown_appearance: 'Appearance', settings_dropdown_preferences: 'Preferences', @@ -418,6 +427,9 @@ const LOCALES = { onboarding_workspace_placeholder: '/home/you/workspace', onboarding_provider_label: 'Setup mode', onboarding_quick_setup_badge: 'quick setup', + provider_category_easy_start: 'Easy start', + provider_category_self_hosted: 'Open / self-hosted', + provider_category_specialized: 'Specialized', onboarding_api_key_label: 'API key', onboarding_api_key_placeholder: 'Leave blank to keep an existing saved key', onboarding_api_key_help_prefix: 'Saved as a secret in your Hermes .env file using', @@ -942,6 +954,9 @@ const LOCALES = { onboarding_workspace_placeholder: '/home/you/workspace', onboarding_provider_label: 'Режим настройки', onboarding_quick_setup_badge: 'Быстрая настройка', + provider_category_easy_start: 'Быстрый старт', + provider_category_self_hosted: 'Локальные / Open source', + provider_category_specialized: 'Специализированные', onboarding_api_key_label: 'Ключ API', onboarding_api_key_placeholder: 'Оставьте пустым, чтобы сохранить уже сохранённый ключ', onboarding_api_key_help_prefix: 'Сохраняется как секрет в вашем файле `.env` Hermes с помощью', @@ -1189,6 +1204,15 @@ const LOCALES = { 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_check_now: 'Проверить', + settings_checking: 'Проверка\u2026', + settings_up_to_date: 'Актуально \u2713', + settings_updates_available: 'Доступно обновлений: {count}', + settings_updates_disabled: 'Проверка обновлений отключена', + settings_update_check_failed: 'Ошибка проверки обновлений', + settings_label_workspace_panel_open: 'Открывать панель рабочей области по умолчанию', + settings_desc_workspace_panel_open: 'При включении панель файлов будет открываться автоматически в каждой новой сессии.', + open_in_browser: 'Открыть в браузере', settings_section_system_title: 'System', settings_tab_appearance: 'Appearance', settings_tab_conversation: 'Conversation', @@ -1507,6 +1531,9 @@ const LOCALES = { onboarding_workspace_placeholder: '/home/you/workspace', onboarding_provider_label: 'Modo de configuración', onboarding_quick_setup_badge: 'configuración rápida', + provider_category_easy_start: 'Inicio rápido', + provider_category_self_hosted: 'Local / Open source', + provider_category_specialized: 'Especializados', onboarding_api_key_label: 'API key', onboarding_api_key_placeholder: 'Déjala en blanco para conservar una key ya guardada', onboarding_api_key_help_prefix: 'Se guarda como secreto en tu archivo .env de Hermes usando', @@ -1738,6 +1765,15 @@ const LOCALES = { 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_check_now: 'Comprobar ahora', + settings_checking: 'Comprobando\u2026', + settings_up_to_date: 'Actualizado \u2713', + 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', + settings_label_workspace_panel_open: 'Mantener panel de espacio abierto', + settings_desc_workspace_panel_open: 'Al activar, el panel de archivos se abre automáticamente en cada nueva sesión. Aún puedes cerrarlo manualmente.', + open_in_browser: 'Abrir en el navegador', settings_section_system_title: 'System', settings_tab_appearance: 'Appearance', settings_tab_conversation: 'Conversation', @@ -2070,6 +2106,15 @@ const LOCALES = { 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_check_now: 'Jetzt prüfen', + settings_checking: 'Prüfung\u2026', + settings_up_to_date: 'Aktuell \u2713', + settings_updates_available: '{count} Update(s) verfügbar', + settings_updates_disabled: 'Update-Prüfung deaktiviert', + settings_update_check_failed: 'Update-Prüfung fehlgeschlagen', + settings_label_workspace_panel_open: 'Arbeitsbereich-Panel standardmäßig öffnen', + settings_desc_workspace_panel_open: 'Wenn aktiviert, wird der Datei-Browser bei jeder neuen Sitzung automatisch geöffnet. Er kann jederzeit manuell geschlossen werden.', + open_in_browser: 'Im Browser öffnen', settings_section_system_title: 'System', settings_tab_appearance: 'Appearance', settings_tab_conversation: 'Conversation', @@ -2386,6 +2431,9 @@ const LOCALES = { onboarding_workspace_placeholder: '/home/you/workspace', onboarding_provider_label: '设置模式', onboarding_quick_setup_badge: '快速设置', + provider_category_easy_start: '快速开始', + provider_category_self_hosted: '本地 / 开源', + provider_category_specialized: '专业服务', onboarding_api_key_label: 'API key', onboarding_api_key_placeholder: '留空可保留已保存的 key', onboarding_api_key_help_prefix: '会作为密钥保存到 Hermes .env 文件中,变量名为', @@ -2616,6 +2664,15 @@ const LOCALES = { 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_check_now: '立即检查', + settings_checking: '检查中\u2026', + settings_up_to_date: '已是最新 \u2713', + settings_updates_available: '有 {count} 个更新可用', + settings_updates_disabled: '更新检查已禁用', + settings_update_check_failed: '更新检查失败', + settings_label_workspace_panel_open: '默认保持工作区面板打开', + settings_desc_workspace_panel_open: '启用后,工作区/文件浏览器面板会在每次新会话时自动打开。您仍可随时手动关闭。', + open_in_browser: '在浏览器中打开', settings_section_system_title: 'System', settings_tab_appearance: 'Appearance', settings_tab_conversation: 'Conversation', @@ -2788,6 +2845,15 @@ const LOCALES = { settings_section_preferences_meta: 'Hermes Web UI 的預設值與介面行為。', settings_section_system_title: '系統', settings_section_system_meta: '實例版本與存取控制。', + settings_check_now: '立即檢查', + settings_checking: '檢查中\u2026', + settings_up_to_date: '已是最新 \u2713', + settings_updates_available: '有 {count} 個更新可用', + settings_updates_disabled: '更新檢查已禁用', + settings_update_check_failed: '更新檢查失敗', + settings_label_workspace_panel_open: '預設保持工作區面板開啓', + settings_desc_workspace_panel_open: '啟用後,工作區/檔案瀏覽器面板會在每次新會話時自動開啓。您仍可隨時手動關閉。', + open_in_browser: '在瀏覽器中開啓', settings_dropdown_conversation: '對話', settings_dropdown_appearance: '外觀', settings_dropdown_preferences: '偏好設定', @@ -2977,6 +3043,9 @@ const LOCALES = { onboarding_password_will_replace: '\u5c07\u53d6\u4ee3', onboarding_provider_label: '\u8a2d\u5b9a\u6a21\u5f0f', onboarding_quick_setup_badge: '\u5feb\u901f\u8a2d\u5b9a', + provider_category_easy_start: '\u5feb\u901f\u958b\u59cb', + provider_category_self_hosted: '\u672c\u5730 / \u958b\u6e90', + provider_category_specialized: '\u5c08\u696d\u670d\u52d9', onboarding_skip: '\u8df3\u904e\u8a2d\u5b9a', onboarding_skipped: '\u5df2\u8df3\u904e\u8a2d\u5b9a \u2014 \u4f7f\u7528\u73fe\u6709\u914d\u7f6e\u3002', onboarding_step_finish_desc: '\u6aa2\u8996\u8a2d\u5b9a\u4e26\u9032\u5165\u61c9\u7528\u7a0b\u5f0f\u3002', diff --git a/static/index.html b/static/index.html index 8f682af2..d4474d6e 100644 --- a/static/index.html +++ b/static/index.html @@ -592,6 +592,13 @@ +
+ +
When enabled, the workspace / file browser panel opens automatically with each new session. You can still close it manually at any time.
+
@@ -693,7 +700,11 @@
System
- +
+ + + +
@@ -728,12 +739,16 @@
+

       
       
+      
       
     
diff --git a/static/onboarding.js b/static/onboarding.js index b10de996..955c2d23 100644 --- a/static/onboarding.js +++ b/static/onboarding.js @@ -8,6 +8,30 @@ function _getOnboardingSetupProvider(id){ return _getOnboardingSetupProviders().find(p=>p.id===id)||null; } +function _getOnboardingSetupCategories(){ + return (((ONBOARDING.status||{}).setup||{}).categories)||[]; +} + +/** Render the provider ${options} +