Merge branch 'nesquena:master' into conversation_switching_perf2

This commit is contained in:
Josh Jameson
2026-04-26 01:52:00 +01:00
committed by GitHub
34 changed files with 2255 additions and 129 deletions
+36
View File
@@ -2,7 +2,43 @@
## [Unreleased]
## v0.50.211 — 2026-04-25
### Changed
- **Compact sidebar timestamps** — session timestamps in the left sidebar now show short labels (`1m`, `6m`, `1h`, `1d`, `1w`) instead of verbose strings like "6 minutes ago". Keeps all existing i18n paths; bucket headers (Today, Yesterday, This week) unchanged. (`static/sessions.js`, `static/i18n.js`) [#1057 @pavolbiely]
### Added
- **Adaptive session title refresh** — new opt-in setting (`Settings → Preferences → Adaptive title refresh`) re-generates the session title from the latest exchange every N turns (5, 10, or 20). Off by default. Runs in a daemon thread after stream end, never blocks the stream. Manual title renames are preserved (double-checked before and after LLM call). (`api/streaming.py`, `api/config.py`, `static/panels.js`, `static/i18n.js`, `static/index.html`) [#1058 @bergeouss]
### Fixed
- **Settings picker active state** — theme, skin, and font-size picker cards in Settings → Appearance now correctly highlight the selected option. Root cause: the base CSS rule used `!important` on `border-color`, overriding the inline style set by `_syncThemePicker()` and siblings. Fix moves to an `.active` class with its own `!important` rule. (`static/style.css`, `static/boot.js`) [#1059]
## v0.50.210 — 2026-04-25
### Added
- **gpt-5.5 and gpt-5.5-mini in model picker** — available for openai, openai-codex, and copilot providers. (`api/config.py`) [#1052 @aliceisjustplaying]
- **Login redirects back to original URL after re-login** — the iOS PWA auth redirect now passes `?next=` with the current path; `login.js` honors it via a `_safeNextPath()` helper that guards against open-redirect (rejects `//`, backslash, and non-path-absolute inputs). (`static/login.js`, `static/ui.js`, `static/workspace.js`) [#1053]
### Fixed
- **Non-standard provider first-run experience** — agent dir discovery now searches XDG_DATA_HOME, `/opt`, `/usr/local` paths; onboarding wizard auto-completes for non-wizard providers (ollama-cloud, deepseek, xai, kimi-k2.6) with `provider_configured=True`; wizard model field no longer hardcodes `gpt-5.4-mini` literal; session model resolver correctly handles unlisted active providers. (`api/config.py`, `api/onboarding.py`, `api/routes.py`) Closes #1019#1023 [#1049]
- **Cron session titles in sidebar** — cron-launched sessions now display the human-friendly job name (from `~/.hermes/cron/jobs.json`) instead of a generic "Cron Session" label. (`api/models.py`, `api/routes.py`) [#1050 @waldmanz]
- **AIAgent reused per session — fixes Honcho first-turn injection** — `AIAgent` is now cached per `session_id` so the agent's turn counter increments correctly across messages. Cache is evicted on session delete/clear. (`api/config.py`, `api/routes.py`, `api/streaming.py`) Closes #1039 [#1051 @qxxaa]
- **Mermaid Google Fonts CSP violation suppressed** — `fontFamily:'inherit'` in Mermaid themeVariables prevents `@import url('fonts.googleapis.com')` from being injected into diagram SVGs. (`static/ui.js`) Closes #1044 [#1054]
- **bfcache layout and dropdown restore** — `pageshow+event.persisted` handler re-syncs topbar, workspace panel, session list, and gateway SSE; also closes open composer dropdowns frozen by bfcache. `_initResizePanels()` removed from pageshow (bfcache preserves listeners). (`static/boot.js`) Closes #1045 [#1055]
## 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
+1 -1
View File
@@ -3,7 +3,7 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
> Everything you can do from the CLI terminal, you can do from this UI.
>
> Last updated: v0.50.185 (April 24, 2026) — 2107 tests collected
> Last updated: v0.50.211 (April 25, 2026) — 2276 tests collected
> Tests: 2107 collected (`pytest tests/ --collect-only -q`)
> Source: <repo>/
+1 -1
View File
@@ -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: 2276 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.
+29
View File
@@ -91,6 +91,14 @@ def _discover_agent_dir() -> Path:
# 6. ~/hermes-agent
candidates.append(HOME / "hermes-agent")
# 7. XDG_DATA_HOME / hermes-agent (e.g. ~/.local/share/hermes-agent)
xdg_data = Path(os.getenv("XDG_DATA_HOME", str(HOME / ".local" / "share")))
candidates.append(xdg_data.expanduser() / "hermes-agent")
# 8. System-wide install paths (e.g. /opt/hermes-agent, /usr/local/hermes-agent)
for sys_prefix in ("/opt", "/usr/local", "/usr/local/share"):
candidates.append(Path(sys_prefix) / "hermes-agent")
for path in candidates:
if path.exists() and (path / "run_agent.py").exists():
return path.resolve()
@@ -607,10 +615,14 @@ _PROVIDER_MODELS = {
{"id": "claude-haiku-4-5", "label": "Claude Haiku 4.5"},
],
"openai": [
{"id": "gpt-5.5", "label": "GPT-5.5"},
{"id": "gpt-5.5-mini", "label": "GPT-5.5 Mini"},
{"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"},
{"id": "gpt-5.4", "label": "GPT-5.4"},
],
"openai-codex": [
{"id": "gpt-5.5", "label": "GPT-5.5"},
{"id": "gpt-5.5-mini", "label": "GPT-5.5 Mini"},
{"id": "gpt-5.4", "label": "GPT-5.4"},
{"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"},
{"id": "gpt-5.3-codex", "label": "GPT-5.3 Codex"},
@@ -660,6 +672,8 @@ _PROVIDER_MODELS = {
],
# GitHub Copilot — model IDs served via the Copilot API
"copilot": [
{"id": "gpt-5.5", "label": "GPT-5.5"},
{"id": "gpt-5.5-mini", "label": "GPT-5.5 Mini"},
{"id": "gpt-5.4", "label": "GPT-5.4"},
{"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"},
{"id": "gpt-4o", "label": "GPT-4o"},
@@ -1738,6 +1752,18 @@ AGENT_INSTANCES: dict = {} # stream_id -> AIAgent instance for interrupt propag
STREAM_PARTIAL_TEXT: dict = {} # stream_id -> partial assistant text accumulated during streaming
SERVER_START_TIME = time.time()
# Agent cache: reuse AIAgent across messages in the same WebUI session so that
# _user_turn_count survives between turns. This mirrors the gateway's
# _agent_cache pattern and is required for injectionFrequency: "first-turn".
SESSION_AGENT_CACHE: dict = {} # session_id -> (AIAgent, config_sig)
SESSION_AGENT_CACHE_LOCK = threading.Lock()
def _evict_session_agent(session_id: str) -> None:
"""Remove a cached agent for a session (on delete, clear, or model switch)."""
with SESSION_AGENT_CACHE_LOCK:
SESSION_AGENT_CACHE.pop(session_id, None)
# ── Thread-local env context ─────────────────────────────────────────────────
_thread_ctx = threading.local()
@@ -1801,6 +1827,7 @@ _SETTINGS_DEFAULTS = {
"notifications_enabled": False, # browser notification when tab is in background
"show_thinking": True, # show/hide thinking/reasoning blocks in chat view
"sidebar_density": "compact", # compact | detailed
"auto_title_refresh_every": "0", # adaptive title refresh: 0=off, 5/10/20=every N exchanges
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
}
_SETTINGS_LEGACY_DROP_KEYS = {"assistant_language", "bubble_layout", "default_model"}
@@ -1901,6 +1928,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {
_SETTINGS_ENUM_VALUES = {
"send_key": {"enter", "ctrl+enter"},
"sidebar_density": {"compact", "detailed"},
"auto_title_refresh_every": {"0", "5", "10", "20"},
}
_SETTINGS_BOOL_KEYS = {
"onboarding_completed",
@@ -1969,6 +1997,7 @@ def save_settings(settings: dict) -> dict:
resolve_default_workspace(current.get("default_workspace"))
)
persisted = {k: v for k, v in current.items() if k != "default_model"}
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
SETTINGS_FILE.write_text(
json.dumps(persisted, ensure_ascii=False, indent=2),
encoding="utf-8",
+19 -1
View File
@@ -691,7 +691,25 @@ def get_cli_sessions() -> list:
profile = _cli_profile # CLI DB has no profile column; use active profile
_source = row['source'] or 'cli'
_display_title = row['title'] or f'{_source.title()} Session'
_title = row['title']
if not _title and _source == 'cron' and sid.startswith('cron_'):
# Extract job_id from session ID (cron_{job_id}_{timestamp})
# and look up the human-friendly job name from jobs.json
parts = sid.split('_')
if len(parts) >= 3:
_job_id = parts[1]
try:
_jobs_path = hermes_home / 'cron' / 'jobs.json'
if _jobs_path.exists():
import json as _json
_jobs_data = _json.loads(_jobs_path.read_text())
for _j in _jobs_data.get('jobs', []):
if _j.get('id') == _job_id:
_title = _j.get('name') or _title
break
except Exception:
pass # degrade gracefully
_display_title = _title or f'{_source.title()} Session'
cli_sessions.append({
'session_id': sid,
'title': _display_title,
+119 -10
View File
@@ -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": {
@@ -429,11 +518,33 @@ def get_onboarding_status() -> dict:
auto_completed = skip_requested # unconditional: operator says skip, we skip
# Auto-complete for existing Hermes users: if config.yaml already exists
# AND the system is chat_ready, treat onboarding as done. These users
# configured Hermes via the CLI before the Web UI existed; they must never
# be shown the first-run wizard — it would silently overwrite their config.
# AND the provider is configured (or the system is chat_ready), treat onboarding
# as done. These users configured Hermes via the CLI before the Web UI existed;
# they must never be shown the first-run wizard — it would silently overwrite their
# config. We use provider_configured (not chat_ready) so that users with
# non-wizard providers (ollama-cloud, deepseek, xai, kimi, etc.) are not forced
# through the wizard just because their provider doesn't have a detectable API key
# — the wizard cannot represent their provider and would overwrite their config
# with whichever wizard-supported provider they accidentally select.
config_exists = Path(_get_config_path()).exists()
config_auto_completed = config_exists and bool(runtime.get("chat_ready"))
# For providers not in the wizard's quick-setup list (e.g. ollama-cloud, deepseek,
# xai, kimi-k2.6), the wizard can never help — it only knows how to configure
# openrouter/anthropic/openai/google/custom. If such a user has a configured
# provider + model in config.yaml, showing the wizard would only confuse them
# (or worse, let them accidentally overwrite their config with gpt-5.4-mini).
_current_provider = str(
(cfg.get("model", {}) or {}).get("provider", "") if isinstance(cfg.get("model"), dict)
else ""
).strip().lower()
_is_non_wizard_provider = bool(
_current_provider and _current_provider not in _SUPPORTED_PROVIDER_SETUPS
)
config_auto_completed = config_exists and (
bool(runtime.get("chat_ready"))
or (_is_non_wizard_provider and bool(runtime.get("provider_configured")))
)
# Persist the flag so it survives future transient import failures (e.g. after
# a git branch switch in the hermes-agent repo). Without this, a CLI-configured
@@ -542,12 +653,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)
+44 -9
View File
@@ -232,7 +232,13 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]:
return default_model, bool(default_model)
active_provider = _normalize_provider_id(catalog.get("active_provider"))
if not active_provider:
# Also keep the raw active_provider slug for cross-provider detection with
# non-listed providers (ollama-cloud, deepseek, xai, etc.) that _normalize_provider_id
# returns "" for. If the raw provider is set but normalization returned "", we still
# want to detect that a session model from a known provider (e.g. openai/gpt-5.4-mini)
# is stale relative to this unknown active provider. (#1023)
raw_active_provider = str(catalog.get("active_provider") or "").strip().lower()
if not active_provider and not raw_active_provider:
return model, False
slash = model.find("/")
@@ -275,7 +281,12 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]:
# Skip normalization for models on custom/openrouter namespaces — these are
# user-controlled and should never be silently replaced.
if model_provider and model_provider not in {"", "custom", "openrouter"} and model_provider != active_provider and default_model:
# Also normalize when the model is from a known provider but the active provider
# is an unlisted one (e.g. ollama-cloud) — active_provider is "" in that case
# but raw_active_provider is set. If model_provider doesn't start with the raw
# active provider name, the session model is stale. (#1023)
_active_for_compare = active_provider or raw_active_provider
if model_provider and model_provider not in {"", "custom", "openrouter"} and model_provider != _active_for_compare and default_model:
return default_model, True
return model, False
@@ -1224,6 +1235,9 @@ def handle_post(handler, parsed) -> bool:
# Delete from WebUI session store
with LOCK:
SESSIONS.pop(sid, None)
# Evict cached agent so turn count doesn't leak into a recycled session
from api.config import _evict_session_agent
_evict_session_agent(sid)
try:
p = (SESSION_DIR / f"{sid}.json").resolve()
p.relative_to(SESSION_DIR.resolve())
@@ -1264,6 +1278,9 @@ def handle_post(handler, parsed) -> bool:
s.tool_calls = []
s.title = "Untitled"
s.save()
# Evict cached agent — cleared session is a fresh conversation
from api.config import _evict_session_agent
_evict_session_agent(body["session_id"])
return j(handler, {"ok": True, "session": s.compact()})
if parsed.path == "/api/session/truncate":
@@ -2148,9 +2165,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 +2182,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
@@ -3535,22 +3569,23 @@ def _handle_session_import_cli(handler, body):
if not msgs:
return bad(handler, "Session not found in CLI store", 404)
# Derive title from first user message
title = title_from(msgs, "CLI Session")
model = "unknown"
# Get profile, model, and timestamps from CLI session metadata
# Get profile, model, timestamps, and title from CLI session metadata
profile = None
created_at = None
updated_at = None
cli_title = None
for cs in get_cli_sessions():
if cs["session_id"] == sid:
profile = cs.get("profile")
model = cs.get("model", "unknown")
created_at = cs.get("created_at")
updated_at = cs.get("updated_at")
cli_title = cs.get("title")
break
# Use the CLI session title if available (e.g., cron job name), otherwise derive from messages
title = cli_title or title_from(msgs, "CLI Session")
s = import_cli_session(
sid,
title,
+192 -1
View File
@@ -209,6 +209,58 @@ def _first_exchange_snippets(messages):
return user_text[:500], asst_text[:500]
def _latest_exchange_snippets(messages):
"""Return (last_user_text, last_assistant_text) snippets for title refresh.
Walks the message list backwards to find the last user+assistant pair,
skipping empty or tool-call-only assistant messages.
"""
user_text = ''
asst_text = ''
for m in reversed(messages or []):
if not isinstance(m, dict):
continue
role = m.get('role')
if role == 'assistant' and not asst_text:
candidate = _message_text(m.get('content'))
# Skip tool-call-only preambles
if m.get('tool_calls') and (not candidate or _looks_invalid_generated_title(candidate)):
continue
if candidate:
asst_text = candidate
elif role == 'user' and not user_text:
candidate = _message_text(m.get('content'))
if candidate:
user_text = candidate
if user_text and asst_text:
break
return user_text[:500], asst_text[:500]
def _count_exchanges(messages):
"""Count the number of user messages (rough exchange count)."""
count = 0
for m in messages or []:
if isinstance(m, dict) and m.get('role') == 'user':
content = m.get('content', '')
if isinstance(content, list):
content = ' '.join(p.get('text', '') for p in content if isinstance(p, dict) and p.get('type') == 'text')
if str(content).strip():
count += 1
return count
def _get_title_refresh_interval() -> int:
"""Read the auto_title_refresh_every setting (0 = disabled)."""
try:
from api.config import load_settings
settings = load_settings()
val = settings.get('auto_title_refresh_every', '0')
return int(val) if str(val).strip().isdigit() and int(val) > 0 else 0
except Exception:
return 0
def _is_provisional_title(current_title: str, messages) -> bool:
"""Heuristic: title equals first-message substring placeholder."""
derived = title_from(messages, '') or ''
@@ -731,6 +783,85 @@ def _run_background_title_update(session_id: str, user_text: str, assistant_text
put_event('stream_end', {'session_id': session_id})
def _run_background_title_refresh(session_id: str, user_text: str, assistant_text: str, current_title: str, put_event, agent=None):
"""Refresh an existing LLM-generated title using the latest exchange text.
Unlike _run_background_title_update, this does NOT guard on
llm_title_generated it assumes the title was already LLM-generated
and the session has progressed enough to warrant a refresh.
It does NOT emit stream_end (the caller already did).
"""
try:
try:
s = get_session(session_id)
except KeyError:
return
# Safety: skip if user manually renamed since the check
effective = str(s.title or '').strip()
if effective != current_title:
_put_title_status(put_event, session_id, 'skipped', 'manual_title', effective)
return
if not effective or effective in ('Untitled', 'New Chat'):
return
aux_title_configured = _aux_title_configured()
if agent and not aux_title_configured:
next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text)
if not next_title and llm_status in ('llm_error', 'llm_invalid'):
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent, use_agent_model=True)
else:
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text)
if not next_title and agent and llm_status in ('llm_error_aux', 'llm_invalid_aux'):
next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text)
if not next_title:
_put_title_status(put_event, session_id, 'refresh_skipped', llm_status or 'empty', effective, raw_preview)
return
# Skip if the new title is essentially the same (after normalization)
normalized_current = re.sub(r'\s+', ' ', effective).strip().lower()
normalized_new = re.sub(r'\s+', ' ', next_title).strip().lower()
if normalized_current == normalized_new:
_put_title_status(put_event, session_id, 'refresh_skipped', 'same_title', effective, raw_preview)
return
with _get_session_agent_lock(session_id):
with LOCK:
s = SESSIONS.get(session_id, s)
# Re-check: user may have renamed while we were generating
if str(s.title or '').strip() != current_title:
_put_title_status(put_event, session_id, 'skipped', 'manual_title', str(s.title or '').strip())
return
s.title = next_title
s.llm_title_generated = True
s.save(touch_updated_at=False)
effective_title = s.title
_put_title_status(put_event, session_id, 'refreshed', llm_status, effective_title, raw_preview)
put_event('title', {'session_id': session_id, 'title': effective_title})
logger.info("Adaptive title refresh: session=%s new_title=%r", session_id, effective_title)
except Exception:
logger.debug("Background title refresh failed for session %s", session_id, exc_info=True)
def _maybe_schedule_title_refresh(session, put_event, agent):
"""Check if the session is due for an adaptive title refresh and schedule it."""
refresh_interval = _get_title_refresh_interval()
if refresh_interval <= 0:
return
current_title = str(session.title or '').strip()
if not current_title or current_title in ('Untitled', 'New Chat'):
return
if not getattr(session, 'llm_title_generated', False):
return
exchange_count = _count_exchanges(session.messages)
if exchange_count <= 0 or exchange_count % refresh_interval != 0:
return
last_u, last_a = _latest_exchange_snippets(session.messages)
if not last_u and not last_a:
return
threading.Thread(
target=_run_background_title_refresh,
args=(session.session_id, last_u, last_a, current_title, put_event, agent),
daemon=True,
).start()
def _sanitize_messages_for_api(messages):
"""Return a deep copy of messages with only API-safe fields.
@@ -1398,7 +1529,56 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
if 'gateway_session_key' in _agent_params:
_agent_kwargs['gateway_session_key'] = session_id
agent = _AIAgent(**_agent_kwargs)
# ── Agent cache: reuse across messages in the same session ──
# Mirrors gateway _agent_cache. Keeps _user_turn_count alive so
# injectionFrequency: "first-turn" actually suppresses after turn 1.
if ephemeral:
agent = _AIAgent(**_agent_kwargs)
logger.debug('[webui] Created ephemeral agent for session %s', session_id)
else:
import hashlib as _hashlib
import json as _json
from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK
_sig_blob = _json.dumps([
resolved_model or '',
_hashlib.sha256((resolved_api_key or '').encode()).hexdigest()[:16],
resolved_base_url or '',
resolved_provider or '',
sorted(_toolsets) if _toolsets else [],
], sort_keys=True)
_agent_sig = _hashlib.sha256(_sig_blob.encode()).hexdigest()[:16]
agent = None
with SESSION_AGENT_CACHE_LOCK:
_cached = SESSION_AGENT_CACHE.get(session_id)
if _cached and _cached[1] == _agent_sig:
agent = _cached[0]
logger.debug('[webui] Reusing cached agent for session %s', session_id)
if agent is not None:
# Refresh per-turn callbacks — these close over request-scoped
# objects (put queue, cancel_event) that are new each request.
agent.stream_delta_callback = _agent_kwargs.get('stream_delta_callback')
agent.tool_progress_callback = _agent_kwargs.get('tool_progress_callback')
if hasattr(agent, 'reasoning_callback'):
agent.reasoning_callback = _agent_kwargs.get('reasoning_callback')
if hasattr(agent, 'clarify_callback'):
agent.clarify_callback = _agent_kwargs.get('clarify_callback')
if _session_db is not None:
agent._session_db = _session_db
if hasattr(agent, '_api_call_count'):
agent._api_call_count = 0
# Reset interrupt state from a prior cancel so the reused
# agent does not think it is still interrupted.
if hasattr(agent, '_interrupted'):
agent._interrupted = False
if hasattr(agent, '_interrupt_message'):
agent._interrupt_message = None
else:
agent = _AIAgent(**_agent_kwargs)
with SESSION_AGENT_CACHE_LOCK:
SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig)
logger.debug('[webui] Created new agent for session %s', session_id)
# Store agent instance for cancel/interrupt propagation
with STREAMS_LOCK:
@@ -1648,6 +1828,13 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
with SESSION_AGENT_LOCKS_LOCK:
SESSION_AGENT_LOCKS[new_sid] = _agent_lock
SESSION_AGENT_LOCKS.pop(old_sid, None)
# Migrate cached agent to the new session ID so the turn
# count survives context compression.
from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK
with SESSION_AGENT_CACHE_LOCK:
_cached_entry = SESSION_AGENT_CACHE.pop(old_sid, None)
if _cached_entry:
SESSION_AGENT_CACHE[new_sid] = _cached_entry
if old_path.exists() and not new_path.exists():
try:
old_path.rename(new_path)
@@ -1766,6 +1953,10 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
# which may be rotated during context compression. The client captured
# activeSid = original session_id so they must match for stream_end to close.
put('stream_end', {'session_id': session_id})
# Adaptive title refresh: re-generate title from latest exchange
# every N exchanges (when enabled in settings). Runs after stream_end
# so it doesn't block the stream.
_maybe_schedule_title_refresh(s, put, agent)
finally:
# Stop the live metering ticker
_metering_stop.set()
+33 -14
View File
@@ -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()){
@@ -708,17 +710,17 @@ function _pickSkin(name){
function _syncThemePicker(active){
document.querySelectorAll('#themePickerGrid .theme-pick-btn').forEach(btn=>{
const sel=btn.dataset.themeVal===active;
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
btn.classList.toggle('active',btn.dataset.themeVal===active);
btn.style.borderColor='';
btn.style.boxShadow='';
});
}
function _syncSkinPicker(active){
document.querySelectorAll('#skinPickerGrid .skin-pick-btn').forEach(btn=>{
const sel=btn.dataset.skinVal===active;
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
btn.classList.toggle('active',btn.dataset.skinVal===active);
btn.style.borderColor='';
btn.style.boxShadow='';
});
}
@@ -741,9 +743,9 @@ function _pickFontSize(size){
function _syncFontSizePicker(active){
document.querySelectorAll('#fontSizePickerGrid .font-size-pick-btn').forEach(btn=>{
const sel=btn.dataset.fontSizeVal===(active||'default');
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
btn.classList.toggle('active',btn.dataset.fontSizeVal===(active||'default'));
btn.style.borderColor='';
btn.style.boxShadow='';
});
}
@@ -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;
@@ -893,13 +898,27 @@ function applyBotName(){
// back-forward cache, the async boot IIFE above does NOT re-run, but the
// DOM — including any stale value in #sessionSearch — IS restored. A
// prior search string would silently hide all sessions via the filter in
// renderSessionListFromCache(). Clear the field and re-render whenever
// the page is restored from cache (`event.persisted === true`).
// renderSessionListFromCache(). Clear the field and re-run the full layout
// sync whenever the page is restored from cache (`event.persisted === true`).
// Fix #1045: also re-run topbar/workspace/panel state so the rail and layout
// chrome aren't left in the stale bfcache snapshot.
window.addEventListener('pageshow', (event) => {
if (!event.persisted) return; // fresh loads are handled by the IIFE above
const _srch = document.getElementById('sessionSearch');
if (_srch) _srch.value = '';
// Close any dropdowns/popovers that were open when the user navigated away.
// bfcache freezes DOM state, so a dropdown left open remains open on restore.
if (typeof closeModelDropdown === 'function') try { closeModelDropdown(); } catch (_) {}
if (typeof closeReasoningDropdown === 'function') try { closeReasoningDropdown(); } catch (_) {}
if (typeof closeWsDropdown === 'function') try { closeWsDropdown(); } catch (_) {}
if (typeof closeProfileDropdown === 'function') try { closeProfileDropdown(); } catch (_) {}
// Re-synchronise layout chrome that the boot IIFE sets up but bfcache
// doesn't re-run. Each call is guarded so missing helpers degrade silently.
if (typeof syncTopbar === 'function') try { syncTopbar(); } catch (_) {}
if (typeof syncWorkspacePanelState === 'function') try { syncWorkspacePanelState(); } catch (_) {}
if (typeof renderSessionListFromCache === 'function') {
try { renderSessionListFromCache(); } catch (_) {}
}
// Restart the gateway SSE watcher — the persisted connection is dead after bfcache
if (typeof startGatewaySSE === 'function') try { startGatewaySSE(); } catch (_) {}
});
+125 -52
View File
@@ -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',
@@ -299,11 +308,10 @@ const LOCALES = {
new_conversation: 'New conversation',
filter_conversations: 'Filter conversations...',
session_time_unknown: 'Unknown',
session_time_just_now: 'just now',
session_time_minutes_ago: (n) => `${n} minute${n === 1 ? '' : 's'} ago`,
session_time_hours_ago: (n) => `${n} hour${n === 1 ? '' : 's'} ago`,
session_time_days_ago: (n) => `${n} day${n === 1 ? '' : 's'} ago`,
session_time_last_week: 'last week',
session_time_minutes_ago: (n) => `${n}m`,
session_time_hours_ago: (n) => `${n}h`,
session_time_days_ago: (n) => `${n}d`,
session_time_last_week: '1w',
session_time_bucket_today: 'Today',
session_time_bucket_yesterday: 'Yesterday',
session_time_bucket_this_week: 'This week',
@@ -331,6 +339,12 @@ const LOCALES = {
settings_sidebar_density_compact: 'Compact',
settings_sidebar_density_detailed: 'Detailed',
settings_desc_sidebar_density: 'Controls how much metadata the session list shows in the left sidebar.',
settings_label_auto_title_refresh: 'Adaptive title refresh',
settings_auto_title_refresh_off: 'Off',
settings_auto_title_refresh_5: 'Every 5 exchanges',
settings_auto_title_refresh_10: 'Every 10 exchanges',
settings_auto_title_refresh_20: 'Every 20 exchanges',
settings_desc_auto_title_refresh: 'Automatically re-generates the session title based on the latest exchange, keeping it relevant as the conversation evolves. Requires an LLM title generation model to be configured.',
settings_desc_cli_sessions: 'Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.',
settings_desc_sync_insights: 'Mirrors WebUI token usage to state.db so hermes /insights includes browser session data. Off by default.',
settings_desc_check_updates: 'Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.',
@@ -418,6 +432,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',
@@ -799,38 +816,10 @@ const LOCALES = {
new_conversation: 'Новая беседа',
filter_conversations: 'Фильтр бесед...',
session_time_unknown: 'Неизвестно',
session_time_just_now: 'только что',
session_time_minutes_ago: (n) => {
const mod10 = n % 10;
const mod100 = n % 100;
const word = mod10 === 1 && mod100 !== 11
? 'минута'
: (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)
? 'минуты'
: 'минут');
return `${n} ${word} назад`;
},
session_time_hours_ago: (n) => {
const mod10 = n % 10;
const mod100 = n % 100;
const word = mod10 === 1 && mod100 !== 11
? 'час'
: (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)
? 'часа'
: 'часов');
return `${n} ${word} назад`;
},
session_time_days_ago: (n) => {
const mod10 = n % 10;
const mod100 = n % 100;
const word = mod10 === 1 && mod100 !== 11
? 'день'
: (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)
? 'дня'
: 'дней');
return `${n} ${word} назад`;
},
session_time_last_week: 'на прошлой неделе',
session_time_minutes_ago: (n) => `${n}м`,
session_time_hours_ago: (n) => `${n}ч`,
session_time_days_ago: (n) => `${n}д`,
session_time_last_week: '1н',
session_time_bucket_today: 'Сегодня',
session_time_bucket_yesterday: 'Вчера',
session_time_bucket_this_week: 'На этой неделе',
@@ -857,6 +846,12 @@ const LOCALES = {
settings_sidebar_density_compact: 'Компактно',
settings_sidebar_density_detailed: 'Подробно',
settings_desc_sidebar_density: 'Управляет тем, сколько метаданных показывается в списке сеансов на левой панели.',
settings_label_auto_title_refresh: 'Адаптивное обновление заголовка',
settings_auto_title_refresh_off: 'Выкл',
settings_auto_title_refresh_5: 'Каждые 5 обменов',
settings_auto_title_refresh_10: 'Каждые 10 обменов',
settings_auto_title_refresh_20: 'Каждые 20 обменов',
settings_desc_auto_title_refresh: 'Автоматически переформулирует заголовок сессии на основе последнего обмена, чтобы он оставался актуальным по мере развития беседы. Требует настроенную модель генерации заголовков.',
settings_desc_cli_sessions: 'Объединяет сеансы из Hermes CLI (state.db) в список сеансов. Нажмите на CLI-сеанс, чтобы импортировать его и продолжить разговор.',
settings_desc_sync_insights: 'Синхронизирует использование токенов WebUI в state.db, чтобы Hermes /insights включал данные браузерных сеансов. Выключено по умолчанию.',
settings_desc_check_updates: 'Показывает баннер, когда доступны более новые версии WebUI или Agent. Периодически выполняет git fetch в фоне.',
@@ -942,6 +937,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 +1187,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',
@@ -1388,11 +1395,10 @@ const LOCALES = {
new_conversation: 'Nueva conversación',
filter_conversations: 'Filtrar conversaciones...',
session_time_unknown: 'Desconocido',
session_time_just_now: 'justo ahora',
session_time_minutes_ago: (n) => `hace ${n} minuto${n === 1 ? '' : 's'}`,
session_time_hours_ago: (n) => `hace ${n} hora${n === 1 ? '' : 's'}`,
session_time_days_ago: (n) => `hace ${n} día${n === 1 ? '' : 's'}`,
session_time_last_week: 'la semana pasada',
session_time_minutes_ago: (n) => `${n}m`,
session_time_hours_ago: (n) => `${n}h`,
session_time_days_ago: (n) => `${n}d`,
session_time_last_week: '1sem',
session_time_bucket_today: 'Hoy',
session_time_bucket_yesterday: 'Ayer',
session_time_bucket_this_week: 'Esta semana',
@@ -1420,6 +1426,12 @@ const LOCALES = {
settings_sidebar_density_compact: 'Compacta',
settings_sidebar_density_detailed: 'Detallada',
settings_desc_sidebar_density: 'Controla cuántos metadatos muestra la lista de sesiones en la barra lateral izquierda.',
settings_label_auto_title_refresh: 'Actualización adaptativa del título',
settings_auto_title_refresh_off: 'Desactivado',
settings_auto_title_refresh_5: 'Cada 5 intercambios',
settings_auto_title_refresh_10: 'Cada 10 intercambios',
settings_auto_title_refresh_20: 'Cada 20 intercambios',
settings_desc_auto_title_refresh: 'Regenera automáticamente el título de la sesión basándose en el último intercambio, manteniéndolo relevante a medida que evoluciona la conversación. Requiere un modelo LLM de generación de títulos configurado.',
settings_desc_cli_sessions: 'Fusiona las sesiones del CLI de Hermes (state.db) en la lista de sesiones. Haz clic en una sesión de CLI para importarla y continuar la conversación.',
settings_desc_sync_insights: 'Refleja el uso de tokens de la WebUI en state.db para que hermes /insights incluya datos de sesiones del navegador. Desactivado por defecto.',
settings_desc_check_updates: 'Muestra un banner cuando haya versiones más nuevas de la WebUI o del Agent. Ejecuta periódicamente un git fetch en segundo plano.',
@@ -1507,6 +1519,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 +1753,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',
@@ -1961,6 +1985,12 @@ const LOCALES = {
settings_sidebar_density_compact: 'Kompakt',
settings_sidebar_density_detailed: 'Detailliert',
settings_desc_sidebar_density: 'Steuert, wie viele Metadaten die Sitzungsliste in der linken Seitenleiste anzeigt.',
settings_label_auto_title_refresh: 'Adaptive Titelaktualisierung',
settings_auto_title_refresh_off: 'Aus',
settings_auto_title_refresh_5: 'Alle 5 Antworten',
settings_auto_title_refresh_10: 'Alle 10 Antworten',
settings_auto_title_refresh_20: 'Alle 20 Antworten',
settings_desc_auto_title_refresh: 'Generiert den Sitzungstitel automatisch anhand des letzten Austauschs neu und hält ihn so aktuell, während sich das Gespräch entwickelt. Erfordert ein konfiguriertes LLM-Titelgenerierungsmodell.',
settings_desc_cli_sessions: 'Fügt Sitzungen aus der Hermes CLI (state.db) in die Sitzungsliste ein. Klicken Sie auf eine CLI-Sitzung, um sie zu importieren und das Gespräch fortzusetzen.',
settings_desc_sync_insights: 'Spiegelt den WebUI-Token-Verbrauch in die state.db, sodass hermes /insights Browser-Sitzungsdaten enthält. Standardmäßig aus.',
settings_desc_check_updates: 'Zeigt ein Banner an, wenn neuere Versionen der WebUI oder des Agenten verfügbar sind. Führt regelmäßig einen Git-Fetch im Hintergrund aus.',
@@ -2070,6 +2100,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',
@@ -2269,11 +2308,10 @@ const LOCALES = {
new_conversation: '新建对话',
filter_conversations: '筛选对话…',
session_time_unknown: '未知',
session_time_just_now: '刚刚',
session_time_minutes_ago: (n) => `${n} 分钟前`,
session_time_hours_ago: (n) => `${n} 小时前`,
session_time_days_ago: (n) => `${n} 天前`,
session_time_last_week: '上周',
session_time_minutes_ago: (n) => `${n}`,
session_time_hours_ago: (n) => `${n}小时`,
session_time_days_ago: (n) => `${n}`,
session_time_last_week: '1周',
session_time_bucket_today: '今天',
session_time_bucket_yesterday: '昨天',
session_time_bucket_this_week: '本周',
@@ -2332,6 +2370,12 @@ const LOCALES = {
settings_sidebar_density_compact: '紧凑',
settings_sidebar_density_detailed: '详细',
settings_desc_sidebar_density: '控制左侧会话列表展示多少元信息。',
settings_label_auto_title_refresh: '\u81ea\u9002\u5e94\u6807\u9898\u66f4\u65b0',
settings_auto_title_refresh_off: '\u5173\u95ed',
settings_auto_title_refresh_5: '\u6bcf 5 \u8f6e\u5bf9\u8bdd',
settings_auto_title_refresh_10: '\u6bcf 10 \u8f6e\u5bf9\u8bdd',
settings_auto_title_refresh_20: '\u6bcf 20 \u8f6e\u5bf9\u8bdd',
settings_desc_auto_title_refresh: '\u57fa\u4e8e\u6700\u65b0\u5bf9\u8bdd\u81ea\u52a8\u91cd\u65b0\u751f\u6210\u4f1a\u8bdd\u6807\u9898\uff0c\u4f7f\u5176\u968f\u5bf9\u8bdd\u53d1\u5c55\u4fdd\u6301\u76f8\u5173\u3002\u9700\u8981\u914d\u7f6e LLM \u6807\u9898\u751f\u6210\u6a21\u578b\u3002',
settings_desc_cli_sessions: '将 Hermes CLIstate.db)中的会话合并到会话列表。点击某个 CLI 会话可导入并继续对话。',
settings_desc_sync_insights: '将 WebUI token 使用情况同步到 state.db,使 hermes /insights 包含浏览器会话数据。默认关闭。',
settings_desc_check_updates: '当有更新的 WebUI 或助手版本时显示横幅。会在后台定期执行 git fetch。',
@@ -2386,6 +2430,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 +2663,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 +2844,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: '偏好設定',
@@ -2877,6 +2942,12 @@ const LOCALES = {
settings_sidebar_density_compact: '精簡',
settings_sidebar_density_detailed: '詳細',
settings_desc_sidebar_density: '控制左側對話清單要顯示多少額外資訊。',
settings_label_auto_title_refresh: '\u81ea\u9002\u61c9\u6a19\u984c\u66f4\u65b0',
settings_auto_title_refresh_off: '\u95dc\u9589',
settings_auto_title_refresh_5: '\u6bcf 5 \u8f2a\u5c0d\u8a71',
settings_auto_title_refresh_10: '\u6bcf 10 \u8f2a\u5c0d\u8a71',
settings_auto_title_refresh_20: '\u6bcf 20 \u8f2a\u5c0d\u8a71',
settings_desc_auto_title_refresh: '\u57fa\u65bc\u6700\u65b0\u5c0d\u8a71\u81ea\u52d5\u91cd\u65b0\u751f\u6210\u6703\u8a71\u6a19\u984c\uff0c\u4f7f\u5176\u968f\u5c0d\u8a71\u767c\u5c55\u4fdd\u6301\u76f8\u95dc\u3002\u9700\u8981\u914d\u7f6e LLM \u6a19\u984c\u751f\u6210\u6a21\u578b\u3002',
settings_desc_cli_sessions: '將 Hermes CLI (的 state.db) 中的會話添加到會話清單。點擊一個 CLI 會話將導入它並繼續對話。',
settings_desc_sync_insights: '將 WebUI token 使用情況同步到 state.db,使 hermes /insights 包含瀏覽器會話數據。預設未啟用。',
settings_desc_check_updates: '當有更新的 WebUI 或助手版本時顯示標記。將在後台正常執行 Git-Fetch。',
@@ -2977,6 +3048,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',
@@ -3082,11 +3156,10 @@ const LOCALES = {
session_time_bucket_this_week: '\u672c\u9031',
session_time_bucket_today: '\u4eca\u5929',
session_time_bucket_yesterday: '\u6628\u5929',
session_time_days_ago: (d) => `${d} 天前`,
session_time_hours_ago: (h) => `${h} 小時前`,
session_time_just_now: '\u525b\u525b',
session_time_last_week: '\u4e0a\u9031',
session_time_minutes_ago: (m) => `${m} 分鐘前`,
session_time_days_ago: (d) => `${d}\u5929`,
session_time_hours_ago: (h) => `${h}\u5c0f\u6642`,
session_time_last_week: '1\u9031',
session_time_minutes_ago: (m) => `${m}\u5206`,
session_time_unknown: '\u672a\u77e5',
settings_unsaved_changes: '\u60a8\u6709\u672a\u5132\u5b58\u7684\u8b8a\u66f4\u3002',
sign_out_failed: '\u767b\u51fa\u5931\u6557\uff1a',
+26 -1
View File
@@ -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">
@@ -645,6 +652,16 @@
</select>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sidebar_density">Controls how much metadata the session list shows in the left sidebar.</div>
</div>
<div class="settings-field">
<label for="settingsAutoTitleRefresh" data-i18n="settings_label_auto_title_refresh">Adaptive title refresh</label>
<select id="settingsAutoTitleRefresh" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
<option value="0" data-i18n="settings_auto_title_refresh_off">Off</option>
<option value="5" data-i18n="settings_auto_title_refresh_5">Every 5 exchanges</option>
<option value="10" data-i18n="settings_auto_title_refresh_10">Every 10 exchanges</option>
<option value="20" data-i18n="settings_auto_title_refresh_20">Every 20 exchanges</option>
</select>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_auto_title_refresh">Automatically re-generates the session title based on the latest exchange, keeping it relevant as the conversation evolves. Requires an LLM title generation model to be configured.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowCliSessions" style="width:15px;height:15px;accent-color:var(--accent)">
@@ -693,7 +710,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 +749,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>
+15 -1
View File
@@ -21,6 +21,20 @@ document.addEventListener('DOMContentLoaded', function () {
if (err) { err.style.display = 'none'; }
}
// Return the ?next= redirect path if present and safe, otherwise './'
// Guards against open-redirect: rejects protocol-relative (//evil.com),
// absolute URLs, backslash variants, and control characters.
function _safeNextPath() {
try {
var raw = new URL(window.location.href).searchParams.get('next');
if (!raw) return './';
if (raw.charAt(0) !== '/') return './'; // must be path-absolute
if (raw.charAt(1) === '/' || raw.charAt(1) === '\\') return './'; // reject // and \\
if (/[\x00-\x1f\x7f\s]/.test(raw)) return './'; // reject control chars / whitespace
return raw;
} catch (_) { return './'; }
}
async function doLogin(e) {
e.preventDefault();
var pw = input.value;
@@ -35,7 +49,7 @@ document.addEventListener('DOMContentLoaded', function () {
var data = {};
try { data = await res.json(); } catch (_) {}
if (res.ok && data.ok) {
window.location.href = './';
window.location.href = _safeNextPath();
} else {
showErr(data.error || invalidPw);
}
+31 -11
View File
@@ -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;
}
@@ -266,7 +286,7 @@ async function loadOnboardingWizard(){
const current=((status.setup||{}).current)||{};
ONBOARDING.form.provider=current.provider||'openrouter';
ONBOARDING.form.workspace=(status.workspaces&&status.workspaces.last)||status.settings.default_workspace||'';
ONBOARDING.form.model=status.settings.default_model||current.model||'openai/gpt-5.4-mini';
ONBOARDING.form.model=status.settings.default_model||current.model||'';
ONBOARDING.form.password='';
ONBOARDING.form.apiKey='';
ONBOARDING.form.baseUrl=current.base_url||'';
+69
View File
@@ -2266,6 +2266,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');
@@ -2346,6 +2362,12 @@ async function loadSettingsPanel(){
sidebarDensitySel.value=settings.sidebar_density==='detailed'?'detailed':'compact';
sidebarDensitySel.addEventListener('change',_markSettingsDirty,{once:false});
}
const autoTitleRefreshSel=$('settingsAutoTitleRefresh');
if(autoTitleRefreshSel){
const val=String(settings.auto_title_refresh_every||'0');
autoTitleRefreshSel.value=['0','5','10','20'].includes(val)?val:'0';
autoTitleRefreshSel.addEventListener('change',_markSettingsDirty,{once:false});
}
// Bot name
const botNameField=$('settingsBotName');
if(botNameField){botNameField.value=settings.bot_name||'Hermes';botNameField.addEventListener('input',_markSettingsDirty,{once:false});}
@@ -2609,6 +2631,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||'');
@@ -2635,6 +2703,7 @@ async function saveSettings(andClose){
body.notifications_enabled=!!($('settingsNotificationsEnabled')||{}).checked;
body.show_thinking=window._showThinking!==false;
body.sidebar_density=sidebarDensity;
body.auto_title_refresh_every=(($('settingsAutoTitleRefresh')||{}).value||'0');
const botName=(($('settingsBotName')||{}).value||'').trim();
body.bot_name=botName||'Hermes';
// Password: only act if the field has content; blank = leave auth unchanged
+2 -2
View File
@@ -713,7 +713,7 @@ function _formatRelativeSessionTime(timestampMs, nowMs = Date.now()) {
const {startOfToday, startOfYesterday, startOfWeek, startOfLastWeek} = _sessionCalendarBoundaries(nowMs);
const dayDiff = Math.max(0, _localDayOrdinal(nowMs) - _localDayOrdinal(timestampMs));
if (timestampMs >= startOfToday) {
if (diffMs < minute) return t('session_time_just_now');
if (diffMs < minute) return t('session_time_minutes_ago', 1);
if (diffMs < hour) {
const minutes = Math.floor(diffMs / minute);
return t('session_time_minutes_ago', minutes);
@@ -721,7 +721,7 @@ function _formatRelativeSessionTime(timestampMs, nowMs = Date.now()) {
const hours = Math.floor(diffMs / hour);
return t('session_time_hours_ago', hours);
}
if (timestampMs >= startOfYesterday) return t('session_time_bucket_yesterday');
if (timestampMs >= startOfYesterday) return t('session_time_days_ago', 1);
if (timestampMs >= startOfWeek) return t('session_time_days_ago', dayDiff);
if (timestampMs >= startOfLastWeek) return t('session_time_last_week');
return _formatSessionDate(timestampMs, nowMs);
+10
View File
@@ -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);}
@@ -1655,6 +1661,10 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
#mainSettings .theme-pick-btn:hover,
#mainSettings .skin-pick-btn:hover,
#mainSettings .font-size-pick-btn:hover{border-color:var(--accent-bg-strong)!important;background:var(--surface)!important;}
/* Active/selected state for picker cards — must use !important to beat the base border-color:var(--border)!important rule above */
#mainSettings .theme-pick-btn.active,
#mainSettings .skin-pick-btn.active,
#mainSettings .font-size-pick-btn.active{border-color:var(--accent)!important;box-shadow:0 0 0 1px var(--accent-bg-strong)!important;background:var(--surface)!important;}
/* Responsive: tighten canvas on small screens. */
@media (max-width: 768px){
+12 -2
View File
@@ -9,6 +9,10 @@ const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns
// single-threaded so only one done event fires at a time in practice.
let _queueDrainSid=null;
const $=id=>document.getElementById(id);
// Redirect to /login when the server responds with 401 (auth session expired).
// Handles iOS PWA standalone mode where a server-side 302→/login would break
// out of the PWA shell into Safari instead of navigating within it.
function _redirectIfUnauth(res){if(res&&res.status===401){window.location.href='/login?next='+encodeURIComponent(window.location.pathname+window.location.search);return true;}return false;}
function _getSessionQueue(sid, create=false){
if(!sid) return [];
if(!SESSION_QUEUES[sid]&&create) SESSION_QUEUES[sid]=[];
@@ -87,7 +91,9 @@ async function populateModelDropdown(){
const sel=$('modelSelect');
if(!sel) return;
try{
const data=await fetch(new URL('api/models',location.href).href,{credentials:'include'}).then(r=>r.json());
const _modelsRes=await fetch(new URL('api/models',location.href).href,{credentials:'include'});
if(_redirectIfUnauth(_modelsRes)) return;
const data=await _modelsRes.json();
if(!data.groups||!data.groups.length) return; // keep HTML defaults
// Store active provider globally so the send path can warn on mismatch
window._activeProvider=data.active_provider||null;
@@ -191,7 +197,9 @@ async function _fetchLiveModels(provider, sel){
try{
const url=new URL('api/models/live',location.href);
url.searchParams.set('provider',provider);
const data=await fetch(url.href,{credentials:'include'}).then(r=>r.json());
const _liveRes=await fetch(url.href,{credentials:'include'});
if(_redirectIfUnauth(_liveRes)) return;
const data=await _liveRes.json();
if(!data.models||!data.models.length) return;
_liveModelCache[provider]=data.models;
const added=_addLiveModelsToSelect(provider,data.models,sel);
@@ -2682,6 +2690,7 @@ function renderMermaidBlocks(){
script.onload=()=>{
if(typeof mermaid!=='undefined'){
mermaid.initialize({startOnLoad:false,theme:document.documentElement.classList.contains('dark')?'dark':'default',themeVariables:{
fontFamily:'inherit',fontSize:'14px',
primaryColor:'#4a6fa5',primaryTextColor:'#e2e8f0',lineColor:'#718096',
secondaryColor:'#2d3748',tertiaryColor:'#1a202c',primaryBorderColor:'#4a5568',
}});
@@ -3125,6 +3134,7 @@ async function uploadPendingFiles(){
fd.append('session_id',S.session.session_id);fd.append('file',f,f.name);
try{
const res=await fetch(new URL('api/upload',location.href).href,{method:'POST',credentials:'include',body:fd});
if(_redirectIfUnauth(res)) return;
if(!res.ok){const err=await res.text();throw new Error(err);}
const data=await res.json();
if(data.error)throw new Error(data.error);
+34 -3
View File
@@ -4,6 +4,10 @@ async function api(path,opts={}){
const url=new URL(rel,location.href);
const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts});
if(!res.ok){
// 401 means the auth session expired. Redirect to /login so the user can
// re-authenticate. This is especially important for iOS PWA (standalone mode)
// where a server-side 302 → /login opens in Safari instead of within the PWA.
if(res.status===401){window.location.href='/login?next='+encodeURIComponent(window.location.pathname+window.location.search);return;}
const text=await res.text();
// Parse JSON error body and surface the human-readable message,
// rather than showing raw JSON like {"error":"Profile 'x' does not exist."}
@@ -97,6 +101,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 +115,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 +228,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 +312,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');
}
+106
View File
@@ -0,0 +1,106 @@
"""
Tests for issue #1038 — iOS PWA auth-expiry redirect.
When a 401 is returned by any API endpoint, the client-side JS should redirect
to /login rather than showing a raw error toast. On iOS PWA standalone mode a
server-side 302/login breaks out of the PWA shell into Safari, so the fix is
client-side: workspace.js api() intercepts 401 before throwing and calls
window.location.href = '/login'.
These are static regression tests that verify the JS source contains the
correct guard patterns.
"""
import re
from pathlib import Path
ROOT = Path(__file__).parent.parent
def _workspace_js() -> str:
return (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
def _ui_js() -> str:
return (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
class TestPWAAuthRedirect:
def test_workspace_js_has_401_redirect(self):
"""api() in workspace.js must redirect to /login on 401."""
src = _workspace_js()
# Guard must appear inside the !res.ok block, before throwing
assert "res.status===401" in src, \
"workspace.js api() must check res.status===401"
assert "window.location.href='/login" in src or 'window.location.href="/login' in src, \
"workspace.js api() must redirect to /login on 401"
def test_workspace_js_401_before_throw(self):
"""The 401 redirect must come before the generic error throw."""
src = _workspace_js()
idx_401 = src.find("res.status===401")
idx_throw = src.find("throw new Error")
assert idx_401 != -1, "401 guard not found in workspace.js"
assert idx_throw != -1, "throw not found in workspace.js"
assert idx_401 < idx_throw, \
"401 redirect must appear before the generic throw in workspace.js"
def test_ui_js_has_redirect_helper(self):
"""ui.js must define _redirectIfUnauth helper."""
src = _ui_js()
assert "_redirectIfUnauth" in src, \
"ui.js must define _redirectIfUnauth helper function"
def test_ui_js_models_fetch_uses_redirect(self):
"""populateModelDropdown() must call _redirectIfUnauth on the api/models response."""
src = _ui_js()
# The helper must be called after the api/models fetch
assert "_redirectIfUnauth(_modelsRes)" in src, \
"populateModelDropdown() must check 401 on api/models fetch"
def test_ui_js_live_models_fetch_uses_redirect(self):
"""loadLiveModels() must call _redirectIfUnauth on the api/models/live response."""
src = _ui_js()
assert "_redirectIfUnauth(_liveRes)" in src, \
"loadLiveModels() must check 401 on api/models/live fetch"
def test_ui_js_upload_fetch_uses_redirect(self):
"""File upload must call _redirectIfUnauth on the api/upload response."""
src = _ui_js()
assert "_redirectIfUnauth(res)" in src, \
"upload fetch must call _redirectIfUnauth"
class TestLoginJsSafeNextPath:
"""login.js _safeNextPath() must honor ?next= but reject open-redirect payloads."""
@staticmethod
def _login_js():
return (Path(__file__).parent.parent / "static" / "login.js").read_text(encoding="utf-8")
def test_safe_next_path_function_exists(self):
"""login.js must define _safeNextPath() to honor the ?next= redirect."""
assert "_safeNextPath" in self._login_js(), (
"login.js must define _safeNextPath() to use the ?next= redirect after login"
)
def test_login_uses_safe_next_path(self):
"""doLogin success handler must redirect to _safeNextPath(), not hardcoded './'."""
src = self._login_js()
assert "_safeNextPath()" in src, (
"doLogin must call _safeNextPath() instead of hardcoding './'"
)
def test_safe_next_path_rejects_protocol_relative(self):
"""_safeNextPath guard must reject '//' prefix (protocol-relative open-redirect)."""
src = self._login_js()
assert "charAt(1) === '/'" in src or "startsWith('//')" in src, (
"_safeNextPath must reject protocol-relative paths like //evil.com"
)
def test_safe_next_path_rejects_non_path_absolute(self):
"""_safeNextPath guard must require path starts with '/'."""
src = self._login_js()
assert "charAt(0) !== '/'" in src or "startsWith('/')" in src, (
"_safeNextPath must reject non-path-absolute inputs (e.g. 'http://...')"
)
+51
View File
@@ -0,0 +1,51 @@
"""
Tests for issue #1044 — Mermaid CSP font violation.
Mermaid's built-in themes inject an @import for Google Fonts (Manrope) at
render time, which is blocked by the CSP's style-src directive. Fix: pass
fontFamily:'inherit' in themeVariables so Mermaid never requests an external
font URL.
"""
from pathlib import Path
ROOT = Path(__file__).parent.parent
def _ui_js() -> str:
return (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
class TestMermaidCSPFont:
def test_mermaid_init_has_font_family_inherit(self):
"""themeVariables in mermaid.initialize() must set fontFamily to 'inherit'."""
src = _ui_js()
assert "fontFamily:'inherit'" in src, (
"mermaid.initialize() themeVariables must set fontFamily:'inherit' "
"to suppress the Google Fonts (Manrope) import that violates CSP"
)
def test_mermaid_init_no_google_fonts_url(self):
"""ui.js must not contain a hardcoded fonts.googleapis.com URL."""
src = _ui_js()
assert "fonts.googleapis.com" not in src, (
"ui.js must not reference fonts.googleapis.com — use fontFamily:'inherit'"
)
def test_mermaid_font_family_inside_theme_variables_block(self):
"""fontFamily:'inherit' must be inside the themeVariables block of mermaid.initialize()."""
src = _ui_js()
init_idx = src.find("mermaid.initialize(")
assert init_idx != -1, "mermaid.initialize() call not found in ui.js"
# Find the themeVariables block after the initialize call
tv_idx = src.find("themeVariables", init_idx)
assert tv_idx != -1, "themeVariables not found inside mermaid.initialize()"
font_idx = src.find("fontFamily:'inherit'", tv_idx)
assert font_idx != -1, (
"fontFamily:'inherit' must appear inside themeVariables in mermaid.initialize()"
)
# The closing brace of themeVariables should come after fontFamily
close_brace = src.find("})", tv_idx)
assert font_idx < close_brace, (
"fontFamily:'inherit' must be inside the themeVariables block (before })"
)
+116
View File
@@ -0,0 +1,116 @@
"""
Tests for issue #1045 — bfcache layout broken on tab restore.
When the browser restores a page from bfcache (event.persisted === true),
the async boot IIFE does not re-run. The existing pageshow handler (added for
#822) only cleared the session search field and re-rendered the session list.
This left the rail, topbar, workspace panel, and resize handles in the stale
bfcache DOM state, producing a broken layout.
Fix: extend the pageshow handler to also call syncTopbar, syncWorkspacePanelState,
_initResizePanels, and startGatewaySSE all guarded so missing helpers degrade.
"""
from pathlib import Path
ROOT = Path(__file__).parent.parent
def _boot_js() -> str:
return (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
class TestBfcacheLayoutRestore:
def test_pageshow_calls_sync_topbar(self):
"""pageshow handler must call syncTopbar() on bfcache restore."""
src = _boot_js()
# Find the pageshow listener block
ps_idx = src.find("window.addEventListener('pageshow'")
assert ps_idx != -1, "pageshow listener not found in boot.js"
handler_body = src[ps_idx:ps_idx + 1600]
assert "syncTopbar" in handler_body, (
"pageshow handler must call syncTopbar() to restore topbar state after bfcache"
)
def test_pageshow_calls_sync_workspace_panel_state(self):
"""pageshow handler must call syncWorkspacePanelState()."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
assert "syncWorkspacePanelState" in handler_body, (
"pageshow handler must call syncWorkspacePanelState() on bfcache restore"
)
def test_pageshow_calls_start_gateway_sse(self):
"""pageshow handler must call startGatewaySSE() to reconnect the dead SSE connection."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
assert "startGatewaySSE" in handler_body, (
"pageshow handler must restart gateway SSE (bfcache-persisted connections are dead)"
)
def test_pageshow_still_clears_session_search(self):
"""pageshow handler must still clear #sessionSearch (original #822 fix preserved)."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
assert "sessionSearch" in handler_body, (
"pageshow handler must still clear #sessionSearch (regression: #822 fix must be preserved)"
)
def test_pageshow_still_calls_render_session_list_from_cache(self):
"""pageshow handler must still call renderSessionListFromCache()."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
assert "renderSessionListFromCache" in handler_body, (
"pageshow handler must still call renderSessionListFromCache() (regression: #822 fix)"
)
def test_pageshow_does_not_call_init_resize_panels(self):
"""pageshow handler must NOT call _initResizePanels() — bfcache
preserves event listeners so re-attaching them stacks duplicates."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
assert "_initResizePanels" not in handler_body, (
"pageshow handler must not call _initResizePanels() — it stacks "
"duplicate mousedown listeners on every bfcache restore"
)
def test_new_calls_are_guarded_with_typeof(self):
"""New calls in the pageshow handler must be typeof-guarded for safe degradation."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
# Each of the new calls must be guarded
for fn in ("syncTopbar", "syncWorkspacePanelState", "startGatewaySSE",
"closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"):
assert f"typeof {fn} === 'function'" in handler_body, (
f"{fn}() call in pageshow handler must be guarded with typeof === 'function'"
)
def test_pageshow_closes_all_dropdowns(self):
"""pageshow handler must close all known dropdowns to reset frozen bfcache popover state."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
for fn in ("closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"):
assert fn in handler_body, (
f"pageshow handler must call {fn}() to dismiss any dropdown left open by bfcache"
)
def test_dropdowns_closed_before_layout_sync(self):
"""Dropdown closes must come before layout sync calls (clean state first)."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
close_idx = handler_body.find("closeModelDropdown")
sync_idx = handler_body.find("syncTopbar")
assert close_idx != -1 and sync_idx != -1, "Both close and sync calls must be present"
assert close_idx < sync_idx, (
"Dropdown close calls must appear before layout sync calls in the pageshow handler"
)
+382
View File
@@ -0,0 +1,382 @@
"""Tests for adaptive session title refresh helpers (PR #1058).
Covers all five new functions added to api/streaming.py:
- _count_exchanges
- _latest_exchange_snippets
- _get_title_refresh_interval
- _run_background_title_refresh
- _maybe_schedule_title_refresh
"""
import sys
import os
import threading
import types
import unittest
from unittest.mock import MagicMock, patch
import pytest
# Ensure the project root is on sys.path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from api.streaming import (
_count_exchanges,
_latest_exchange_snippets,
_get_title_refresh_interval,
_run_background_title_refresh,
_maybe_schedule_title_refresh,
)
@pytest.fixture(autouse=True)
def _restore_auth_sessions():
"""Snapshot and restore api.auth._sessions around each test.
Importing api.streaming can trigger api.config.load_settings() which may
call into api.auth and create a real session token. Without this fixture,
that stale token leaks into test_auth_session_persistence.py tests (which
assume _sessions starts empty) when our file runs first alphabetically.
"""
import api.auth as _auth
snapshot = dict(_auth._sessions)
yield
_auth._sessions.clear()
_auth._sessions.update(snapshot)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _user_msg(text):
return {'role': 'user', 'content': text}
def _asst_msg(text, tool_calls=None):
msg = {'role': 'assistant', 'content': text}
if tool_calls is not None:
msg['tool_calls'] = tool_calls
return msg
def _tool_only_asst():
"""Assistant message that only has tool_calls, no real text."""
return {'role': 'assistant', 'content': '', 'tool_calls': [{'id': 't1', 'type': 'function'}]}
def _make_session(title='My Title', llm_title_generated=True, messages=None, session_id='sid1'):
s = MagicMock()
s.title = title
s.llm_title_generated = llm_title_generated
s.messages = messages or []
s.session_id = session_id
s.save = MagicMock()
return s
# ---------------------------------------------------------------------------
# _count_exchanges
# ---------------------------------------------------------------------------
class TestCountExchanges:
def test_empty_messages_returns_zero(self):
assert _count_exchanges([]) == 0
def test_none_messages_returns_zero(self):
assert _count_exchanges(None) == 0
def test_counts_only_user_messages(self):
msgs = [_user_msg('hello'), _asst_msg('hi'), _user_msg('world')]
assert _count_exchanges(msgs) == 2
def test_skips_empty_user_messages(self):
msgs = [_user_msg(''), _user_msg(' '), _user_msg('real question')]
assert _count_exchanges(msgs) == 1
def test_counts_list_content_user_messages(self):
msgs = [
{'role': 'user', 'content': [{'type': 'text', 'text': 'list question'}]},
]
assert _count_exchanges(msgs) == 1
def test_skips_empty_list_content(self):
msgs = [
{'role': 'user', 'content': [{'type': 'text', 'text': ' '}]},
]
assert _count_exchanges(msgs) == 0
def test_ignores_non_user_roles(self):
msgs = [_asst_msg('response'), {'role': 'system', 'content': 'system prompt'}]
assert _count_exchanges(msgs) == 0
def test_ignores_non_dict_entries(self):
msgs = ['not a dict', _user_msg('real'), None]
assert _count_exchanges(msgs) == 1
def test_five_exchanges(self):
msgs = []
for i in range(5):
msgs.append(_user_msg(f'question {i}'))
msgs.append(_asst_msg(f'answer {i}'))
assert _count_exchanges(msgs) == 5
# ---------------------------------------------------------------------------
# _latest_exchange_snippets
# ---------------------------------------------------------------------------
class TestLatestExchangeSnippets:
def test_empty_returns_empty_strings(self):
u, a = _latest_exchange_snippets([])
assert u == '' and a == ''
def test_none_returns_empty_strings(self):
u, a = _latest_exchange_snippets(None)
assert u == '' and a == ''
def test_basic_pair(self):
msgs = [_user_msg('first q'), _asst_msg('first a'),
_user_msg('second q'), _asst_msg('second a')]
u, a = _latest_exchange_snippets(msgs)
assert u == 'second q'
assert a == 'second a'
def test_returns_latest_not_first(self):
msgs = [_user_msg('old q'), _asst_msg('old a'),
_user_msg('new q'), _asst_msg('new a')]
u, a = _latest_exchange_snippets(msgs)
assert u == 'new q'
def test_skips_tool_call_only_assistant(self):
"""An assistant msg with tool_calls and no real text should be skipped."""
msgs = [_user_msg('q'), _asst_msg('real answer'),
_user_msg('q2'), _tool_only_asst()]
u, a = _latest_exchange_snippets(msgs)
# _tool_only_asst should be skipped; fall back to previous real assistant
assert a == 'real answer'
assert u == 'q2'
def test_truncates_long_content(self):
long_text = 'x' * 600
msgs = [_user_msg(long_text), _asst_msg(long_text)]
u, a = _latest_exchange_snippets(msgs)
assert len(u) == 500
assert len(a) == 500
def test_no_assistant_message(self):
msgs = [_user_msg('q')]
u, a = _latest_exchange_snippets(msgs)
assert u == 'q'
assert a == ''
def test_no_user_message(self):
msgs = [_asst_msg('a')]
u, a = _latest_exchange_snippets(msgs)
assert u == ''
assert a == 'a'
def test_ignores_non_dict_entries(self):
msgs = ['noise', _user_msg('q'), None, _asst_msg('a')]
u, a = _latest_exchange_snippets(msgs)
assert u == 'q'
assert a == 'a'
# ---------------------------------------------------------------------------
# _get_title_refresh_interval
# ---------------------------------------------------------------------------
class TestGetTitleRefreshInterval:
def test_returns_int_for_valid_setting(self):
# _get_title_refresh_interval does a local import: `from api.config import load_settings`
# so patch the source module, not api.streaming
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': '5'}):
assert _get_title_refresh_interval() == 5
def test_returns_zero_for_off_setting(self):
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': '0'}):
assert _get_title_refresh_interval() == 0
def test_returns_zero_when_key_absent(self):
with patch('api.config.load_settings', return_value={}):
assert _get_title_refresh_interval() == 0
def test_returns_zero_on_exception(self):
with patch('api.config.load_settings', side_effect=Exception('boom')):
assert _get_title_refresh_interval() == 0
def test_valid_values_10_and_20(self):
for val in ('10', '20'):
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': val}):
assert _get_title_refresh_interval() == int(val)
# ---------------------------------------------------------------------------
# _run_background_title_refresh
# ---------------------------------------------------------------------------
class TestRunBackgroundTitleRefresh:
def _make_put_event(self):
events = []
def put(name, data):
events.append((name, data))
return put, events
def _make_session_obj(self, title='Old Title'):
s = MagicMock()
s.title = title
s.llm_title_generated = True
s.save = MagicMock()
return s
def test_skips_when_title_changed_before_call(self):
"""If the title has changed (manual rename) since the refresh was scheduled, skip."""
put, events = self._make_put_event()
with patch('api.streaming.get_session') as mock_get, \
patch('api.streaming.SESSIONS', {}), \
patch('api.streaming.LOCK', threading.Lock()):
s = self._make_session_obj(title='Different Title')
mock_get.return_value = s
_run_background_title_refresh(
'sid', 'user', 'asst', 'Old Title', put, agent=None
)
# No 'title' event should have been emitted
assert not any(name == 'title' for name, _ in events)
def test_skips_if_session_not_found(self):
put, events = self._make_put_event()
with patch('api.streaming.get_session', side_effect=KeyError('not found')):
_run_background_title_refresh('sid', 'u', 'a', 'title', put)
assert events == []
def test_skips_when_title_is_untitled(self):
put, events = self._make_put_event()
with patch('api.streaming.get_session') as mock_get:
s = self._make_session_obj(title='Untitled')
mock_get.return_value = s
_run_background_title_refresh('sid', 'u', 'a', 'Untitled', put)
assert not any(name == 'title' for name, _ in events)
def test_skips_same_title(self):
"""If the LLM generates a title identical to the current one, no event is emitted."""
put, events = self._make_put_event()
with patch('api.streaming.get_session') as mock_get, \
patch('api.streaming._aux_title_configured', return_value=True), \
patch('api.streaming._generate_llm_session_title_via_aux',
return_value=('Old Title', 'llm_ok', 'raw')), \
patch('api.streaming.SESSIONS', {}), \
patch('api.streaming.LOCK', threading.Lock()):
s = self._make_session_obj(title='Old Title')
mock_get.return_value = s
_run_background_title_refresh('sid', 'u', 'a', 'Old Title', put)
assert not any(name == 'title' for name, _ in events)
def test_emits_title_event_on_new_title(self):
put, events = self._make_put_event()
s = self._make_session_obj(title='Old Title')
# Use a real dict for SESSIONS so .get() works, pre-populated with our session
fake_sessions = {'sid': s}
with patch('api.streaming.get_session', return_value=s), \
patch('api.streaming._aux_title_configured', return_value=True), \
patch('api.streaming._generate_llm_session_title_via_aux',
return_value=('New Refreshed Title', 'llm_ok', 'raw')), \
patch('api.streaming.SESSIONS', fake_sessions), \
patch('api.streaming.LOCK', threading.Lock()):
_run_background_title_refresh('sid', 'u', 'a', 'Old Title', put)
title_events = [(n, d) for n, d in events if n == 'title']
assert len(title_events) == 1
assert title_events[0][1]['title'] == 'New Refreshed Title'
def test_exceptions_are_silently_swallowed(self):
"""Any unexpected error inside must not propagate — it's a background daemon."""
put, events = self._make_put_event()
with patch('api.streaming.get_session', side_effect=RuntimeError('oops')):
# Should not raise
_run_background_title_refresh('sid', 'u', 'a', 'title', put)
assert events == []
# ---------------------------------------------------------------------------
# _maybe_schedule_title_refresh
# ---------------------------------------------------------------------------
class TestMaybeScheduleTitleRefresh:
def _noop_put(self, name, data):
pass
def test_does_nothing_when_disabled(self):
with patch('api.streaming._get_title_refresh_interval', return_value=0):
spawned = []
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
_maybe_schedule_title_refresh(session, self._noop_put, None)
assert spawned == []
def test_does_nothing_when_title_is_empty(self):
with patch('api.streaming._get_title_refresh_interval', return_value=5):
spawned = []
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
session = _make_session(title='', messages=[_user_msg('q'), _asst_msg('a')] * 5)
_maybe_schedule_title_refresh(session, self._noop_put, None)
assert spawned == []
def test_does_nothing_for_untitled(self):
with patch('api.streaming._get_title_refresh_interval', return_value=5):
spawned = []
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
session = _make_session(title='Untitled', messages=[_user_msg('q'), _asst_msg('a')] * 5)
_maybe_schedule_title_refresh(session, self._noop_put, None)
assert spawned == []
def test_does_nothing_when_title_not_llm_generated(self):
with patch('api.streaming._get_title_refresh_interval', return_value=5):
spawned = []
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
session = _make_session(llm_title_generated=False,
messages=[_user_msg('q'), _asst_msg('a')] * 5)
_maybe_schedule_title_refresh(session, self._noop_put, None)
assert spawned == []
def test_does_nothing_when_exchange_count_not_at_interval(self):
"""Refresh only fires when exchange_count % interval == 0 (and > 0)."""
with patch('api.streaming._get_title_refresh_interval', return_value=5):
spawned = []
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
# 4 exchanges — not a multiple of 5
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 4)
_maybe_schedule_title_refresh(session, self._noop_put, None)
assert spawned == []
def test_spawns_thread_at_exact_interval(self):
"""Refresh fires when exchange_count == refresh_interval."""
with patch('api.streaming._get_title_refresh_interval', return_value=5):
spawned = []
with patch('threading.Thread') as mock_thread_cls:
mock_thread = MagicMock()
mock_thread_cls.return_value = mock_thread
# 5 user messages = 5 exchanges
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
_maybe_schedule_title_refresh(session, self._noop_put, None)
assert mock_thread_cls.called
assert mock_thread.start.called
def test_spawns_thread_at_multiple_of_interval(self):
"""Refresh fires at 10 exchanges when interval is 5."""
with patch('api.streaming._get_title_refresh_interval', return_value=5):
with patch('threading.Thread') as mock_thread_cls:
mock_thread = MagicMock()
mock_thread_cls.return_value = mock_thread
# 10 exchanges
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 10)
_maybe_schedule_title_refresh(session, self._noop_put, None)
assert mock_thread_cls.called
def test_does_nothing_when_no_exchange_content(self):
"""Even at interval, if both snippets are empty, don't spawn."""
with patch('api.streaming._get_title_refresh_interval', return_value=5), \
patch('api.streaming._latest_exchange_snippets', return_value=('', '')):
spawned = []
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
_maybe_schedule_title_refresh(session, self._noop_put, None)
assert spawned == []
@@ -0,0 +1,84 @@
"""Regression tests for settings picker active-state highlighting.
The theme, skin, and font-size pickers in the Appearance settings tab must show
the currently-selected option with a visible accent border. This was broken because
the CSS rule used !important on border-color:var(--border) which overrode the inline
style that _syncThemePicker/etc. set. Fixed by moving to .active CSS class + !important
override on the active state.
Issue: #1059 (settings picker active state)
"""
from pathlib import Path
BOOT_JS = (Path(__file__).parent.parent / "static" / "boot.js").read_text(encoding="utf-8")
STYLE_CSS = (Path(__file__).parent.parent / "static" / "style.css").read_text(encoding="utf-8")
class TestSettingsPickerActiveState:
"""The selected picker card must be visually distinct via the .active class."""
def test_theme_picker_uses_active_class(self):
"""_syncThemePicker must toggle .active class, not set inline borderColor."""
idx = BOOT_JS.find("function _syncThemePicker(")
assert idx >= 0, "_syncThemePicker function not found in boot.js"
body = BOOT_JS[idx:idx + 300]
assert "classList.toggle" in body, (
"_syncThemePicker must use classList.toggle('active', ...) — "
"inline style.borderColor is overridden by !important CSS rules"
)
# Confirm no accent/border2 color values set inline (clearing with '' is OK)
assert "var(--accent)" not in body and "var(--border2)" not in body, (
"_syncThemePicker must not set var(--accent) or var(--border2) inline — "
"those are overridden by !important CSS rules"
)
def test_font_size_picker_uses_active_class(self):
"""_syncFontSizePicker must toggle .active class."""
idx = BOOT_JS.find("function _syncFontSizePicker(")
assert idx >= 0, "_syncFontSizePicker function not found in boot.js"
body = BOOT_JS[idx:idx + 300]
assert "classList.toggle" in body, (
"_syncFontSizePicker must use classList.toggle('active', ...)"
)
assert "var(--accent)" not in body and "var(--border2)" not in body, (
"_syncFontSizePicker must not set var(--accent) or var(--border2) inline"
)
def test_skin_picker_uses_active_class(self):
"""_syncSkinPicker must toggle .active class."""
idx = BOOT_JS.find("function _syncSkinPicker(")
assert idx >= 0, "_syncSkinPicker function not found in boot.js"
body = BOOT_JS[idx:idx + 300]
assert "classList.toggle" in body, (
"_syncSkinPicker must use classList.toggle('active', ...)"
)
assert "var(--accent)" not in body and "var(--border2)" not in body, (
"_syncSkinPicker must not set var(--accent) or var(--border2) inline"
)
def test_css_active_rule_beats_base_rule(self):
"""CSS must have a .active rule with !important that overrides the base border-color rule."""
assert ".theme-pick-btn.active" in STYLE_CSS, (
"style.css must have a .theme-pick-btn.active rule"
)
assert ".font-size-pick-btn.active" in STYLE_CSS, (
"style.css must have a .font-size-pick-btn.active rule"
)
assert ".skin-pick-btn.active" in STYLE_CSS, (
"style.css must have a .skin-pick-btn.active rule"
)
# The active rule must use !important to beat the base !important rule
idx = STYLE_CSS.find(".theme-pick-btn.active")
rule = STYLE_CSS[idx:idx + 200]
assert "!important" in rule, (
".theme-pick-btn.active must use !important to override "
"the base border-color:var(--border)!important rule"
)
def test_active_rule_uses_accent_color(self):
"""The .active rule must apply the accent color to make selection visible."""
idx = STYLE_CSS.find(".theme-pick-btn.active")
rule = STYLE_CSS[idx:idx + 200]
assert "var(--accent)" in rule, (
".theme-pick-btn.active must set border-color to var(--accent)"
)
+94
View File
@@ -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"
)
+15 -2
View File
@@ -33,8 +33,21 @@ class TestSessionPersistence(unittest.TestCase):
sessions_file.unlink()
def _simulate_restart(self) -> None:
"""Reload auth module to simulate a fresh process start."""
importlib.reload(auth)
"""Reload auth module to simulate a fresh process start.
api.auth does `from api.config import STATE_DIR` at module level, so
`_SESSIONS_FILE` is computed from api.config.STATE_DIR at reload time.
We temporarily override api.config.STATE_DIR so the reload uses the
test state dir without reloading api.config itself (which would
invalidate imported references like STREAM_PARTIAL_TEXT in other tests).
"""
import api.config as _config
_saved = _config.STATE_DIR
_config.STATE_DIR = _TEST_STATE
try:
importlib.reload(auth)
finally:
_config.STATE_DIR = _saved
def test_session_survives_restart(self) -> None:
"""A session created before restart should still verify after reload."""
+6 -2
View File
@@ -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, (
+1 -1
View File
@@ -85,7 +85,7 @@ def test_chinese_locale_includes_representative_translations():
"approval_heading: '需要审批'",
"tab_tasks: '任务'",
"tab_profiles: '配置'",
"session_time_just_now: '刚刚'",
"session_time_bucket_today: '今天'",
"onboarding_title: '欢迎使用 Hermes Web UI'",
"onboarding_complete: '引导完成'",
]
+148
View File
@@ -0,0 +1,148 @@
"""Tests for cron session title fallback in get_cli_sessions().
When a CLI session originates from cron and has no title in state.db, the
WebUI sidebar should display the human-friendly job name from cron/jobs.json
instead of a generic "Cron Session" label.
Session ID format produced by hermes-agent: cron_<job_id>_<YYYYMMDD>_<HHMMSS>
"""
import json
import sqlite3
import pytest
import api.models as models
def _make_state_db(path, sessions):
"""Create a state.db with the schema get_cli_sessions() expects.
`sessions` is a list of (id, title, source) tuples.
"""
conn = sqlite3.connect(str(path))
conn.execute("""
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
title TEXT,
model TEXT,
message_count INTEGER,
started_at REAL,
source TEXT
)
""")
conn.execute("""
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
timestamp REAL
)
""")
for sid, title, source in sessions:
conn.execute(
"INSERT INTO sessions (id, title, model, message_count, started_at, source) "
"VALUES (?, ?, ?, ?, ?, ?)",
(sid, title, "gpt-x", 1, 1700000000.0, source),
)
conn.execute(
"INSERT INTO messages (session_id, timestamp) VALUES (?, ?)",
(sid, 1700000001.0),
)
conn.commit()
conn.close()
def _write_jobs_json(hermes_home, jobs):
"""Write cron/jobs.json with the given jobs list."""
cron_dir = hermes_home / "cron"
cron_dir.mkdir(parents=True, exist_ok=True)
(cron_dir / "jobs.json").write_text(
json.dumps({"jobs": jobs}), encoding="utf-8"
)
@pytest.fixture
def fake_hermes_home(tmp_path, monkeypatch):
"""Point get_cli_sessions() at a temporary HERMES_HOME and disable
profile lookups so the test runs hermetically."""
home = tmp_path / "hermes"
home.mkdir()
# Both profile helpers are imported lazily inside get_cli_sessions(),
# so patching the api.profiles module reaches them.
import api.profiles as profiles
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: home)
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: None)
return home
def test_cron_session_uses_job_name_when_title_missing(fake_hermes_home):
"""A cron session with no title should display the friendly job name."""
_write_jobs_json(fake_hermes_home, [
{"id": "cd65df6fc1a8", "name": "wiki-auto-ingest"},
])
_make_state_db(fake_hermes_home / "state.db", [
("cron_cd65df6fc1a8_20260417_191049", None, "cron"),
])
sessions = models.get_cli_sessions()
assert len(sessions) == 1
assert sessions[0]["title"] == "wiki-auto-ingest"
def test_cron_session_falls_back_when_jobs_json_missing(fake_hermes_home):
"""No jobs.json should not crash; title falls back to 'Cron Session'."""
_make_state_db(fake_hermes_home / "state.db", [
("cron_abc123_20260417_191049", None, "cron"),
])
sessions = models.get_cli_sessions()
assert sessions[0]["title"] == "Cron Session"
def test_cron_session_falls_back_when_job_id_not_in_jobs_json(fake_hermes_home):
"""Stale session whose job has been deleted falls back gracefully."""
_write_jobs_json(fake_hermes_home, [
{"id": "different_job", "name": "Some Other Job"},
])
_make_state_db(fake_hermes_home / "state.db", [
("cron_orphan_20260417_191049", None, "cron"),
])
sessions = models.get_cli_sessions()
assert sessions[0]["title"] == "Cron Session"
def test_explicit_title_is_preserved(fake_hermes_home):
"""If state.db already has a title, the cron job lookup should not
override it."""
_write_jobs_json(fake_hermes_home, [
{"id": "cd65df6fc1a8", "name": "wiki-auto-ingest"},
])
_make_state_db(fake_hermes_home / "state.db", [
("cron_cd65df6fc1a8_20260417_191049", "User-edited title", "cron"),
])
sessions = models.get_cli_sessions()
assert sessions[0]["title"] == "User-edited title"
def test_non_cron_sessions_unaffected(fake_hermes_home):
"""The cron-name lookup must not run for cli-source sessions, so the
generic 'Cli Session' fallback still applies when title is empty."""
_write_jobs_json(fake_hermes_home, [
{"id": "cd65df6fc1a8", "name": "wiki-auto-ingest"},
])
# A 'cli' session whose ID coincidentally starts with 'cron_' must not
# pick up the job name — the source check guards against this.
_make_state_db(fake_hermes_home / "state.db", [
("cron_cd65df6fc1a8_xx", None, "cli"),
])
sessions = models.get_cli_sessions()
assert sessions[0]["title"] == "Cli Session"
+15 -3
View File
@@ -192,10 +192,22 @@ class TestOnboardingStatusUnsupportedProvider:
"config.yaml + chat_ready=True must auto-complete onboarding regardless of provider."
)
def test_minimax_cn_not_ready_shows_wizard(self):
"""minimax-cn + chat_ready=False → wizard fires so user can fix it."""
def test_minimax_cn_not_ready_skips_wizard(self):
"""minimax-cn + chat_ready=False → wizard still skipped for non-wizard providers.
The onboarding wizard has no minimax-cn option showing it would only confuse
the user or let them accidentally overwrite their config with an OpenAI/Anthropic
provider. For any provider not in _SUPPORTED_PROVIDER_SETUPS, onboarding is
auto-completed as long as provider_configured is True, regardless of chat_ready.
Users on non-wizard providers with no API key should fix credentials via
Settings Providers, not via the first-run wizard. (#1020)
"""
result = self._make_status(chat_ready=False)
assert result["completed"] is False
assert result["completed"] is True, (
"Wizard fired for minimax-cn user with provider_configured=True! "
"Non-wizard providers must auto-complete onboarding because the wizard "
"cannot configure them and would silently overwrite their config."
)
def test_current_is_oauth_set_for_unsupported_provider(self):
"""setup.current_is_oauth must be True for minimax-cn (not in quick-setup list)."""
+376
View File
@@ -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
View File
@@ -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:
+1 -1
View File
@@ -85,7 +85,7 @@ def test_russian_locale_includes_representative_translations():
"approval_heading: '\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u0435'",
"tab_tasks: '\u0417\u0430\u0434\u0430\u0447\u0438'",
"tab_profiles: '\u041f\u0440\u043e\u0444\u0438\u043b\u0438'",
"session_time_just_now: '\u0442\u043e\u043b\u044c\u043a\u043e \u0447\u0442\u043e'",
"session_time_bucket_today: '\u0421\u0435\u0433\u043e\u0434\u043d\u044f'",
"onboarding_title: '\u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c \u0432 Hermes Web UI'",
"onboarding_complete: '\u041f\u0435\u0440\u0432\u0438\u0447\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0430'",
"profile_default_label: '\u0028\u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e\u0029'",
+8 -10
View File
@@ -42,11 +42,10 @@ def _run_session_time_case(script_body: str) -> dict:
process.env.TZ = 'UTC';
const translations = {{
session_time_unknown: 'Unknown',
session_time_just_now: 'just now',
session_time_minutes_ago: (n) => `${{n}} minute${{n === 1 ? '' : 's'}} ago`,
session_time_hours_ago: (n) => `${{n}} hour${{n === 1 ? '' : 's'}} ago`,
session_time_days_ago: (n) => `${{n}} day${{n === 1 ? '' : 's'}} ago`,
session_time_last_week: 'last week',
session_time_minutes_ago: (n) => `${{n}}m`,
session_time_hours_ago: (n) => `${{n}}h`,
session_time_days_ago: (n) => `${{n}}d`,
session_time_last_week: '1w',
session_time_bucket_today: 'Today',
session_time_bucket_yesterday: 'Yesterday',
session_time_bucket_this_week: 'This week',
@@ -117,7 +116,7 @@ def test_relative_time_uses_calendar_boundaries_and_year_for_old_sessions():
}));
"""
)
assert result["relative"] == "2 days ago"
assert result["relative"] == "2d"
assert result["bucket"] == "This week"
assert "2024" in result["oldDate"]
@@ -134,7 +133,7 @@ def test_relative_time_today_bucket():
}));
"""
)
assert result["relative"] == "2 hours ago"
assert result["relative"] == "2h"
assert result["bucket"] == "Today"
@@ -151,15 +150,14 @@ def test_relative_time_handles_just_now_and_dst_safe_yesterday_boundary():
}));
"""
)
assert result["justNow"] == "just now"
assert result["yesterday"] == "Yesterday"
assert result["justNow"] == "1m"
assert result["yesterday"] == "1d"
assert result["yesterdayBucket"] == "Yesterday"
def test_relative_time_strings_are_localized_in_english_and_spanish_bundles():
for key in (
"session_time_unknown",
"session_time_just_now",
"session_time_minutes_ago",
"session_time_hours_ago",
"session_time_days_ago",
+39
View File
@@ -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)"
)