diff --git a/CHANGELOG.md b/CHANGELOG.md index ad50b6df..7f7719fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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 diff --git a/ROADMAP.md b/ROADMAP.md index 1b42476d..23709130 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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: / diff --git a/TESTING.md b/TESTING.md index 88de9d4d..ea140b7f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,7 +8,7 @@ > Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser. > Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}. > -> Automated coverage: 2169 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation. +> Automated coverage: 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. diff --git a/api/config.py b/api/config.py index cd90356d..99e974bf 100644 --- a/api/config.py +++ b/api/config.py @@ -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", diff --git a/api/models.py b/api/models.py index f99a66c2..c71dcbbf 100644 --- a/api/models.py +++ b/api/models.py @@ -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, diff --git a/api/onboarding.py b/api/onboarding.py index d0ac572a..05cc9b4b 100644 --- a/api/onboarding.py +++ b/api/onboarding.py @@ -29,6 +29,7 @@ logger = logging.getLogger(__name__) _SUPPORTED_PROVIDER_SETUPS = { + # ── Easy start ────────────────────────────────────────────────────── "openrouter": { "label": "OpenRouter", "env_var": "OPENROUTER_API_KEY", @@ -37,6 +38,8 @@ _SUPPORTED_PROVIDER_SETUPS = { "models": [ {"id": model["id"], "label": model["label"]} for model in _FALLBACK_MODELS ], + "category": "easy_start", + "quick": True, }, "anthropic": { "label": "Anthropic", @@ -44,6 +47,7 @@ _SUPPORTED_PROVIDER_SETUPS = { "default_model": "claude-sonnet-4.6", "requires_base_url": False, "models": list(_PROVIDER_MODELS.get("anthropic", [])), + "category": "easy_start", }, "openai": { "label": "OpenAI", @@ -52,6 +56,26 @@ _SUPPORTED_PROVIDER_SETUPS = { "default_base_url": "https://api.openai.com/v1", "requires_base_url": False, "models": list(_PROVIDER_MODELS.get("openai", [])), + "category": "easy_start", + }, + # ── Open / self-hosted ───────────────────────────────────────────── + "ollama": { + "label": "Ollama", + "env_var": "OLLAMA_API_KEY", + "default_model": "qwen3:32b", + "default_base_url": "http://localhost:11434/v1", + "requires_base_url": True, + "models": [], + "category": "self_hosted", + }, + "lmstudio": { + "label": "LM Studio", + "env_var": "LMSTUDIO_API_KEY", + "default_model": "gpt-4o-mini", + "default_base_url": "http://localhost:1234/v1", + "requires_base_url": True, + "models": [], + "category": "self_hosted", }, "custom": { "label": "Custom OpenAI-compatible", @@ -59,9 +83,59 @@ _SUPPORTED_PROVIDER_SETUPS = { "default_model": "gpt-4o-mini", "requires_base_url": True, "models": [], + "category": "self_hosted", + }, + # ── Specialized / extended ────────────────────────────────────────── + "gemini": { + "label": "Google Gemini", + "env_var": "GOOGLE_API_KEY", + "default_model": "gemini-3.1-pro-preview", + "default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai", + "requires_base_url": False, + # _PROVIDER_MODELS in api/config.py is keyed under "google" even though + # the agent's alias map normalizes "google" → "gemini". Use the catalog + # key here so the wizard surfaces the actual model list. + "models": list(_PROVIDER_MODELS.get("google", [])), + "category": "specialized", + }, + "deepseek": { + "label": "DeepSeek", + "env_var": "DEEPSEEK_API_KEY", + "default_model": "deepseek-chat-v3-0324", + "default_base_url": "https://api.deepseek.com/v1", + "requires_base_url": False, + "models": list(_PROVIDER_MODELS.get("deepseek", [])), + "category": "specialized", + }, + "mistralai": { + "label": "Mistral", + "env_var": "MISTRAL_API_KEY", + "default_model": "mistral-large-latest", + "default_base_url": "https://api.mistral.ai/v1", + "requires_base_url": False, + # No catalog entry for mistralai today — wizard shows a free-text input. + "models": list(_PROVIDER_MODELS.get("mistralai", [])), + "category": "specialized", + }, + "x-ai": { + "label": "xAI (Grok)", + "env_var": "XAI_API_KEY", + "default_model": "grok-4.20", + "default_base_url": "https://api.x.ai/v1", + "requires_base_url": False, + # Agent normalizes "x-ai" → "xai"; _PROVIDER_MODELS is also keyed "xai" + # when populated, so check both keys for forward-compatibility. + "models": list(_PROVIDER_MODELS.get("xai", []) or _PROVIDER_MODELS.get("x-ai", [])), + "category": "specialized", }, } +_PROVIDER_CATEGORIES = [ + {"id": "easy_start", "label": "Easy start", "order": 0}, + {"id": "self_hosted", "label": "Open / self-hosted", "order": 1}, + {"id": "specialized", "label": "Specialized", "order": 2}, +] + _UNSUPPORTED_PROVIDER_NOTE = ( "OAuth and advanced provider flows such as Nous Portal, OpenAI Codex, and GitHub " "Copilot are still terminal-first. Use `hermes model` for those flows." @@ -384,10 +458,24 @@ def _build_setup_catalog(cfg: dict) -> dict: "default_base_url": meta.get("default_base_url") or "", "requires_base_url": bool(meta.get("requires_base_url")), "models": list(meta.get("models", [])), - "quick": provider_id == "openrouter", + "category": meta.get("category", "easy_start"), + "quick": meta.get("quick", False), } ) + # Sort providers by category order, then alphabetically within each category. + cat_order = {c["id"]: c["order"] for c in _PROVIDER_CATEGORIES} + providers.sort(key=lambda p: (cat_order.get(p["category"], 99), p["label"])) + + # Group providers by category for the frontend. + categories = [] + for cat in sorted(_PROVIDER_CATEGORIES, key=lambda c: c["order"]): + categories.append({ + "id": cat["id"], + "label": cat["label"], + "providers": [p["id"] for p in providers if p["category"] == cat["id"]], + }) + # Flag whether the currently-configured provider is OAuth-based (not in the # API-key flow). The frontend uses this to show a confirmation card instead # of a key input when the user has already authenticated via 'hermes auth'. @@ -397,6 +485,7 @@ def _build_setup_catalog(cfg: dict) -> dict: return { "providers": providers, + "categories": categories, "unsupported_note": _UNSUPPORTED_PROVIDER_NOTE, "current_is_oauth": current_is_oauth, "current": { @@ -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) diff --git a/api/routes.py b/api/routes.py index b6105dfd..ecdcbd39 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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, diff --git a/api/streaming.py b/api/streaming.py index a999e4c6..a4b0e360 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -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() diff --git a/static/boot.js b/static/boot.js index 789d0fae..94a85aa8 100644 --- a/static/boot.js +++ b/static/boot.js @@ -42,6 +42,8 @@ function _setWorkspacePanelMode(mode){ const open=_workspacePanelMode!=='closed'; document.documentElement.dataset.workspacePanel=open?'open':'closed'; // Persist open/closed across refreshes (browse/preview → open; closed → closed) + // Do NOT overwrite the user's "keep open" preference — only track runtime state + // so that toggleWorkspacePanel(false) from the toolbar doesn't clear the setting. localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed'); layout.classList.toggle('workspace-panel-collapsed',!open); if(_isCompactWorkspaceViewport()){ @@ -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 (_) {} }); diff --git a/static/i18n.js b/static/i18n.js index b2b16da8..fd8cc5df 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -239,6 +239,15 @@ const LOCALES = { settings_section_preferences_meta: 'Defaults and UI behavior for Hermes Web UI.', settings_section_system_title: 'System', settings_section_system_meta: 'Instance version and access controls.', + settings_check_now: 'Check now', + settings_checking: 'Checking\u2026', + settings_up_to_date: 'Up to date \u2713', + settings_updates_available: '{count} update(s) available', + settings_updates_disabled: 'Update checks disabled', + settings_update_check_failed: 'Update check failed', + settings_label_workspace_panel_open: 'Keep workspace panel open by default', + settings_desc_workspace_panel_open: 'When enabled, the workspace / file browser panel opens automatically with each new session. You can still close it manually at any time.', + open_in_browser: 'Open in browser', settings_dropdown_conversation: 'Conversation', settings_dropdown_appearance: 'Appearance', settings_dropdown_preferences: 'Preferences', @@ -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', diff --git a/static/index.html b/static/index.html index 8f682af2..6c11da75 100644 --- a/static/index.html +++ b/static/index.html @@ -592,6 +592,13 @@ +
+ +
When enabled, the workspace / file browser panel opens automatically with each new session. You can still close it manually at any time.
+
@@ -645,6 +652,16 @@
Controls how much metadata the session list shows in the left sidebar.
+
+ + +
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.
+
- +
+ + + +
@@ -728,12 +749,16 @@
+

       
       
+      
       
     
diff --git a/static/login.js b/static/login.js index 9345e455..bba29b50 100644 --- a/static/login.js +++ b/static/login.js @@ -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); } diff --git a/static/onboarding.js b/static/onboarding.js index b10de996..08f4a153 100644 --- a/static/onboarding.js +++ b/static/onboarding.js @@ -8,6 +8,30 @@ function _getOnboardingSetupProvider(id){ return _getOnboardingSetupProviders().find(p=>p.id===id)||null; } +function _getOnboardingSetupCategories(){ + return (((ONBOARDING.status||{}).setup||{}).categories)||[]; +} + +/** Render the provider ${options} +