mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 10:40:16 +00:00
v0.50.209: check-for-updates, workspace toggle, HTML preview, provider categories, queue flyout docs (#1042)
* feat: add manual 'Check for Updates' button in System settings (#785) Add a 'Check now' button next to the version badge in the System settings section, allowing users to manually trigger an update check at any time without waiting for the automatic periodic check. Changes: - index.html: add button with spinner and status text inline with version badge - panels.js: add checkUpdatesNow() calling /api/updates/check?force=1 with immediate feedback (checking... / up to date / X updates available) - style.css: style the button block and spinner - i18n.js: add 5 new keys (settings_check_now, settings_checking, settings_up_to_date, settings_updates_available, settings_updates_disabled) in all 6 locales (en, ru, es, de, zh, zh-Hant) * fix: sanitize error message in checkUpdatesNow to avoid exposing paths Review feedback: strip filesystem paths from error messages and cap length to prevent internal details leaking into the UI. * fix: fully sanitize error in update check — never expose raw e.message in UI Previous partial fix (80cdaee) stripped filesystem paths from e.message but still displayed the JS exception message to users. Per reviewer feedback and project convention (NEVER expose raw e.message in UI), replace with: - A generic user-facing i18n key (settings_update_check_failed) as default - Fallback to API response body error if available (structured, not raw) - Full error logged via console.warn for debugging - Button disable-during-check already confirmed working (try/finally pattern) - settings_update_check_failed key added in all 6 locales * fix(#785): align HTML selectors with CSS and add regression tests - Wrap update button in div#checkUpdatesBlock so CSS selectors apply - Change button class from sm-btn to btn-tiny (matching stylesheet) - Remove inline styles now handled by CSS (#checkUpdatesBlock, .btn-tiny) - Move spinner sizing to CSS class .spinner-xs - Add 4 static tests in test_update_banner_fixes.py: checkUpdatesNow defined, btnCheckUpdatesNow in HTML, CSS selectors exist, i18n key in all locales * feat: 'Keep workspace panel open' toggle in Appearance settings (#999) * feat: categorize providers in setup wizard (#603) - Add 6 new providers: Google Gemini, DeepSeek, Mistral, xAI (Grok), Ollama, LM Studio to the onboarding quick-setup catalog - Group providers into 3 categories: Easy start, Open/self-hosted, Specialized — rendered as <optgroup> in the provider dropdown - Generic base_url save logic (requires_base_url + default_base_url) instead of hardcoded provider checks - i18n keys for category labels in en, ru, es, zh, zh-Hant * ci: re-run tests * fix(tests): prevent reload_config() from overwriting in-memory mock in test_issue644 The test helper _available_models_with_cfg patches cfg in-memory but get_available_models() calls reload_config() when the config file's mtime doesn't match _cfg_mtime. On CI, config.yaml exists so mtime > 0 and _cfg_mtime starts at 0.0, triggering a reload that overwrites the test's mock with on-disk content. Fix: freeze _cfg_mtime to the current config file mtime inside the helper, so reload_config() is not triggered during the test. * fix: correct default model IDs for gemini, xai, deepseek; add specialized provider tests - gemini: gemini-3.1-pro-preview → gemini-2.5-pro-preview - x-ai: grok-4.20 → grok-3 - deepseek: deepseek-chat-v3-0324 → deepseek-chat - Add TestApplyBaseURLSpecialized: 4 tests verifying base_url written for gemini, deepseek, mistral, and x-ai through apply_onboarding_setup * test: add TestApplyBaseURLSpecialized — verify base_url written for gemini, deepseek, mistralai, x-ai * fix(onboarding): correct stale model defaults for specialized providers Three issues in the new specialized provider catalog (#1027 hold reason): 1. gemini default_model was `gemini-2.5-pro-preview` — agent's catalog has the 3.1 family. Updated to `gemini-3.1-pro-preview`. 2. x-ai default_model was `grok-3` — agent's catalog has `grok-4.20`. Updated. 3. gemini `models` list was sourcing from `_PROVIDER_MODELS.get("gemini")` which returns []. The catalog in api/config.py is keyed under "google" (even though the agent's alias map normalizes google -> gemini). Switched to `_PROVIDER_MODELS.get("google")` so the wizard surfaces the actual 5-model list. Also forward-compatible lookup for x-ai (xai or x-ai key). Without these fixes, users picking gemini or x-ai in the wizard would see no model dropdown and the default_model written to config.yaml would 404 on first chat. deepseek default_model bumped from `deepseek-chat` to `deepseek-chat-v3-0324` to match the test fixture's expectation and the agent catalog's pinned version. Added two regression tests: - test_gemini_model_list_is_populated: pins the catalog-key correctness - test_specialized_default_models_match_catalog: pins the version prefixes (3.x for gemini, 4.x for grok) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: inline HTML preview in workspace panel (#779) Render .html/.htm files as live previews in a sandboxed iframe instead of showing raw source code. Adds an 'Open in browser' button to open the file in a new tab. Changes: - workspace.js: add HTML_EXTS set, 'html' preview mode, iframe routing in openFile(), and openInBrowser() function - index.html: add sandboxed iframe element and 'Open in browser' button in preview toolbar (visible only for HTML files) - i18n.js: add 'open_in_browser' key in all 6 locales The iframe uses sandbox='allow-scripts' for security. Download button remains available alongside the new preview. * docs: document sandbox security tradeoff for HTML preview Review feedback: fileExt() already lowercases extensions so .HTML/.HTM work. Added code comment explaining the deliberate sandbox=allow-scripts choice: scripts are needed for most HTML documents but the iframe is still origin- isolated and cannot access parent cookies/data. * fix: pass ?inline=1 to file/raw so HTML preview iframe renders instead of downloading routes.py: add inline_preview param — bypasses Content-Disposition:attachment for text/html when ?inline=1 is set, serving the file inline for the sandboxed iframe. workspace.js: add &inline=1 to the iframe src URL. test: add 5 static regression tests for the inline HTML preview. * fix(security): CSP sandbox header for inline HTML preview The iframe sandbox="allow-scripts" attribute on previewHtmlIframe only applies when HTML is loaded INSIDE that iframe. A user tricked into opening /api/file/raw?path=evil.html&inline=1 directly in a top-level tab (e.g. via a chat link) would render the HTML in the WebUI's origin without any sandbox, giving the page full access to cookies and localStorage. Server-side Content-Security-Policy: sandbox allow-scripts mirrors the iframe sandbox exactly: scripts run, but the document is treated as a unique opaque origin (no allow-same-origin) and cannot read WebUI cookies, localStorage, or postMessage to the parent regardless of how the URL is accessed. Added test_inline_html_response_sets_csp_sandbox to pin the header. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: v0.50.209 release notes — 4 PRs, 2212 tests (+43) * docs(changelog): document #1040 queue flyout and Cloudflare CSP in v0.50.209 The stage commited2bd18listed v0.50.209 as a 4-PR release but the stage actually bundles 5 PRs — #1040 (queue flyout) was cherry-picked in without a corresponding CHANGELOG entry. Without this fix, the queue feature ships silently and the bundled Cloudflare CSP relaxation in api/helpers.py is also undocumented. Adds two entries: - Added: queue flyout (#1040) under v0.50.209 - Changed: CSP allowlist for Cloudflare Access deployments Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<optgroup>` 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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+93
-6
@@ -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)
|
||||
|
||||
|
||||
+19
-2
@@ -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
|
||||
|
||||
+8
-3
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
+16
-1
@@ -592,6 +592,13 @@
|
||||
</div>
|
||||
<input type="hidden" id="settingsFontSize" value="default">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsWorkspacePanelOpen" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_workspace_panel_open">Keep workspace panel open by default</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="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.</div>
|
||||
</div>
|
||||
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
|
||||
</div>
|
||||
<div class="settings-pane" id="settingsPanePreferences">
|
||||
@@ -693,7 +700,11 @@
|
||||
<div class="settings-section-title" data-i18n="settings_section_system_title">System</div>
|
||||
<div class="settings-section-meta" data-i18n="settings_section_system_meta">Instance version and access controls.</div>
|
||||
</div>
|
||||
<span class="settings-version-badge">—</span>
|
||||
<div id="checkUpdatesBlock">
|
||||
<span class="settings-version-badge">—</span>
|
||||
<button class="btn-tiny" id="btnCheckUpdatesNow" onclick="checkUpdatesNow()" title="Check for updates now" data-i18n-title="settings_check_now"><svg id="checkUpdatesSpinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="spinner-xs" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56"/><polyline points="21 3 21 9 15 9"/></svg><span id="checkUpdatesLabel" data-i18n="settings_check_now">Check now</span></button>
|
||||
<span id="checkUpdatesStatus"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||
@@ -728,12 +739,16 @@
|
||||
<div class="preview-path" id="previewPath">
|
||||
<span id="previewPathText"></span>
|
||||
<span class="preview-badge" id="previewBadge"></span>
|
||||
<button id="btnOpenInBrowser" class="panel-icon-btn" style="margin-left:auto;font-size:12px;width:auto;padding:2px 8px;display:none;align-items:center;gap:4px" onclick="openInBrowser()" title="Open in new browser tab"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg> <span data-i18n="open_in_browser">Open in browser</span></button>
|
||||
<button id="btnDownloadFile" class="panel-icon-btn" style="margin-left:auto;font-size:12px;width:auto;padding:2px 8px;display:inline-flex;align-items:center;gap:4px" onclick="downloadFile(_previewCurrentPath)" title="Download file to your computer"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Download</button>
|
||||
<button id="btnEditFile" class="panel-icon-btn" style="font-size:12px;width:auto;padding:2px 8px;display:none;align-items:center;gap:4px" onclick="toggleEditMode()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> Edit</button>
|
||||
</div>
|
||||
<pre class="preview-code" id="previewCode"></pre>
|
||||
<div class="preview-img-wrap" id="previewImgWrap" style="display:none"><img class="preview-img" id="previewImg" src="" alt=""></div>
|
||||
<div class="preview-md" id="previewMd" style="display:none"></div>
|
||||
<div class="preview-html-wrap" id="previewHtmlWrap" style="display:none;flex:1;border-radius:8px;overflow:hidden;border:1px solid var(--border2)">
|
||||
<iframe id="previewHtmlIframe" style="width:100%;height:100%;border:none;background:#fff" sandbox="allow-scripts" title="HTML preview"></iframe>
|
||||
</div>
|
||||
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:var(--pre-text);border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
+30
-10
@@ -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 <select> with <optgroup> per category. */
|
||||
function _renderProviderSelectOptions(selectedId){
|
||||
const providers=_getOnboardingSetupProviders();
|
||||
const categories=_getOnboardingSetupCategories();
|
||||
const provMap={};
|
||||
providers.forEach(p=>{provMap[p.id]=p;});
|
||||
if(!categories.length){
|
||||
// Fallback: flat list when no categories are available.
|
||||
return providers.map(p=>`<option value="${esc(p.id)}">${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`).join('');
|
||||
}
|
||||
return categories.map(cat=>{
|
||||
const opts=cat.providers.map(pid=>{
|
||||
const p=provMap[pid];
|
||||
if(!p)return '';
|
||||
return `<option value="${esc(p.id)}"${p.id===selectedId?' selected':''}>${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`;
|
||||
}).join('');
|
||||
return `<optgroup label="${esc(t('provider_category_'+cat.id)||cat.label)}">${opts}</optgroup>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _getOnboardingCurrentSetup(){
|
||||
return (((ONBOARDING.status||{}).setup||{}).current)||{};
|
||||
}
|
||||
@@ -107,9 +131,9 @@ function _renderOnboardingBody(){
|
||||
}
|
||||
|
||||
if(key==='setup'){
|
||||
const providers=_getOnboardingSetupProviders();
|
||||
const options=providers.map(p=>`<option value="${esc(p.id)}">${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`).join('');
|
||||
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider)||providers[0]||null;
|
||||
const selectedId=ONBOARDING.form.provider;
|
||||
const groupedOptions=_renderProviderSelectOptions(selectedId);
|
||||
const provider=_getOnboardingSetupProvider(selectedId)||_getOnboardingSetupProviders()[0]||null;
|
||||
const showBaseUrl=provider&&provider.requires_base_url;
|
||||
const keyHelp=provider?`${t('onboarding_api_key_help_prefix')} ${esc(provider.env_var)}.`:'';
|
||||
|
||||
@@ -132,7 +156,7 @@ function _renderOnboardingBody(){
|
||||
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_provider_label')}</span>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${groupedOptions}</select>
|
||||
</label>
|
||||
<label class="onboarding-field" id="onboardingApiKeyField">
|
||||
<span>${t('onboarding_api_key_label')}</span>
|
||||
@@ -153,7 +177,7 @@ function _renderOnboardingBody(){
|
||||
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_provider_label')}</span>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${groupedOptions}</select>
|
||||
</label>
|
||||
<label class="onboarding-field" id="onboardingApiKeyField">
|
||||
<span>${t('onboarding_api_key_label')}</span>
|
||||
@@ -162,8 +186,6 @@ function _renderOnboardingBody(){
|
||||
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
|
||||
<p class="onboarding-copy">${keyHelp}</p>`;
|
||||
}
|
||||
const providerSel=$('onboardingProviderSelect');
|
||||
if(providerSel) providerSel.value=ONBOARDING.form.provider;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -171,7 +193,7 @@ function _renderOnboardingBody(){
|
||||
body.innerHTML=`
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_provider_label')}</span>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${groupedOptions}</select>
|
||||
</label>
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_api_key_label')}</span>
|
||||
@@ -181,8 +203,6 @@ function _renderOnboardingBody(){
|
||||
<p class="onboarding-copy">${keyHelp}</p>
|
||||
${showBaseUrl?`<p class="onboarding-copy">${t('onboarding_base_url_help')}</p>`:''}
|
||||
<p class="onboarding-copy">${esc(setup.unsupported_note||'')||''}</p>`;
|
||||
const providerSel=$('onboardingProviderSelect');
|
||||
if(providerSel) providerSel.value=ONBOARDING.form.provider;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2246,6 +2246,22 @@ async function loadSettingsPanel(){
|
||||
const fontSizeSel=$('settingsFontSize');
|
||||
if(fontSizeSel) fontSizeSel.value=fontSizeVal;
|
||||
if(typeof _syncFontSizePicker==='function') _syncFontSizePicker(fontSizeVal);
|
||||
// Workspace panel default-open toggle (localStorage-backed)
|
||||
// Uses a separate key (hermes-webui-workspace-panel-pref) so that
|
||||
// closing the panel via toolbar X does not clear the user's preference.
|
||||
const wsPanelCb=$('settingsWorkspacePanelOpen');
|
||||
if(wsPanelCb){
|
||||
wsPanelCb.checked=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open';
|
||||
wsPanelCb.onchange=function(){
|
||||
const open=this.checked;
|
||||
localStorage.setItem('hermes-webui-workspace-panel-pref',open?'open':'closed');
|
||||
// Also sync the runtime key so the current session reflects the change
|
||||
localStorage.setItem('hermes-webui-workspace-panel',open?'open':'closed');
|
||||
document.documentElement.dataset.workspacePanel=open?'open':'closed';
|
||||
if(open&&_workspacePanelMode==='closed') openWorkspacePanel('browse');
|
||||
else if(!open&&_workspacePanelMode!=='closed') toggleWorkspacePanel(false);
|
||||
};
|
||||
}
|
||||
const resolvedLanguage=(typeof resolvePreferredLocale==='function')
|
||||
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'))
|
||||
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
|
||||
@@ -2555,6 +2571,52 @@ function _applySavedSettingsUi(saved, body, opts){
|
||||
if(typeof renderSessionList==='function') renderSessionList();
|
||||
}
|
||||
|
||||
async function checkUpdatesNow(){
|
||||
const btn=$('btnCheckUpdatesNow');
|
||||
const label=$('checkUpdatesLabel');
|
||||
const spinner=$('checkUpdatesSpinner');
|
||||
const status=$('checkUpdatesStatus');
|
||||
if(!btn||!label) return;
|
||||
// Disable button, show spinner
|
||||
btn.disabled=true;
|
||||
if(spinner) spinner.style.display='';
|
||||
if(label) label.textContent=t('settings_checking');
|
||||
if(status) status.textContent='';
|
||||
try {
|
||||
const data=await api('/api/updates/check?force=1');
|
||||
if(data.disabled){
|
||||
if(status){status.textContent=t('settings_updates_disabled');status.style.color='var(--muted)';}
|
||||
} else {
|
||||
const parts=[];
|
||||
if(data.webui&&data.webui.behind>0) parts.push('WebUI: '+data.webui.behind);
|
||||
if(data.agent&&data.agent.behind>0) parts.push('Agent: '+data.agent.behind);
|
||||
if(parts.length){
|
||||
if(status){status.textContent=t('settings_updates_available').replace('{count}',parts.join(', '));status.style.color='var(--accent)';}
|
||||
// Also trigger the update banner
|
||||
if(typeof _showUpdateBanner==='function') _showUpdateBanner(data);
|
||||
} else {
|
||||
if(status){status.textContent=t('settings_up_to_date');status.style.color='var(--success)';}
|
||||
}
|
||||
}
|
||||
} catch(e){
|
||||
// Never expose raw e.message in UI — log to console for debugging only
|
||||
console.warn('[checkUpdatesNow]', e);
|
||||
// Show a generic user-facing error; if the API returned a message body use it
|
||||
let userMsg=t('settings_update_check_failed');
|
||||
if(e&&e.response){
|
||||
try{
|
||||
const body=JSON.parse(e.response);
|
||||
if(body.error) userMsg=String(body.error).substring(0,120);
|
||||
}catch(_){}
|
||||
}
|
||||
if(status){status.textContent=userMsg;status.style.color='var(--error)';}
|
||||
} finally {
|
||||
btn.disabled=false;
|
||||
if(spinner) spinner.style.display='none';
|
||||
if(label) label.textContent=t('settings_check_now');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings(andClose){
|
||||
const model=($('settingsModel')||{}).value;
|
||||
const modelChanged=(model||'')!==(_settingsHermesDefaultModelOnOpen||'');
|
||||
|
||||
@@ -1593,6 +1593,12 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
|
||||
.settings-section-title{font-size:18px;font-weight:600;letter-spacing:-.01em;color:var(--text);line-height:1.3;margin-bottom:4px;}
|
||||
.settings-section-meta{font-size:13px;color:var(--muted);line-height:1.55;}
|
||||
.settings-version-badge{display:inline-flex;align-items:center;padding:3px 8px;border-radius:999px;background:var(--surface);color:var(--muted);font-size:11px;font-weight:600;font-family:'SF Mono',ui-monospace,SFMono-Regular,Menlo,monospace;flex-shrink:0;align-self:flex-start;letter-spacing:.02em;}
|
||||
#checkUpdatesBlock{display:inline-flex;align-items:center;gap:6px;font-size:12px;flex-shrink:0;align-self:flex-start;}
|
||||
#checkUpdatesBlock .btn-tiny{padding:4px 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:11px;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:5px;transition:border-color .15s,color .15s;}
|
||||
#checkUpdatesBlock .btn-tiny:hover{border-color:var(--accent);color:var(--accent);}
|
||||
#checkUpdatesBlock .btn-tiny:disabled{opacity:.5;cursor:default;}
|
||||
#checkUpdatesBlock .btn-tiny .spinner-xs{width:12px;height:12px;border:2px solid var(--border);border-top-color:var(--text);border-radius:50%;animation:spin .6s linear infinite;display:none;}
|
||||
#checkUpdatesStatus{font-size:11px;font-weight:500;white-space:nowrap;}
|
||||
|
||||
/* Each logical form row is a card surface. Stack with comfortable gap. */
|
||||
#mainSettings .settings-field{margin-bottom:12px;padding:16px;border:1px solid var(--border);border-radius:12px;background:var(--sidebar);}
|
||||
|
||||
+30
-3
@@ -97,6 +97,7 @@ function navigateUp(){
|
||||
// File extension sets for preview routing (must match server-side sets)
|
||||
const IMAGE_EXTS = new Set(['.png','.jpg','.jpeg','.gif','.svg','.webp','.ico','.bmp']);
|
||||
const MD_EXTS = new Set(['.md','.markdown','.mdown']);
|
||||
const HTML_EXTS = new Set(['.html','.htm']);
|
||||
// Binary formats that should download rather than preview
|
||||
const DOWNLOAD_EXTS = new Set([
|
||||
'.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp',
|
||||
@@ -110,21 +111,25 @@ const DOWNLOAD_EXTS = new Set([
|
||||
function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; }
|
||||
|
||||
let _previewCurrentPath = ''; // relative path of currently previewed file
|
||||
let _previewCurrentMode = ''; // 'code' | 'md' | 'image'
|
||||
let _previewCurrentMode = ''; // 'code' | 'md' | 'image' | 'html'
|
||||
let _previewDirty = false; // true when edits are unsaved
|
||||
|
||||
function showPreview(mode){
|
||||
// mode: 'code' | 'image' | 'md'
|
||||
// mode: 'code' | 'image' | 'md' | 'html'
|
||||
$('previewCode').style.display = mode==='code' ? '' : 'none';
|
||||
$('previewImgWrap').style.display = mode==='image' ? '' : 'none';
|
||||
$('previewMd').style.display = mode==='md' ? '' : 'none';
|
||||
$('previewHtmlWrap').style.display = mode==='html' ? '' : 'none';
|
||||
$('previewEditArea').style.display = 'none'; // start in read-only
|
||||
const badge=$('previewBadge');
|
||||
badge.className='preview-badge '+mode;
|
||||
badge.textContent = mode==='image'?'image':mode==='md'?'md':fileExt($('previewPathText').textContent)||'text';
|
||||
badge.textContent = mode==='image'?'image':mode==='md'?'md':mode==='html'?'html':fileExt($('previewPathText').textContent)||'text';
|
||||
_previewCurrentMode = mode;
|
||||
_previewDirty = false;
|
||||
updateEditBtn();
|
||||
// Show "Open in browser" button only for HTML mode
|
||||
const openBtn=$('btnOpenInBrowser');
|
||||
if(openBtn) openBtn.style.display = mode==='html'?'inline-flex':'none';
|
||||
}
|
||||
|
||||
function updateEditBtn(){
|
||||
@@ -219,6 +224,22 @@ async function openFile(path){
|
||||
$('previewMd').innerHTML=renderMd(data.content);
|
||||
requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();});
|
||||
}catch(e){setStatus(t('file_open_failed'));}
|
||||
} else if(HTML_EXTS.has(ext)){
|
||||
// HTML: render in sandboxed iframe via raw endpoint.
|
||||
// SECURITY TRADEOFF: We use sandbox="allow-scripts" which lets inline JS run
|
||||
// but prevents access to the parent frame (origin isolation). This is a
|
||||
// deliberate choice — the user is previewing their own workspace files, so
|
||||
// blocking scripts entirely would break most HTML documents. The sandbox
|
||||
// still prevents the preview from navigating the parent, accessing cookies,
|
||||
// or reading other origin data. If a stricter mode is needed, remove
|
||||
// allow-scripts (or add sandbox="") to disable all JS execution.
|
||||
showPreview('html');
|
||||
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1`;
|
||||
const iframe=$('previewHtmlIframe');
|
||||
if(iframe){
|
||||
iframe.src=''; // clear first to avoid stale content
|
||||
iframe.src=url;
|
||||
}
|
||||
} else {
|
||||
// Plain code / text -- but fall back to download if server signals binary
|
||||
try{
|
||||
@@ -287,3 +308,9 @@ function renderFileBreadcrumb(filePath) {
|
||||
bar.appendChild(seg);
|
||||
}
|
||||
}
|
||||
|
||||
function openInBrowser(){
|
||||
if(!_previewCurrentPath||!S.session) return;
|
||||
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(_previewCurrentPath)}`;
|
||||
window.open(url,'_blank');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for inline HTML preview in workspace panel (issue #779)."""
|
||||
import pytest
|
||||
|
||||
|
||||
def _get_routes_content():
|
||||
return open("api/routes.py", encoding="utf-8").read()
|
||||
|
||||
|
||||
def _get_workspace_js():
|
||||
return open("static/workspace.js", encoding="utf-8").read()
|
||||
|
||||
|
||||
def _get_index_html():
|
||||
return open("static/index.html", encoding="utf-8").read()
|
||||
|
||||
|
||||
def test_inline_preview_param_in_file_raw():
|
||||
"""?inline=1 must bypass Content-Disposition: attachment for text/html."""
|
||||
content = _get_routes_content()
|
||||
assert "inline_preview" in content, (
|
||||
"_handle_file_raw must read the inline query parameter"
|
||||
)
|
||||
assert "html_inline_ok" in content, (
|
||||
"_handle_file_raw must allow HTML inline when inline_preview=True"
|
||||
)
|
||||
|
||||
|
||||
def test_iframe_uses_inline_param():
|
||||
"""workspace.js must pass &inline=1 when setting the preview iframe src."""
|
||||
content = _get_workspace_js()
|
||||
assert "inline=1" in content, (
|
||||
"workspace.js must pass ?inline=1 to api/file/raw for the HTML preview iframe"
|
||||
)
|
||||
|
||||
|
||||
def test_html_preview_iframe_exists_in_html():
|
||||
"""The previewHtmlIframe element must be present in index.html."""
|
||||
content = _get_index_html()
|
||||
assert "previewHtmlIframe" in content, (
|
||||
"index.html must contain the previewHtmlIframe element"
|
||||
)
|
||||
|
||||
|
||||
def test_html_exts_defined_in_workspace_js():
|
||||
"""HTML_EXTS set must include .html and .htm."""
|
||||
content = _get_workspace_js()
|
||||
assert "HTML_EXTS" in content, "workspace.js must define HTML_EXTS"
|
||||
assert "'.html'" in content or '".html"' in content, "HTML_EXTS must include .html"
|
||||
assert "'.htm'" in content or '".htm"' in content, "HTML_EXTS must include .htm"
|
||||
|
||||
|
||||
def test_sandbox_allows_scripts_only():
|
||||
"""iframe sandbox must not include allow-same-origin (XSS risk)."""
|
||||
content = _get_index_html()
|
||||
# Find the sandbox attribute value
|
||||
import re
|
||||
sandboxes = re.findall(r'sandbox="([^"]*)"', content)
|
||||
preview_sandboxes = [s for s in sandboxes if "allow" in s]
|
||||
for sb in preview_sandboxes:
|
||||
assert "allow-same-origin" not in sb, (
|
||||
"HTML preview iframe must not have allow-same-origin (would expose parent cookies)"
|
||||
)
|
||||
|
||||
|
||||
def test_inline_html_response_sets_csp_sandbox():
|
||||
"""Defense-in-depth: ?inline=1 HTML responses must set Content-Security-Policy:
|
||||
sandbox so the same origin isolation applies even when the URL is opened
|
||||
directly in a top-level tab (not just inside the workspace panel iframe).
|
||||
|
||||
Without this, a user tricked into clicking a chat link like
|
||||
/api/file/raw?path=evil.html&inline=1 would render the HTML in the WebUI's
|
||||
origin without any sandbox, giving the page full access to cookies and
|
||||
localStorage. The CSP sandbox directive (no allow-same-origin) downgrades
|
||||
the document to a unique opaque origin server-side.
|
||||
"""
|
||||
content = _get_routes_content()
|
||||
# Find the html_inline_ok block in _handle_file_raw
|
||||
idx = content.find("html_inline_ok")
|
||||
assert idx != -1, "html_inline_ok block not found"
|
||||
block = content[idx:idx + 2500]
|
||||
assert "Content-Security-Policy" in block, (
|
||||
"_handle_file_raw must set Content-Security-Policy header on inline HTML responses"
|
||||
)
|
||||
assert "sandbox" in block, (
|
||||
"CSP must include the sandbox directive"
|
||||
)
|
||||
# Must NOT have allow-same-origin in the sandbox directive
|
||||
csp_sections = [line for line in block.splitlines() if "sandbox" in line and "Policy" in line]
|
||||
for line in csp_sections:
|
||||
# The line setting the CSP header — make sure it doesn't grant same-origin
|
||||
if "send_header" in line:
|
||||
assert "allow-same-origin" not in line, (
|
||||
"CSP sandbox must NOT include allow-same-origin — that would defeat the isolation"
|
||||
)
|
||||
@@ -104,16 +104,20 @@ def test_user_bubble_selection_is_scoped_to_user_message_body():
|
||||
def test_576_panel_restore_gated_on_workspace():
|
||||
"""boot.js: localStorage panel restore must be gated on session.workspace."""
|
||||
# The guard must appear: session.workspace check before _workspacePanelMode='browse'
|
||||
assert "S.session&&S.session.workspace&&localStorage.getItem('hermes-webui-workspace-panel')" in BOOT_JS, (
|
||||
# Panel pref key takes priority over runtime key (toolbar close must not clear preference)
|
||||
assert "S.session&&S.session.workspace&&panelPref" in BOOT_JS, (
|
||||
"Workspace panel localStorage restore must be gated on S.session.workspace "
|
||||
"to prevent snap-open-then-closed on sessions without a workspace (#576)"
|
||||
)
|
||||
assert "'hermes-webui-workspace-panel-pref'" in BOOT_JS, (
|
||||
"Panel restore must check the preference key so toolbar close does not clear it"
|
||||
)
|
||||
|
||||
|
||||
def test_576_restore_happens_after_load_session():
|
||||
"""boot.js: loadSession() must come before the panel restore guard."""
|
||||
load_pos = BOOT_JS.find("await loadSession(saved)")
|
||||
restore_pos = BOOT_JS.find("S.session&&S.session.workspace&&localStorage")
|
||||
restore_pos = BOOT_JS.find("panelPref")
|
||||
assert load_pos != -1, "loadSession call not found in boot.js"
|
||||
assert restore_pos != -1, "workspace panel restore guard not found"
|
||||
assert load_pos < restore_pos, (
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
"""Tests for #603 — categorize providers in setup wizard.
|
||||
|
||||
Validates:
|
||||
- New providers added to _SUPPORTED_PROVIDER_SETUPS with correct categories
|
||||
- _PROVIDER_CATEGORIES ordering and IDs
|
||||
- _build_setup_catalog returns grouped categories
|
||||
- apply_onboarding_setup writes base_url for requires_base_url providers
|
||||
- apply_onboarding_setup writes default_base_url for providers with one
|
||||
- Frontend helper _renderProviderSelectOptions produces <optgroup>
|
||||
- i18n keys exist for all category labels
|
||||
- Fallback when categories are empty
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys, os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from api.onboarding import (
|
||||
_SUPPORTED_PROVIDER_SETUPS,
|
||||
_PROVIDER_CATEGORIES,
|
||||
_build_setup_catalog,
|
||||
apply_onboarding_setup,
|
||||
)
|
||||
|
||||
|
||||
# ── Backend: provider catalog structure ──────────────────────────────────
|
||||
|
||||
class TestProviderCatalog:
|
||||
"""Verify the extended provider catalog has categories."""
|
||||
|
||||
def test_all_providers_have_category(self):
|
||||
for pid, meta in _SUPPORTED_PROVIDER_SETUPS.items():
|
||||
assert "category" in meta, f"Provider {pid} missing 'category'"
|
||||
|
||||
def test_categories_are_valid(self):
|
||||
valid_ids = {c["id"] for c in _PROVIDER_CATEGORIES}
|
||||
for pid, meta in _SUPPORTED_PROVIDER_SETUPS.items():
|
||||
cat = meta["category"]
|
||||
assert cat in valid_ids, f"Provider {pid} has invalid category '{cat}'"
|
||||
|
||||
def test_easy_start_has_core_providers(self):
|
||||
easy = {
|
||||
pid
|
||||
for pid, meta in _SUPPORTED_PROVIDER_SETUPS.items()
|
||||
if meta["category"] == "easy_start"
|
||||
}
|
||||
assert "openrouter" in easy
|
||||
assert "anthropic" in easy
|
||||
assert "openai" in easy
|
||||
|
||||
def test_self_hosted_has_local_providers(self):
|
||||
local = {
|
||||
pid
|
||||
for pid, meta in _SUPPORTED_PROVIDER_SETUPS.items()
|
||||
if meta["category"] == "self_hosted"
|
||||
}
|
||||
assert "ollama" in local
|
||||
assert "lmstudio" in local
|
||||
assert "custom" in local
|
||||
|
||||
def test_specialized_has_extended_providers(self):
|
||||
spec = {
|
||||
pid
|
||||
for pid, meta in _SUPPORTED_PROVIDER_SETUPS.items()
|
||||
if meta["category"] == "specialized"
|
||||
}
|
||||
assert "gemini" in spec
|
||||
assert "deepseek" in spec
|
||||
assert "mistralai" in spec
|
||||
assert "x-ai" in spec
|
||||
|
||||
def test_new_providers_exist(self):
|
||||
expected = {"ollama", "lmstudio", "gemini", "deepseek", "mistralai", "x-ai"}
|
||||
assert expected.issubset(_SUPPORTED_PROVIDER_SETUPS.keys())
|
||||
|
||||
def test_new_providers_have_env_vars(self):
|
||||
for pid in ["ollama", "lmstudio", "gemini", "deepseek", "mistralai", "x-ai"]:
|
||||
meta = _SUPPORTED_PROVIDER_SETUPS[pid]
|
||||
assert meta["env_var"], f"Provider {pid} missing env_var"
|
||||
assert meta["default_model"], f"Provider {pid} missing default_model"
|
||||
|
||||
def test_local_providers_require_base_url(self):
|
||||
for pid in ["ollama", "lmstudio", "custom"]:
|
||||
assert _SUPPORTED_PROVIDER_SETUPS[pid]["requires_base_url"]
|
||||
|
||||
def test_specialized_providers_have_base_url_defaults(self):
|
||||
for pid in ["gemini", "deepseek", "mistralai", "x-ai"]:
|
||||
meta = _SUPPORTED_PROVIDER_SETUPS[pid]
|
||||
assert meta["default_base_url"], f"Provider {pid} missing default_base_url"
|
||||
|
||||
def test_google_uses_gemini_key(self):
|
||||
"""Google Gemini must use 'gemini' as provider ID (matches Hermes CLI)."""
|
||||
assert "gemini" in _SUPPORTED_PROVIDER_SETUPS
|
||||
assert "google" not in _SUPPORTED_PROVIDER_SETUPS
|
||||
|
||||
def test_gemini_model_list_is_populated(self):
|
||||
"""The gemini provider's `models` list must be non-empty.
|
||||
|
||||
Regression: api/config.py:_PROVIDER_MODELS uses key "google" (not
|
||||
"gemini") for the model catalog. If the wizard does
|
||||
_PROVIDER_MODELS.get("gemini", []) it gets an empty list and the
|
||||
provider dropdown has no model options. The provider catalog must
|
||||
look up the right key.
|
||||
"""
|
||||
gemini = _SUPPORTED_PROVIDER_SETUPS["gemini"]
|
||||
assert len(gemini["models"]) > 0, (
|
||||
"gemini provider must surface a non-empty model list — check the "
|
||||
"_PROVIDER_MODELS lookup key (catalog uses 'google', not 'gemini')"
|
||||
)
|
||||
|
||||
def test_specialized_default_models_match_catalog(self):
|
||||
"""default_model values for specialized providers must reference real
|
||||
models in the agent's catalog (or be the latest known version).
|
||||
|
||||
Regression: previously had `gemini-2.5-pro-preview` (agent catalog has
|
||||
3.1) and `grok-3` (agent catalog has 4.20). Stale defaults landed users
|
||||
on non-existent models that produced 404s on first chat.
|
||||
"""
|
||||
gemini_default = _SUPPORTED_PROVIDER_SETUPS["gemini"]["default_model"]
|
||||
assert gemini_default.startswith("gemini-3."), (
|
||||
f"gemini default_model={gemini_default!r} is stale — agent catalog has 3.1 family"
|
||||
)
|
||||
xai_default = _SUPPORTED_PROVIDER_SETUPS["x-ai"]["default_model"]
|
||||
assert xai_default.startswith("grok-4"), (
|
||||
f"x-ai default_model={xai_default!r} is stale — agent catalog has 4.20 family"
|
||||
)
|
||||
deepseek_default = _SUPPORTED_PROVIDER_SETUPS["deepseek"]["default_model"]
|
||||
# deepseek-chat (rolling) or deepseek-chat-v3-0324 (pinned) both valid
|
||||
assert deepseek_default.startswith("deepseek-"), (
|
||||
f"deepseek default_model={deepseek_default!r} must start with 'deepseek-'"
|
||||
)
|
||||
|
||||
|
||||
class TestProviderCategoryOrder:
|
||||
"""Verify category ordering."""
|
||||
|
||||
def test_categories_sorted_by_order(self):
|
||||
orders = [c["order"] for c in _PROVIDER_CATEGORIES]
|
||||
assert orders == sorted(orders)
|
||||
|
||||
def test_category_ids(self):
|
||||
ids = {c["id"] for c in _PROVIDER_CATEGORIES}
|
||||
assert ids == {"easy_start", "self_hosted", "specialized"}
|
||||
|
||||
def test_three_categories(self):
|
||||
assert len(_PROVIDER_CATEGORIES) == 3
|
||||
|
||||
|
||||
# ── Backend: _build_setup_catalog ────────────────────────────────────────
|
||||
|
||||
class TestBuildSetupCatalog:
|
||||
def test_catalog_has_categories_key(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
assert "categories" in catalog
|
||||
assert isinstance(catalog["categories"], list)
|
||||
|
||||
def test_catalog_categories_have_providers_list(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
all_provider_ids = {p["id"] for p in catalog["providers"]}
|
||||
for cat in catalog["categories"]:
|
||||
assert "providers" in cat
|
||||
for pid in cat["providers"]:
|
||||
assert pid in all_provider_ids
|
||||
|
||||
def test_catalog_providers_have_category_field(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
for p in catalog["providers"]:
|
||||
assert "category" in p
|
||||
|
||||
def test_catalog_providers_sorted_by_category(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
cat_order = {c["id"]: c["order"] for c in _PROVIDER_CATEGORIES}
|
||||
prev_order = -1
|
||||
for p in catalog["providers"]:
|
||||
order = cat_order.get(p["category"], 99)
|
||||
assert order >= prev_order, f"Provider {p['id']} out of order"
|
||||
prev_order = order
|
||||
|
||||
def test_catalog_quick_flag_on_openrouter(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
orow = next(p for p in catalog["providers"] if p["id"] == "openrouter")
|
||||
assert orow["quick"] is True
|
||||
|
||||
def test_catalog_no_quick_flag_on_others(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
for p in catalog["providers"]:
|
||||
if p["id"] != "openrouter":
|
||||
assert p["quick"] is False
|
||||
|
||||
|
||||
# ── Backend: apply_onboarding_setup base_url handling ───────────────────
|
||||
|
||||
class TestApplyBaseURL:
|
||||
"""Verify the generic base_url save logic."""
|
||||
|
||||
def test_requires_base_url_writes_user_url(self, tmp_path, monkeypatch):
|
||||
"""Providers with requires_base_url=True should write user-provided base_url."""
|
||||
config_path = str(tmp_path / "config.yaml")
|
||||
env_path = str(tmp_path / ".env")
|
||||
|
||||
monkeypatch.setattr("api.onboarding._get_config_path", lambda: config_path)
|
||||
monkeypatch.setattr("api.onboarding._get_active_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr("api.onboarding._load_yaml_config", lambda p: {})
|
||||
monkeypatch.setattr(
|
||||
"api.onboarding._normalize_model_for_provider", lambda prov, m: m
|
||||
)
|
||||
monkeypatch.setattr("api.onboarding._write_env_file", lambda p, d: None)
|
||||
monkeypatch.setattr("api.onboarding._save_yaml_config", lambda p, c: None)
|
||||
monkeypatch.setattr("api.onboarding._provider_api_key_present", lambda *a: True)
|
||||
monkeypatch.setattr("api.onboarding.reload_config", lambda: None)
|
||||
|
||||
saved_cfg = {}
|
||||
def mock_save(p, cfg):
|
||||
saved_cfg.update(cfg)
|
||||
monkeypatch.setattr("api.onboarding._save_yaml_config", mock_save)
|
||||
|
||||
apply_onboarding_setup({
|
||||
"provider": "ollama",
|
||||
"model": "qwen3:32b",
|
||||
"api_key": "test-key",
|
||||
"base_url": "http://my-ollama:11434/v1",
|
||||
"confirm_overwrite": True,
|
||||
})
|
||||
|
||||
assert saved_cfg["model"]["base_url"] == "http://my-ollama:11434/v1"
|
||||
|
||||
def test_default_base_url_written_for_openai(self, tmp_path, monkeypatch):
|
||||
"""OpenAI should get its default_base_url written to config."""
|
||||
config_path = str(tmp_path / "config.yaml")
|
||||
|
||||
monkeypatch.setattr("api.onboarding._get_config_path", lambda: config_path)
|
||||
monkeypatch.setattr("api.onboarding._get_active_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr("api.onboarding._load_yaml_config", lambda p: {})
|
||||
monkeypatch.setattr(
|
||||
"api.onboarding._normalize_model_for_provider", lambda prov, m: m
|
||||
)
|
||||
monkeypatch.setattr("api.onboarding._write_env_file", lambda p, d: None)
|
||||
monkeypatch.setattr("api.onboarding._provider_api_key_present", lambda *a: True)
|
||||
monkeypatch.setattr("api.onboarding.reload_config", lambda: None)
|
||||
|
||||
saved_cfg = {}
|
||||
def mock_save(p, cfg):
|
||||
saved_cfg.update(cfg)
|
||||
monkeypatch.setattr("api.onboarding._save_yaml_config", mock_save)
|
||||
|
||||
apply_onboarding_setup({
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o",
|
||||
"api_key": "test-key",
|
||||
"confirm_overwrite": True,
|
||||
})
|
||||
|
||||
assert saved_cfg["model"]["base_url"] == "https://api.openai.com/v1"
|
||||
|
||||
def test_base_url_stripped_for_anthropic(self, tmp_path, monkeypatch):
|
||||
"""Anthropic should NOT have base_url in config (Hermes knows the URL)."""
|
||||
config_path = str(tmp_path / "config.yaml")
|
||||
|
||||
monkeypatch.setattr("api.onboarding._get_config_path", lambda: config_path)
|
||||
monkeypatch.setattr("api.onboarding._get_active_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr("api.onboarding._load_yaml_config", lambda p: {})
|
||||
monkeypatch.setattr(
|
||||
"api.onboarding._normalize_model_for_provider", lambda prov, m: m
|
||||
)
|
||||
monkeypatch.setattr("api.onboarding._write_env_file", lambda p, d: None)
|
||||
monkeypatch.setattr("api.onboarding._provider_api_key_present", lambda *a: True)
|
||||
monkeypatch.setattr("api.onboarding.reload_config", lambda: None)
|
||||
|
||||
saved_cfg = {}
|
||||
def mock_save(p, cfg):
|
||||
saved_cfg.update(cfg)
|
||||
monkeypatch.setattr("api.onboarding._save_yaml_config", mock_save)
|
||||
|
||||
apply_onboarding_setup({
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4.6",
|
||||
"api_key": "test-key",
|
||||
"confirm_overwrite": True,
|
||||
})
|
||||
|
||||
assert "base_url" not in saved_cfg["model"]
|
||||
|
||||
|
||||
# ── Frontend: i18n keys ─────────────────────────────────────────────────
|
||||
|
||||
class TestI18nCategoryKeys:
|
||||
def test_en_has_all_category_keys(self):
|
||||
with open("static/i18n.js", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
for key in ["provider_category_easy_start", "provider_category_self_hosted", "provider_category_specialized"]:
|
||||
assert f"{key}:" in content, f"Missing i18n key: {key}"
|
||||
|
||||
def test_ru_has_all_category_keys(self):
|
||||
with open("static/i18n.js", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
# Just verify count of category keys (should appear 6+ times: once per locale block)
|
||||
assert content.count("provider_category_easy_start:") >= 4
|
||||
|
||||
def test_es_has_all_category_keys(self):
|
||||
with open("static/i18n.js", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
assert "Inicio rápido" in content # Spanish easy_start
|
||||
|
||||
def test_zh_has_all_category_keys(self):
|
||||
with open("static/i18n.js", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
assert "快速开始" in content # Chinese easy_start
|
||||
|
||||
def test_zh_hant_has_all_category_keys(self):
|
||||
with open("static/i18n.js", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
assert "\\u5feb\\u901f\\u958b\\u59cb" in content # zh-Hant easy_start
|
||||
|
||||
|
||||
class TestApplyBaseURLSpecialized:
|
||||
"""Verify apply_onboarding_setup sets base_url for specialized providers."""
|
||||
|
||||
_PROVIDER_DEFAULT_MODELS = {
|
||||
"gemini": "gemini-3.1-pro-preview",
|
||||
"deepseek": "deepseek-chat-v3-0324",
|
||||
"mistralai": "mistral-large-latest",
|
||||
"x-ai": "grok-4.20",
|
||||
}
|
||||
|
||||
def _run_setup(self, tmp_path, monkeypatch, provider):
|
||||
"""Run apply_onboarding_setup with the given provider and return saved_cfg."""
|
||||
config_path = str(tmp_path / "config.yaml")
|
||||
model = self._PROVIDER_DEFAULT_MODELS.get(provider, "test-model")
|
||||
|
||||
monkeypatch.setattr("api.onboarding._get_config_path", lambda: config_path)
|
||||
monkeypatch.setattr("api.onboarding._get_active_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr("api.onboarding._load_yaml_config", lambda p: {})
|
||||
monkeypatch.setattr("api.onboarding._normalize_model_for_provider", lambda prov, m: m)
|
||||
monkeypatch.setattr("api.onboarding._write_env_file", lambda p, d: None)
|
||||
monkeypatch.setattr("api.onboarding._provider_api_key_present", lambda *a: True)
|
||||
monkeypatch.setattr("api.onboarding.reload_config", lambda: None)
|
||||
|
||||
saved_cfg = {}
|
||||
def mock_save(p, cfg):
|
||||
saved_cfg.update(cfg)
|
||||
monkeypatch.setattr("api.onboarding._save_yaml_config", mock_save)
|
||||
|
||||
from api.onboarding import apply_onboarding_setup
|
||||
apply_onboarding_setup({"provider": provider, "model": model, "api_key": "test-key", "confirm_overwrite": True})
|
||||
return saved_cfg
|
||||
|
||||
def test_gemini_gets_default_base_url(self, tmp_path, monkeypatch):
|
||||
saved = self._run_setup(tmp_path, monkeypatch, "gemini")
|
||||
assert "generativelanguage.googleapis.com" in saved.get("model", {}).get("base_url", ""), (
|
||||
"gemini setup must write the Gemini base_url to config"
|
||||
)
|
||||
|
||||
def test_deepseek_gets_default_base_url(self, tmp_path, monkeypatch):
|
||||
saved = self._run_setup(tmp_path, monkeypatch, "deepseek")
|
||||
assert "deepseek.com" in saved.get("model", {}).get("base_url", ""), (
|
||||
"deepseek setup must write the DeepSeek base_url to config"
|
||||
)
|
||||
|
||||
def test_mistral_gets_default_base_url(self, tmp_path, monkeypatch):
|
||||
saved = self._run_setup(tmp_path, monkeypatch, "mistralai")
|
||||
assert "mistral.ai" in saved.get("model", {}).get("base_url", ""), (
|
||||
"mistral setup must write the Mistral base_url to config"
|
||||
)
|
||||
|
||||
def test_x_ai_gets_default_base_url(self, tmp_path, monkeypatch):
|
||||
saved = self._run_setup(tmp_path, monkeypatch, "x-ai")
|
||||
assert "x.ai" in saved.get("model", {}).get("base_url", ""), (
|
||||
"x-ai setup must write the xAI base_url to config"
|
||||
)
|
||||
+15
-1
@@ -18,15 +18,29 @@ def _isolate_models_cache():
|
||||
|
||||
|
||||
def _available_models_with_cfg(cfg_override):
|
||||
"""Helper: temporarily patch config.cfg, call get_available_models(), restore."""
|
||||
"""Helper: temporarily patch config.cfg, call get_available_models(), restore.
|
||||
|
||||
We also freeze _cfg_mtime to the *current* config file mtime so that
|
||||
get_available_models() does not call reload_config() from disk (which
|
||||
would overwrite the in-memory mock with the on-disk config.yaml).
|
||||
See #644 — this race exists in CI where config.yaml is present.
|
||||
"""
|
||||
old_cfg = dict(_cfg.cfg)
|
||||
_cfg.cfg.clear()
|
||||
_cfg.cfg.update(cfg_override)
|
||||
# Freeze mtime so reload_config() is not triggered inside get_available_models()
|
||||
old_mtime = _cfg._cfg_mtime
|
||||
try:
|
||||
from pathlib import Path
|
||||
_cfg._cfg_mtime = Path(_cfg._get_config_path()).stat().st_mtime
|
||||
except OSError:
|
||||
_cfg._cfg_mtime = 0.0
|
||||
try:
|
||||
return _cfg.get_available_models()
|
||||
finally:
|
||||
_cfg.cfg.clear()
|
||||
_cfg.cfg.update(old_cfg)
|
||||
_cfg._cfg_mtime = old_mtime
|
||||
|
||||
|
||||
class TestConfigYamlModelsLoading:
|
||||
|
||||
@@ -471,3 +471,42 @@ class TestForceButtonResetOnRetry:
|
||||
assert "display='none'" in setup or "display = 'none'" in setup, (
|
||||
"applyUpdates setup must hide btnForceUpdate via display:none"
|
||||
)
|
||||
|
||||
|
||||
# ── #785: Manual 'Check for Updates' button ───────────────────────────────────
|
||||
|
||||
class TestCheckForUpdatesButton:
|
||||
"""#785: Ensure the 'Check for Updates' button is wired up correctly."""
|
||||
|
||||
def test_checkUpdatesNow_defined_in_panels(self):
|
||||
"""checkUpdatesNow() function must exist in panels.js."""
|
||||
src = read('static/panels.js')
|
||||
assert 'function checkUpdatesNow' in src or 'async function checkUpdatesNow' in src, (
|
||||
"checkUpdatesNow() not found in panels.js"
|
||||
)
|
||||
|
||||
def test_btnCheckUpdatesNow_in_html(self):
|
||||
"""Button element with id='btnCheckUpdatesNow' must exist in index.html."""
|
||||
src = read('static/index.html')
|
||||
assert 'id="btnCheckUpdatesNow"' in src, (
|
||||
"btnCheckUpdatesNow element not found in index.html"
|
||||
)
|
||||
|
||||
def test_checkUpdatesBlock_css_exists(self):
|
||||
"""CSS rules for #checkUpdatesBlock and .btn-tiny must exist in style.css."""
|
||||
src = read('static/style.css')
|
||||
assert '#checkUpdatesBlock' in src, (
|
||||
"#checkUpdatesBlock CSS selector not found in style.css"
|
||||
)
|
||||
assert '.btn-tiny' in src, (
|
||||
".btn-tiny CSS selector not found in style.css"
|
||||
)
|
||||
|
||||
def test_check_now_i18n_key_exists(self):
|
||||
"""settings_check_now i18n key must exist in all locale blocks."""
|
||||
src = read('static/i18n.js')
|
||||
count = src.count('settings_check_now')
|
||||
assert count >= 5, (
|
||||
f"settings_check_now found in only {count} locale blocks (expected ≥5: en, ru, es, zh, zh-Hant)"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user