mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 02:36:27 +00:00
Merge branch 'nesquena:master' into conversation_switching_perf2
This commit is contained in:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 CLI(state.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
@@ -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
@@ -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
@@ -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||'';
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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://...')"
|
||||
)
|
||||
@@ -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 })"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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: '引导完成'",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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)."""
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
"""Tests for #603 — categorize providers in setup wizard.
|
||||
|
||||
Validates:
|
||||
- New providers added to _SUPPORTED_PROVIDER_SETUPS with correct categories
|
||||
- _PROVIDER_CATEGORIES ordering and IDs
|
||||
- _build_setup_catalog returns grouped categories
|
||||
- apply_onboarding_setup writes base_url for requires_base_url providers
|
||||
- apply_onboarding_setup writes default_base_url for providers with one
|
||||
- Frontend helper _renderProviderSelectOptions produces <optgroup>
|
||||
- i18n keys exist for all category labels
|
||||
- Fallback when categories are empty
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys, os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from api.onboarding import (
|
||||
_SUPPORTED_PROVIDER_SETUPS,
|
||||
_PROVIDER_CATEGORIES,
|
||||
_build_setup_catalog,
|
||||
apply_onboarding_setup,
|
||||
)
|
||||
|
||||
|
||||
# ── Backend: provider catalog structure ──────────────────────────────────
|
||||
|
||||
class TestProviderCatalog:
|
||||
"""Verify the extended provider catalog has categories."""
|
||||
|
||||
def test_all_providers_have_category(self):
|
||||
for pid, meta in _SUPPORTED_PROVIDER_SETUPS.items():
|
||||
assert "category" in meta, f"Provider {pid} missing 'category'"
|
||||
|
||||
def test_categories_are_valid(self):
|
||||
valid_ids = {c["id"] for c in _PROVIDER_CATEGORIES}
|
||||
for pid, meta in _SUPPORTED_PROVIDER_SETUPS.items():
|
||||
cat = meta["category"]
|
||||
assert cat in valid_ids, f"Provider {pid} has invalid category '{cat}'"
|
||||
|
||||
def test_easy_start_has_core_providers(self):
|
||||
easy = {
|
||||
pid
|
||||
for pid, meta in _SUPPORTED_PROVIDER_SETUPS.items()
|
||||
if meta["category"] == "easy_start"
|
||||
}
|
||||
assert "openrouter" in easy
|
||||
assert "anthropic" in easy
|
||||
assert "openai" in easy
|
||||
|
||||
def test_self_hosted_has_local_providers(self):
|
||||
local = {
|
||||
pid
|
||||
for pid, meta in _SUPPORTED_PROVIDER_SETUPS.items()
|
||||
if meta["category"] == "self_hosted"
|
||||
}
|
||||
assert "ollama" in local
|
||||
assert "lmstudio" in local
|
||||
assert "custom" in local
|
||||
|
||||
def test_specialized_has_extended_providers(self):
|
||||
spec = {
|
||||
pid
|
||||
for pid, meta in _SUPPORTED_PROVIDER_SETUPS.items()
|
||||
if meta["category"] == "specialized"
|
||||
}
|
||||
assert "gemini" in spec
|
||||
assert "deepseek" in spec
|
||||
assert "mistralai" in spec
|
||||
assert "x-ai" in spec
|
||||
|
||||
def test_new_providers_exist(self):
|
||||
expected = {"ollama", "lmstudio", "gemini", "deepseek", "mistralai", "x-ai"}
|
||||
assert expected.issubset(_SUPPORTED_PROVIDER_SETUPS.keys())
|
||||
|
||||
def test_new_providers_have_env_vars(self):
|
||||
for pid in ["ollama", "lmstudio", "gemini", "deepseek", "mistralai", "x-ai"]:
|
||||
meta = _SUPPORTED_PROVIDER_SETUPS[pid]
|
||||
assert meta["env_var"], f"Provider {pid} missing env_var"
|
||||
assert meta["default_model"], f"Provider {pid} missing default_model"
|
||||
|
||||
def test_local_providers_require_base_url(self):
|
||||
for pid in ["ollama", "lmstudio", "custom"]:
|
||||
assert _SUPPORTED_PROVIDER_SETUPS[pid]["requires_base_url"]
|
||||
|
||||
def test_specialized_providers_have_base_url_defaults(self):
|
||||
for pid in ["gemini", "deepseek", "mistralai", "x-ai"]:
|
||||
meta = _SUPPORTED_PROVIDER_SETUPS[pid]
|
||||
assert meta["default_base_url"], f"Provider {pid} missing default_base_url"
|
||||
|
||||
def test_google_uses_gemini_key(self):
|
||||
"""Google Gemini must use 'gemini' as provider ID (matches Hermes CLI)."""
|
||||
assert "gemini" in _SUPPORTED_PROVIDER_SETUPS
|
||||
assert "google" not in _SUPPORTED_PROVIDER_SETUPS
|
||||
|
||||
def test_gemini_model_list_is_populated(self):
|
||||
"""The gemini provider's `models` list must be non-empty.
|
||||
|
||||
Regression: api/config.py:_PROVIDER_MODELS uses key "google" (not
|
||||
"gemini") for the model catalog. If the wizard does
|
||||
_PROVIDER_MODELS.get("gemini", []) it gets an empty list and the
|
||||
provider dropdown has no model options. The provider catalog must
|
||||
look up the right key.
|
||||
"""
|
||||
gemini = _SUPPORTED_PROVIDER_SETUPS["gemini"]
|
||||
assert len(gemini["models"]) > 0, (
|
||||
"gemini provider must surface a non-empty model list — check the "
|
||||
"_PROVIDER_MODELS lookup key (catalog uses 'google', not 'gemini')"
|
||||
)
|
||||
|
||||
def test_specialized_default_models_match_catalog(self):
|
||||
"""default_model values for specialized providers must reference real
|
||||
models in the agent's catalog (or be the latest known version).
|
||||
|
||||
Regression: previously had `gemini-2.5-pro-preview` (agent catalog has
|
||||
3.1) and `grok-3` (agent catalog has 4.20). Stale defaults landed users
|
||||
on non-existent models that produced 404s on first chat.
|
||||
"""
|
||||
gemini_default = _SUPPORTED_PROVIDER_SETUPS["gemini"]["default_model"]
|
||||
assert gemini_default.startswith("gemini-3."), (
|
||||
f"gemini default_model={gemini_default!r} is stale — agent catalog has 3.1 family"
|
||||
)
|
||||
xai_default = _SUPPORTED_PROVIDER_SETUPS["x-ai"]["default_model"]
|
||||
assert xai_default.startswith("grok-4"), (
|
||||
f"x-ai default_model={xai_default!r} is stale — agent catalog has 4.20 family"
|
||||
)
|
||||
deepseek_default = _SUPPORTED_PROVIDER_SETUPS["deepseek"]["default_model"]
|
||||
# deepseek-chat (rolling) or deepseek-chat-v3-0324 (pinned) both valid
|
||||
assert deepseek_default.startswith("deepseek-"), (
|
||||
f"deepseek default_model={deepseek_default!r} must start with 'deepseek-'"
|
||||
)
|
||||
|
||||
|
||||
class TestProviderCategoryOrder:
|
||||
"""Verify category ordering."""
|
||||
|
||||
def test_categories_sorted_by_order(self):
|
||||
orders = [c["order"] for c in _PROVIDER_CATEGORIES]
|
||||
assert orders == sorted(orders)
|
||||
|
||||
def test_category_ids(self):
|
||||
ids = {c["id"] for c in _PROVIDER_CATEGORIES}
|
||||
assert ids == {"easy_start", "self_hosted", "specialized"}
|
||||
|
||||
def test_three_categories(self):
|
||||
assert len(_PROVIDER_CATEGORIES) == 3
|
||||
|
||||
|
||||
# ── Backend: _build_setup_catalog ────────────────────────────────────────
|
||||
|
||||
class TestBuildSetupCatalog:
|
||||
def test_catalog_has_categories_key(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
assert "categories" in catalog
|
||||
assert isinstance(catalog["categories"], list)
|
||||
|
||||
def test_catalog_categories_have_providers_list(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
all_provider_ids = {p["id"] for p in catalog["providers"]}
|
||||
for cat in catalog["categories"]:
|
||||
assert "providers" in cat
|
||||
for pid in cat["providers"]:
|
||||
assert pid in all_provider_ids
|
||||
|
||||
def test_catalog_providers_have_category_field(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
for p in catalog["providers"]:
|
||||
assert "category" in p
|
||||
|
||||
def test_catalog_providers_sorted_by_category(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
cat_order = {c["id"]: c["order"] for c in _PROVIDER_CATEGORIES}
|
||||
prev_order = -1
|
||||
for p in catalog["providers"]:
|
||||
order = cat_order.get(p["category"], 99)
|
||||
assert order >= prev_order, f"Provider {p['id']} out of order"
|
||||
prev_order = order
|
||||
|
||||
def test_catalog_quick_flag_on_openrouter(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
orow = next(p for p in catalog["providers"] if p["id"] == "openrouter")
|
||||
assert orow["quick"] is True
|
||||
|
||||
def test_catalog_no_quick_flag_on_others(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
for p in catalog["providers"]:
|
||||
if p["id"] != "openrouter":
|
||||
assert p["quick"] is False
|
||||
|
||||
|
||||
# ── Backend: apply_onboarding_setup base_url handling ───────────────────
|
||||
|
||||
class TestApplyBaseURL:
|
||||
"""Verify the generic base_url save logic."""
|
||||
|
||||
def test_requires_base_url_writes_user_url(self, tmp_path, monkeypatch):
|
||||
"""Providers with requires_base_url=True should write user-provided base_url."""
|
||||
config_path = str(tmp_path / "config.yaml")
|
||||
env_path = str(tmp_path / ".env")
|
||||
|
||||
monkeypatch.setattr("api.onboarding._get_config_path", lambda: config_path)
|
||||
monkeypatch.setattr("api.onboarding._get_active_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr("api.onboarding._load_yaml_config", lambda p: {})
|
||||
monkeypatch.setattr(
|
||||
"api.onboarding._normalize_model_for_provider", lambda prov, m: m
|
||||
)
|
||||
monkeypatch.setattr("api.onboarding._write_env_file", lambda p, d: None)
|
||||
monkeypatch.setattr("api.onboarding._save_yaml_config", lambda p, c: None)
|
||||
monkeypatch.setattr("api.onboarding._provider_api_key_present", lambda *a: True)
|
||||
monkeypatch.setattr("api.onboarding.reload_config", lambda: None)
|
||||
|
||||
saved_cfg = {}
|
||||
def mock_save(p, cfg):
|
||||
saved_cfg.update(cfg)
|
||||
monkeypatch.setattr("api.onboarding._save_yaml_config", mock_save)
|
||||
|
||||
apply_onboarding_setup({
|
||||
"provider": "ollama",
|
||||
"model": "qwen3:32b",
|
||||
"api_key": "test-key",
|
||||
"base_url": "http://my-ollama:11434/v1",
|
||||
"confirm_overwrite": True,
|
||||
})
|
||||
|
||||
assert saved_cfg["model"]["base_url"] == "http://my-ollama:11434/v1"
|
||||
|
||||
def test_default_base_url_written_for_openai(self, tmp_path, monkeypatch):
|
||||
"""OpenAI should get its default_base_url written to config."""
|
||||
config_path = str(tmp_path / "config.yaml")
|
||||
|
||||
monkeypatch.setattr("api.onboarding._get_config_path", lambda: config_path)
|
||||
monkeypatch.setattr("api.onboarding._get_active_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr("api.onboarding._load_yaml_config", lambda p: {})
|
||||
monkeypatch.setattr(
|
||||
"api.onboarding._normalize_model_for_provider", lambda prov, m: m
|
||||
)
|
||||
monkeypatch.setattr("api.onboarding._write_env_file", lambda p, d: None)
|
||||
monkeypatch.setattr("api.onboarding._provider_api_key_present", lambda *a: True)
|
||||
monkeypatch.setattr("api.onboarding.reload_config", lambda: None)
|
||||
|
||||
saved_cfg = {}
|
||||
def mock_save(p, cfg):
|
||||
saved_cfg.update(cfg)
|
||||
monkeypatch.setattr("api.onboarding._save_yaml_config", mock_save)
|
||||
|
||||
apply_onboarding_setup({
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o",
|
||||
"api_key": "test-key",
|
||||
"confirm_overwrite": True,
|
||||
})
|
||||
|
||||
assert saved_cfg["model"]["base_url"] == "https://api.openai.com/v1"
|
||||
|
||||
def test_base_url_stripped_for_anthropic(self, tmp_path, monkeypatch):
|
||||
"""Anthropic should NOT have base_url in config (Hermes knows the URL)."""
|
||||
config_path = str(tmp_path / "config.yaml")
|
||||
|
||||
monkeypatch.setattr("api.onboarding._get_config_path", lambda: config_path)
|
||||
monkeypatch.setattr("api.onboarding._get_active_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr("api.onboarding._load_yaml_config", lambda p: {})
|
||||
monkeypatch.setattr(
|
||||
"api.onboarding._normalize_model_for_provider", lambda prov, m: m
|
||||
)
|
||||
monkeypatch.setattr("api.onboarding._write_env_file", lambda p, d: None)
|
||||
monkeypatch.setattr("api.onboarding._provider_api_key_present", lambda *a: True)
|
||||
monkeypatch.setattr("api.onboarding.reload_config", lambda: None)
|
||||
|
||||
saved_cfg = {}
|
||||
def mock_save(p, cfg):
|
||||
saved_cfg.update(cfg)
|
||||
monkeypatch.setattr("api.onboarding._save_yaml_config", mock_save)
|
||||
|
||||
apply_onboarding_setup({
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4.6",
|
||||
"api_key": "test-key",
|
||||
"confirm_overwrite": True,
|
||||
})
|
||||
|
||||
assert "base_url" not in saved_cfg["model"]
|
||||
|
||||
|
||||
# ── Frontend: i18n keys ─────────────────────────────────────────────────
|
||||
|
||||
class TestI18nCategoryKeys:
|
||||
def test_en_has_all_category_keys(self):
|
||||
with open("static/i18n.js", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
for key in ["provider_category_easy_start", "provider_category_self_hosted", "provider_category_specialized"]:
|
||||
assert f"{key}:" in content, f"Missing i18n key: {key}"
|
||||
|
||||
def test_ru_has_all_category_keys(self):
|
||||
with open("static/i18n.js", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
# Just verify count of category keys (should appear 6+ times: once per locale block)
|
||||
assert content.count("provider_category_easy_start:") >= 4
|
||||
|
||||
def test_es_has_all_category_keys(self):
|
||||
with open("static/i18n.js", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
assert "Inicio rápido" in content # Spanish easy_start
|
||||
|
||||
def test_zh_has_all_category_keys(self):
|
||||
with open("static/i18n.js", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
assert "快速开始" in content # Chinese easy_start
|
||||
|
||||
def test_zh_hant_has_all_category_keys(self):
|
||||
with open("static/i18n.js", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
assert "\\u5feb\\u901f\\u958b\\u59cb" in content # zh-Hant easy_start
|
||||
|
||||
|
||||
class TestApplyBaseURLSpecialized:
|
||||
"""Verify apply_onboarding_setup sets base_url for specialized providers."""
|
||||
|
||||
_PROVIDER_DEFAULT_MODELS = {
|
||||
"gemini": "gemini-3.1-pro-preview",
|
||||
"deepseek": "deepseek-chat-v3-0324",
|
||||
"mistralai": "mistral-large-latest",
|
||||
"x-ai": "grok-4.20",
|
||||
}
|
||||
|
||||
def _run_setup(self, tmp_path, monkeypatch, provider):
|
||||
"""Run apply_onboarding_setup with the given provider and return saved_cfg."""
|
||||
config_path = str(tmp_path / "config.yaml")
|
||||
model = self._PROVIDER_DEFAULT_MODELS.get(provider, "test-model")
|
||||
|
||||
monkeypatch.setattr("api.onboarding._get_config_path", lambda: config_path)
|
||||
monkeypatch.setattr("api.onboarding._get_active_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr("api.onboarding._load_yaml_config", lambda p: {})
|
||||
monkeypatch.setattr("api.onboarding._normalize_model_for_provider", lambda prov, m: m)
|
||||
monkeypatch.setattr("api.onboarding._write_env_file", lambda p, d: None)
|
||||
monkeypatch.setattr("api.onboarding._provider_api_key_present", lambda *a: True)
|
||||
monkeypatch.setattr("api.onboarding.reload_config", lambda: None)
|
||||
|
||||
saved_cfg = {}
|
||||
def mock_save(p, cfg):
|
||||
saved_cfg.update(cfg)
|
||||
monkeypatch.setattr("api.onboarding._save_yaml_config", mock_save)
|
||||
|
||||
from api.onboarding import apply_onboarding_setup
|
||||
apply_onboarding_setup({"provider": provider, "model": model, "api_key": "test-key", "confirm_overwrite": True})
|
||||
return saved_cfg
|
||||
|
||||
def test_gemini_gets_default_base_url(self, tmp_path, monkeypatch):
|
||||
saved = self._run_setup(tmp_path, monkeypatch, "gemini")
|
||||
assert "generativelanguage.googleapis.com" in saved.get("model", {}).get("base_url", ""), (
|
||||
"gemini setup must write the Gemini base_url to config"
|
||||
)
|
||||
|
||||
def test_deepseek_gets_default_base_url(self, tmp_path, monkeypatch):
|
||||
saved = self._run_setup(tmp_path, monkeypatch, "deepseek")
|
||||
assert "deepseek.com" in saved.get("model", {}).get("base_url", ""), (
|
||||
"deepseek setup must write the DeepSeek base_url to config"
|
||||
)
|
||||
|
||||
def test_mistral_gets_default_base_url(self, tmp_path, monkeypatch):
|
||||
saved = self._run_setup(tmp_path, monkeypatch, "mistralai")
|
||||
assert "mistral.ai" in saved.get("model", {}).get("base_url", ""), (
|
||||
"mistral setup must write the Mistral base_url to config"
|
||||
)
|
||||
|
||||
def test_x_ai_gets_default_base_url(self, tmp_path, monkeypatch):
|
||||
saved = self._run_setup(tmp_path, monkeypatch, "x-ai")
|
||||
assert "x.ai" in saved.get("model", {}).get("base_url", ""), (
|
||||
"x-ai setup must write the xAI base_url to config"
|
||||
)
|
||||
+15
-1
@@ -18,15 +18,29 @@ def _isolate_models_cache():
|
||||
|
||||
|
||||
def _available_models_with_cfg(cfg_override):
|
||||
"""Helper: temporarily patch config.cfg, call get_available_models(), restore."""
|
||||
"""Helper: temporarily patch config.cfg, call get_available_models(), restore.
|
||||
|
||||
We also freeze _cfg_mtime to the *current* config file mtime so that
|
||||
get_available_models() does not call reload_config() from disk (which
|
||||
would overwrite the in-memory mock with the on-disk config.yaml).
|
||||
See #644 — this race exists in CI where config.yaml is present.
|
||||
"""
|
||||
old_cfg = dict(_cfg.cfg)
|
||||
_cfg.cfg.clear()
|
||||
_cfg.cfg.update(cfg_override)
|
||||
# Freeze mtime so reload_config() is not triggered inside get_available_models()
|
||||
old_mtime = _cfg._cfg_mtime
|
||||
try:
|
||||
from pathlib import Path
|
||||
_cfg._cfg_mtime = Path(_cfg._get_config_path()).stat().st_mtime
|
||||
except OSError:
|
||||
_cfg._cfg_mtime = 0.0
|
||||
try:
|
||||
return _cfg.get_available_models()
|
||||
finally:
|
||||
_cfg.cfg.clear()
|
||||
_cfg.cfg.update(old_cfg)
|
||||
_cfg._cfg_mtime = old_mtime
|
||||
|
||||
|
||||
class TestConfigYamlModelsLoading:
|
||||
|
||||
@@ -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'",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -471,3 +471,42 @@ class TestForceButtonResetOnRetry:
|
||||
assert "display='none'" in setup or "display = 'none'" in setup, (
|
||||
"applyUpdates setup must hide btnForceUpdate via display:none"
|
||||
)
|
||||
|
||||
|
||||
# ── #785: Manual 'Check for Updates' button ───────────────────────────────────
|
||||
|
||||
class TestCheckForUpdatesButton:
|
||||
"""#785: Ensure the 'Check for Updates' button is wired up correctly."""
|
||||
|
||||
def test_checkUpdatesNow_defined_in_panels(self):
|
||||
"""checkUpdatesNow() function must exist in panels.js."""
|
||||
src = read('static/panels.js')
|
||||
assert 'function checkUpdatesNow' in src or 'async function checkUpdatesNow' in src, (
|
||||
"checkUpdatesNow() not found in panels.js"
|
||||
)
|
||||
|
||||
def test_btnCheckUpdatesNow_in_html(self):
|
||||
"""Button element with id='btnCheckUpdatesNow' must exist in index.html."""
|
||||
src = read('static/index.html')
|
||||
assert 'id="btnCheckUpdatesNow"' in src, (
|
||||
"btnCheckUpdatesNow element not found in index.html"
|
||||
)
|
||||
|
||||
def test_checkUpdatesBlock_css_exists(self):
|
||||
"""CSS rules for #checkUpdatesBlock and .btn-tiny must exist in style.css."""
|
||||
src = read('static/style.css')
|
||||
assert '#checkUpdatesBlock' in src, (
|
||||
"#checkUpdatesBlock CSS selector not found in style.css"
|
||||
)
|
||||
assert '.btn-tiny' in src, (
|
||||
".btn-tiny CSS selector not found in style.css"
|
||||
)
|
||||
|
||||
def test_check_now_i18n_key_exists(self):
|
||||
"""settings_check_now i18n key must exist in all locale blocks."""
|
||||
src = read('static/i18n.js')
|
||||
count = src.count('settings_check_now')
|
||||
assert count >= 5, (
|
||||
f"settings_check_now found in only {count} locale blocks (expected ≥5: en, ru, es, zh, zh-Hant)"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user