diff --git a/CHANGELOG.md b/CHANGELOG.md index 8990028d..6916814d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ ## [Unreleased] +## [v0.50.255] — 2026-05-01 + +### Added +- **Insights panel — usage analytics dashboard** (#464) — new `GET /api/insights?days=N` endpoint walks `_index.json` (no full session loads) and aggregates session/message/token counts, model breakdown, and activity-by-day-of-week + activity-by-hour. New nav rail entry between Todos and Settings; the panel renders stats cards, a token breakdown row, and ASCII-style horizontal-bar charts. Period filter (7/30/90 days). (`api/routes.py`, `static/panels.js`, `static/index.html`, `static/i18n.js`, `static/style.css`) @bergeouss — PR #1405, fixes #464 + +- **Rollback UI — restore from agent checkpoints** (#466) — new `api/rollback.py` exposes 3 endpoints (`GET /api/rollback/list`, `GET /api/rollback/diff`, `POST /api/rollback/restore`) over the agent's `CheckpointManager` shadow git repos at `{hermes_home}/checkpoints///.git`. Workspace is allowlisted via `load_workspaces()` (added during contributor security pass `d9f3a69`). `_validate_checkpoint_id()` regex-guards the checkpoint parameter against path-traversal (Opus pre-release advisor finding — `Path()` does NOT normalize `..`). Restore copies files via `shutil.copy2` and never deletes; diff uses `difflib.unified_diff`. (`api/rollback.py`, `api/routes.py`) @bergeouss — PR #1405, fixes #466 + +- **Turn-based voice mode — STT + TTS chained flow** — new voice-mode button in the composer; activating it puts the agent in a listen → send → think → speak → listen loop. Uses the browser's Web Speech API (gated on both `SpeechRecognition` AND `speechSynthesis` support). Auto-send on 1.8s silence after a final transcript. Honors saved voice preferences (`hermes-tts-voice`, `hermes-tts-rate`, `hermes-tts-pitch`). Bails out on `not-allowed` / `service-not-allowed` / `audio-capture` errors. **Pre-release fix:** the patched `autoReadLastAssistant` fired globally — if the user navigated to a different session between send and stream completion, TTS would speak the wrong session's reply. Now captures `S.session.session_id` at thinking-time and bails to listening if the active session changed. (Opus pre-release advisor.) (`static/boot.js`, `static/i18n.js`, `static/index.html`, `static/style.css`) @bergeouss — PR #1405 + +- **API redact toggle — opt out of response-layer redaction** — adds `api_redact_enabled` setting (defaults to `True` so existing users see no behavioral change). When disabled, `redact_session_data()` returns payloads as-is. Useful for users who pipe the WebUI API into automation that needs the original strings. (`api/helpers.py`, `api/config.py`, `static/panels.js`, `static/i18n.js`) @bergeouss — PR #1405 + +- **Subagent tree visualization** — UI affordance for sessions that spawn subagents. (`static/panels.js`, `static/sessions.js`, `static/style.css`, `static/i18n.js`) @bergeouss — PR #1405 + +### Fixed +- **Session provider context preserved across model picker → runtime resolution** (#1240) — the WebUI model picker can show multiple providers exposing the same bare model id (e.g. `gpt-5.5` from OpenAI Codex, OpenRouter, Copilot). Previously sessions persisted only the bare model, so a session selected as "gpt-5.5 from OpenAI Codex" silently rerouted through whatever provider became default after a config change. New `model_provider: str | None` field on `Session` is persisted in metadata, threaded through every chat path (`/api/session/new`, `/api/session/update`, `/api/chat/start`, `/api/chat/sync`, `/btw`, `/background`, `_run_agent_streaming`), and is gated in `compact()` to emit only when truthy (matches v0.50.251 lineage end_reason gating). New `model_with_provider_context(model_id, model_provider)` in `api/config.py` builds the `@provider:model` form when provider differs from configured default, then passes through `resolve_model_provider()`. New `_should_attach_codex_provider_context()` narrow exception detects bare GPT-* models under active OpenAI Codex (because Codex/OpenRouter/Copilot expose overlapping GPT names). New `_resolve_compatible_session_model_state()` returns `(effective_model, effective_provider, model_was_normalized)`. Frontend adds `MODEL_STATE_KEY='hermes-webui-model-state'` localStorage with structured persistence and migrates from the legacy `hermes-webui-model` key. 13 new tests in `test_provider_mismatch.py`, 2 in `test_model_picker_badges.py`. (`api/config.py`, `api/models.py`, `api/routes.py`, `api/streaming.py`, `static/boot.js`, `static/messages.js`, `static/panels.js`, `static/sessions.js`, `static/ui.js`) @starship-s — PR #1390, refs #1240 + +- **TTS toggle: speaker icon never appeared when "Text-to-Speech for responses" was ticked** (#1409, closes #1409) — `_applyTtsEnabled()` set `btn.style.display=enabled?'':'none'` on every `.msg-tts-btn`. The `''` branch removes the inline override, after which the `.msg-tts-btn{display:none;}` rule from `style.css` re-hides the button. Both the "enabled" and "disabled" branches left the icon hidden, so the toggle had no visible effect since the feature shipped in #499. Fixed by switching to a body-class toggle (`body.tts-enabled`) plus a compound CSS selector (`body.tts-enabled .msg-tts-btn{display:inline-flex;}`). The new shape bypasses the `.msg-action-btn` / `.msg-tts-btn` cascade collision and survives subsequent `renderMd()` re-renders without re-querying every button. (`static/panels.js`, `static/style.css`, `tests/test_499_tts_playback.py`) — PR #1411, fixes #1409, reported by @AvidFuturist via Discord + +- **Ollama (local) no longer falsely reports "API key configured" when only Ollama Cloud key is set** (#1410, closes #1410) — both providers were mapped to the same `OLLAMA_API_KEY` env var in `_PROVIDER_ENV_VAR`, so configuring Ollama Cloud lit up the local Ollama card too. The runtime in `hermes_cli/runtime_provider.py` only consumes `OLLAMA_API_KEY` when the base URL hostname is `ollama.com` — local Ollama is keyless by design — so the WebUI was reporting "configured" for a key local Ollama doesn't even read. Dropped the bare `"ollama": "OLLAMA_API_KEY"` mapping; local Ollama users who genuinely need a key can still set `providers.ollama.api_key` in `config.yaml`, and `_provider_has_key()` continues to honor that path. (`api/providers.py`, `tests/test_provider_management.py`) — PR #1411, fixes #1410, reported by @AvidFuturist via Discord + +### Changed + +- **`api/rollback.py` — checkpoint id regex validation (defense-in-depth)** — Opus pre-release follow-up. The `checkpoint` parameter on `/api/rollback/diff` and `/api/rollback/restore` was joined into the path via `_checkpoint_root() / ws_hash / checkpoint`. `Path("/a/b") / "../escape"` does NOT normalize, so an authenticated caller could pass `..//` and read or restore from another allowlisted workspace's checkpoint store. New `_validate_checkpoint_id()` regex-guards with `^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$` and rejects literal `.` / `..`. (`api/rollback.py`) + +- **`redact_session_data()` reads `api_redact_enabled` once per response, not per string** — Opus pre-release follow-up. The new `_redact_text` per-string `load_settings()` call (added by #1405's redact-toggle feature) caused hundreds of disk reads + JSON parses per `/api/session?session_id=X` response on a 50-message session — every nested string in `messages[]` and `tool_calls[]` recursed back into `_redact_value` → `_redact_text` → `load_settings`. Now read once at the top of `redact_session_data()` and threaded through via a private `_enabled` keyword. Fast path when disabled: still walks but returns immediately. (`api/helpers.py`, `tests/test_v050255_opus_followups.py`) + +- **Voice mode pins active session id at thinking-time** — Opus pre-release follow-up. The patched `autoReadLastAssistant` fires globally; if the user navigated to a different session between sending a turn and stream completion, TTS would speak the wrong session's last assistant message. New `_voiceModeThinkingSid` closure variable captures `S.session.session_id` in `_voiceModeSend`; `_speakResponse` bails to `_startListening()` if the current sid no longer matches. (`static/boot.js`, `tests/test_v050255_opus_followups.py`) + +- **`api/rollback.py::_inspect_checkpoint` drops bare `Exception` from except tuple** — Opus pre-release follow-up. The previous `except (subprocess.TimeoutExpired, OSError, Exception)` made the specific catches redundant and swallowed everything. Now `(subprocess.TimeoutExpired, OSError)` only. (`api/rollback.py`, `tests/test_v050255_opus_followups.py`) + ## [v0.50.254] — 2026-05-01 ### Fixed diff --git a/api/config.py b/api/config.py index 90b788a6..4e7f8ad5 100644 --- a/api/config.py +++ b/api/config.py @@ -1034,6 +1034,20 @@ def resolve_model_provider(model_id: str) -> tuple: _PORTAL_PROVIDERS = {"nous", "opencode-zen", "opencode-go", "nvidia"} if config_provider in _PORTAL_PROVIDERS: return model_id, config_provider, config_base_url + # The OpenAI Codex provider uses a real base_url, but its default + # ChatGPT endpoint cannot serve OpenRouter-style provider/model IDs. + # Keep that narrow exception before the custom endpoint protection so + # selecting openai/gpt-5.5 from OpenRouter under active Codex still + # routes through OpenRouter. Other base_url-backed real providers may be + # custom/proxy endpoints, so they must fall through to the branch below. + if ( + config_provider == "openai-codex" + and str(config_base_url or "").strip().rstrip("/") + == "https://chatgpt.com/backend-api/codex" + and prefix in _PROVIDER_MODELS + and prefix != config_provider + ): + return model_id, "openrouter", None # If a custom endpoint base_url is configured, don't reroute through OpenRouter # just because the model name contains a slash (e.g. google/gemma-4-26b-a4b). # The user has explicitly pointed at a base_url, so trust their routing config. @@ -1056,6 +1070,42 @@ def resolve_model_provider(model_id: str) -> tuple: return model_id, config_provider, config_base_url +def model_with_provider_context(model_id: str, model_provider: str | None = None) -> str: + """Return the model string to pass to ``resolve_model_provider()``. + + Session persistence keeps the user's selected provider in ``model_provider`` + instead of forcing every selected model into ``@provider:model`` form. At + runtime, however, ``resolve_model_provider()`` still understands that + internal disambiguation form, so use it only when the provider context is + needed to route away from the current default provider. + """ + model = str(model_id or "").strip() + provider = str(model_provider or "").strip().lower() + if not model or not provider or provider == "default" or model.startswith("@"): + return model + + model_cfg = cfg.get("model", {}) + config_provider = None + if isinstance(model_cfg, dict): + config_provider = str(model_cfg.get("provider") or "").strip().lower() + + # If the selected provider is already the configured provider, leaving the + # model bare preserves provider-specific base_url/proxy settings. + if provider == config_provider: + return model + + # OpenRouter selections with slash IDs are explicit provider/model paths. + if provider == "openrouter": + return f"@{provider}:{model}" + + # For non-OpenRouter slash IDs, keep the ID intact so existing custom/proxy + # base_url routing and portal-provider handling remain in charge. + if "/" in model: + return model + + return f"@{provider}:{model}" + + def get_effective_default_model(config_data: dict | None = None) -> str: """Resolve the effective Hermes default model from config, then env overrides.""" active_cfg = config_data if config_data is not None else cfg @@ -2232,6 +2282,7 @@ _SETTINGS_DEFAULTS = { "notifications_enabled": False, # browser notification when tab is in background "show_thinking": True, # show/hide thinking/reasoning blocks in chat view "simplified_tool_calling": True, # group tools/thinking into one quiet activity disclosure + "api_redact_enabled": True, # redact sensitive data (API keys, secrets) from API responses "sidebar_density": "compact", # compact | detailed "auto_title_refresh_every": "0", # adaptive title refresh: 0=off, 5/10/20=every N exchanges "busy_input_mode": "queue", # behavior when sending while agent is running: queue | interrupt | steer @@ -2349,6 +2400,7 @@ _SETTINGS_BOOL_KEYS = { "notifications_enabled", "show_thinking", "simplified_tool_calling", + "api_redact_enabled", } # Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr') _SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$") diff --git a/api/helpers.py b/api/helpers.py index 50574eba..f6c8b584 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -175,17 +175,39 @@ def _build_redact_fn(): return _combined_redact -_redact_text = _build_redact_fn() +_redact_fn_cached = _build_redact_fn() -def _redact_value(v): - """Recursively redact credentials from strings, dicts, and lists.""" +def _redact_text(text: str, *, _enabled: bool | None = None) -> str: + """Redact sensitive text from API responses. Respects api_redact_enabled setting. + + The ``_enabled`` parameter is an internal optimization for callers that + redact many strings in a single response — `redact_session_data()` reads + the setting once and threads it through ``_redact_value`` so we avoid + re-loading settings.json from disk per string. (Opus pre-release perf fix.) + """ + if not isinstance(text, str) or not text: + return text + if _enabled is None: + from api.config import load_settings + _enabled = bool(load_settings().get("api_redact_enabled", True)) + if not _enabled: + return text + return _redact_fn_cached(text) + + +def _redact_value(v, *, _enabled: bool | None = None): + """Recursively redact credentials from strings, dicts, and lists. + + ``_enabled`` is threaded through so a single response-level redact pass + only reads settings.json once. (Opus pre-release perf fix.) + """ if isinstance(v, str): - return _redact_text(v) + return _redact_text(v, _enabled=_enabled) if isinstance(v, dict): - return {k: _redact_value(val) for k, val in v.items()} + return {k: _redact_value(val, _enabled=_enabled) for k, val in v.items()} if isinstance(v, list): - return [_redact_value(item) for item in v] + return [_redact_value(item, _enabled=_enabled) for item in v] return v @@ -194,14 +216,22 @@ def redact_session_data(session_dict: dict) -> dict: Applies to: messages[], tool_calls[], and title. The underlying session file is not modified; redaction is response-layer only. + + Reads the ``api_redact_enabled`` setting ONCE for the entire response and + threads it through to avoid hundreds of settings.json reads per session + payload (a 50-message session has hundreds of nested strings). When the + setting is disabled this is also a fast path: the recursion still walks + but every string returns early. """ + from api.config import load_settings + _enabled = bool(load_settings().get("api_redact_enabled", True)) result = dict(session_dict) if isinstance(result.get('title'), str): - result['title'] = _redact_text(result['title']) + result['title'] = _redact_text(result['title'], _enabled=_enabled) if 'messages' in result: - result['messages'] = _redact_value(result['messages']) + result['messages'] = _redact_value(result['messages'], _enabled=_enabled) if 'tool_calls' in result: - result['tool_calls'] = _redact_value(result['tool_calls']) + result['tool_calls'] = _redact_value(result['tool_calls'], _enabled=_enabled) return result diff --git a/api/models.py b/api/models.py index 6ab78174..2a1f9c19 100644 --- a/api/models.py +++ b/api/models.py @@ -306,6 +306,7 @@ def _lookup_index_message_count(session_id): class Session: def __init__(self, session_id: str=None, title: str='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, + model_provider=None, messages=None, created_at=None, updated_at=None, tool_calls=None, pinned: bool=False, archived: bool=False, project_id: str=None, profile=None, @@ -326,6 +327,7 @@ class Session: self.title = title self.workspace = str(Path(workspace).expanduser().resolve()) self.model = model + self.model_provider = str(model_provider).strip().lower() if model_provider else None self.messages = messages or [] self.tool_calls = tool_calls or [] self.created_at = created_at or time.time() @@ -366,7 +368,7 @@ class Session: # without parsing the full messages array (which may be 400KB+). # Fields are listed in the order they should appear in the JSON file. METADATA_FIELDS = [ - 'session_id', 'title', 'workspace', 'model', 'created_at', 'updated_at', + 'session_id', 'title', 'workspace', 'model', 'model_provider', 'created_at', 'updated_at', 'pinned', 'archived', 'project_id', 'profile', 'input_tokens', 'output_tokens', 'estimated_cost', 'personality', 'active_stream_id', @@ -448,6 +450,7 @@ class Session: 'title': self.title, 'workspace': self.workspace, 'model': self.model, + 'model_provider': self.model_provider, 'message_count': ( self._metadata_message_count if self._metadata_message_count is not None @@ -702,7 +705,7 @@ def get_session(sid, metadata_only=False): return s raise KeyError(sid) -def new_session(workspace=None, model=None, profile=None): +def new_session(workspace=None, model=None, profile=None, model_provider=None): """Create a new in-memory session. The session lives in the SESSIONS dict only — no disk write happens until @@ -736,6 +739,7 @@ def new_session(workspace=None, model=None, profile=None): s = Session( workspace=workspace or get_last_workspace(), model=effective_model, + model_provider=model_provider, profile=profile, ) with LOCK: diff --git a/api/providers.py b/api/providers.py index a36a935b..424a7927 100644 --- a/api/providers.py +++ b/api/providers.py @@ -44,7 +44,14 @@ _PROVIDER_ENV_VAR: dict[str, str] = { "x-ai": "XAI_API_KEY", "opencode-zen": "OPENCODE_ZEN_API_KEY", "opencode-go": "OPENCODE_GO_API_KEY", - "ollama": "OLLAMA_API_KEY", + # NOTE: bare "ollama" (local) deliberately omitted — local Ollama is keyless + # by default and the runtime in hermes_cli/runtime_provider.py only consumes + # OLLAMA_API_KEY when the base URL hostname is ollama.com (Ollama Cloud). + # If we mapped both providers to the same env var, configuring Ollama Cloud + # would falsely flip the local Ollama card to "API key configured" (#1410). + # Users who genuinely run an authenticated local Ollama can still set a key + # via providers.ollama.api_key in config.yaml — that path remains supported + # by _provider_has_key(). "ollama-cloud": "OLLAMA_API_KEY", "nvidia": "NVIDIA_API_KEY", } diff --git a/api/rollback.py b/api/rollback.py new file mode 100644 index 00000000..18d888fd --- /dev/null +++ b/api/rollback.py @@ -0,0 +1,320 @@ +""" +Hermes Web UI -- Filesystem checkpoint (rollback) API. + +Provides endpoints to list, diff, and restore filesystem checkpoints +created by the Hermes agent's CheckpointManager. Checkpoints live at +``{hermes_home}/checkpoints//`` as shadow git repositories. +""" + +import hashlib +import json +import logging +import os +import re +import shutil +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# Checkpoint identifiers are SHA-style hex hashes from the agent's +# CheckpointManager. We only allow [A-Za-z0-9_.-]{1,64} (no '/' so the +# value cannot be a path separator, no leading '.' so it cannot escape +# upward via '..'/'.'). This is defense-in-depth: the workspace arg is +# already allowlisted, but ``Path() / "../escape"`` does not normalize, +# so without this guard a `checkpoint` value of `..//` +# would let any authenticated caller diff or restore from another +# allowlisted workspace's checkpoint store. (Opus pre-release advisor.) +_CHECKPOINT_ID_RE = re.compile(r"^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$") + + +def _validate_checkpoint_id(checkpoint: str) -> str: + cid = str(checkpoint or "").strip() + if not cid or cid in (".", "..") or not _CHECKPOINT_ID_RE.fullmatch(cid): + raise ValueError( + "checkpoint id must match [A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}" + ) + return cid + + +def _hermes_home() -> Path: + """Return the active Hermes home directory.""" + try: + from api.profiles import get_active_hermes_home + return Path(get_active_hermes_home()) + except Exception: + return Path(os.environ.get("HERMES_HOME", "~/.hermes")).expanduser() + + +def _workspace_hash(workspace: str) -> str: + """Derive the checkpoint directory name from a workspace path. + + Matches the agent's CheckpointManager._get_checkpoint_dir logic: + SHA-256 of the canonical workspace path. + """ + try: + canonical = os.path.realpath(workspace) + except (OSError, ValueError): + canonical = workspace + return hashlib.sha256(canonical.encode()).hexdigest()[:12] + + +def _checkpoint_root() -> Path: + return _hermes_home() / "checkpoints" + + +def _resolve_workspace(workspace: str) -> str: + """Validate and return the canonical workspace path. + + Security: workspace must match a known configured workspace + (from workspaces.json or session-attached workspaces). + """ + if not workspace or not isinstance(workspace, str): + raise ValueError("workspace is required") + # Basic path validation + resolved = os.path.realpath(workspace) + if not os.path.isdir(resolved): + raise ValueError(f"Workspace does not exist: {workspace}") + # Security: confirm workspace is in the known list + try: + from api.workspace import load_workspaces + known_paths = set() + for ws in load_workspaces(): + p = ws.get("path", "") + if p: + known_paths.add(os.path.realpath(p)) + if resolved not in known_paths: + raise ValueError(f"Workspace not in configured list: {workspace}") + except ImportError: + logger.warning("Could not load workspace list for rollback validation") + return resolved + + +def _find_git() -> str: + """Return the path to the git binary.""" + return shutil.which("git") or "git" + + +# ── Public API functions (called from routes.py) ──────────────────────────── + + +def list_checkpoints(workspace: str) -> dict[str, Any]: + """List all checkpoints for a workspace. + + Returns a dict with: + checkpoints: list of checkpoint objects + workspace: resolved workspace path + checkpoint_dir: the checkpoint directory path + """ + resolved = _resolve_workspace(workspace) + ws_hash = _workspace_hash(resolved) + ckpt_dir = _checkpoint_root() / ws_hash + + checkpoints = [] + if not ckpt_dir.is_dir(): + return {"checkpoints": [], "workspace": resolved, "checkpoint_dir": str(ckpt_dir)} + + # Each checkpoint is a git repo in // + git = _find_git() + for entry in sorted(ckpt_dir.iterdir(), key=lambda p: p.stat().st_mtime if p.is_dir() else 0, reverse=True): + if not entry.is_dir(): + continue + ckpt_info = _inspect_checkpoint(entry, git) + if ckpt_info: + checkpoints.append(ckpt_info) + + return { + "checkpoints": checkpoints, + "workspace": resolved, + "checkpoint_dir": str(ckpt_dir), + } + + +def _inspect_checkpoint(ckpt_path: Path, git: str) -> dict[str, Any] | None: + """Extract metadata from a single checkpoint directory.""" + git_dir = ckpt_path / ".git" + if not git_dir.is_dir(): + return None + + name = ckpt_path.name + try: + result = subprocess.run( + [git, "-C", str(ckpt_path), "log", "--format=%H%n%s%n%aI", "-1"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode != 0 or not result.stdout.strip(): + return None + + lines = result.stdout.strip().split("\n") + commit_hash = lines[0] if len(lines) > 0 else name + message = lines[1] if len(lines) > 1 else "checkpoint" + date_str = lines[2] if len(lines) > 2 else "" + + # Parse date for display + date_display = "" + if date_str: + try: + dt = datetime.fromisoformat(date_str) + date_display = dt.strftime("%Y-%m-%d %H:%M") + except (ValueError, TypeError): + date_display = date_str + + # Count files + files_result = subprocess.run( + [git, "-C", str(ckpt_path), "ls-files"], + capture_output=True, text=True, timeout=5, + ) + file_count = len(files_result.stdout.strip().split("\n")) if files_result.stdout.strip() else 0 + + return { + "id": name, + "commit": commit_hash[:12], + "message": message, + "date": date_str, + "date_display": date_display, + "files": file_count, + "path": str(ckpt_path), + } + except (subprocess.TimeoutExpired, OSError) as e: + logger.debug("Failed to inspect checkpoint %s: %s", ckpt_path, e) + return None + + +def get_checkpoint_diff(workspace: str, checkpoint: str) -> dict[str, Any]: + """Show the diff between a checkpoint and the current workspace state. + + Returns a dict with: + diff: unified diff text + files_changed: list of changed file paths + """ + resolved = _resolve_workspace(workspace) + checkpoint = _validate_checkpoint_id(checkpoint) + ws_hash = _workspace_hash(resolved) + ckpt_dir = _checkpoint_root() / ws_hash / checkpoint + + if not ckpt_dir.is_dir(): + raise ValueError(f"Checkpoint not found: {checkpoint}") + + git = _find_git() + + # Get list of files in the checkpoint + ls_result = subprocess.run( + [git, "-C", str(ckpt_dir), "ls-files"], + capture_output=True, text=True, timeout=10, + ) + if ls_result.returncode != 0: + raise ValueError("Failed to list checkpoint files") + + ckpt_files = [f for f in ls_result.stdout.strip().split("\n") if f] + files_changed = [] + diff_lines = [] + + for rel_path in ckpt_files: + ckpt_file = ckpt_dir / rel_path + ws_file = Path(resolved) / rel_path + + if not ckpt_file.is_file(): + continue + + # Read checkpoint version + try: + ckpt_content = ckpt_file.read_text(errors="replace") + except OSError: + continue + + # Read workspace version (if exists) + if ws_file.is_file(): + try: + ws_content = ws_file.read_text(errors="replace") + except OSError: + ws_content = "" + else: + ws_content = None # File was deleted in workspace + + if ws_content is None: + # File exists in checkpoint but not in workspace (deleted) + files_changed.append({"file": rel_path, "status": "deleted"}) + diff_lines.append(f"--- a/{rel_path}") + diff_lines.append(f"+++ /dev/null") + diff_lines.append("@@ -1,{lines} +0,0 @@".format(lines=len(ckpt_content.splitlines()))) + for line in ckpt_content.splitlines(): + diff_lines.append(f"-{line}") + elif ckpt_content != ws_content: + # File changed + import difflib + ckpt_lines = ckpt_content.splitlines(keepends=True) + ws_lines = ws_content.splitlines(keepends=True) + diff = list(difflib.unified_diff(ckpt_lines, ws_lines, fromfile=f"a/{rel_path}", tofile=f"b/{rel_path}", lineterm="")) + if diff: + files_changed.append({"file": rel_path, "status": "modified"}) + diff_lines.extend(diff) + + # Check for new files in workspace that aren't in checkpoint + # (skip for performance — diff is primarily for seeing what the checkpoint captures) + + return { + "checkpoint": checkpoint, + "workspace": resolved, + "diff": "\n".join(diff_lines) if diff_lines else "", + "files_changed": files_changed, + "total_changes": len(files_changed), + } + + +def restore_checkpoint(workspace: str, checkpoint: str) -> dict[str, Any]: + """Restore a checkpoint by copying files back to the workspace. + + Only restores files that exist in the checkpoint. Does NOT delete + files that were added after the checkpoint was created. + + Returns a dict with: + ok: True + files_restored: list of restored file paths + """ + resolved = _resolve_workspace(workspace) + checkpoint = _validate_checkpoint_id(checkpoint) + ws_hash = _workspace_hash(resolved) + ckpt_dir = _checkpoint_root() / ws_hash / checkpoint + + if not ckpt_dir.is_dir(): + raise ValueError(f"Checkpoint not found: {checkpoint}") + + git = _find_git() + + # Get list of files in the checkpoint + ls_result = subprocess.run( + [git, "-C", str(ckpt_dir), "ls-files"], + capture_output=True, text=True, timeout=10, + ) + if ls_result.returncode != 0: + raise ValueError("Failed to list checkpoint files") + + ckpt_files = [f for f in ls_result.stdout.strip().split("\n") if f] + restored = [] + errors = [] + + for rel_path in ckpt_files: + ckpt_file = ckpt_dir / rel_path + ws_file = Path(resolved) / rel_path + + if not ckpt_file.is_file(): + continue + + try: + ws_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(ckpt_file), str(ws_file)) + restored.append(rel_path) + except OSError as e: + errors.append({"file": rel_path, "error": str(e)}) + logger.warning("Failed to restore %s: %s", rel_path, e) + + return { + "ok": True, + "checkpoint": checkpoint, + "workspace": resolved, + "files_restored": restored, + "files_restored_count": len(restored), + "errors": errors, + } diff --git a/api/routes.py b/api/routes.py index 4237f37d..5440a108 100644 --- a/api/routes.py +++ b/api/routes.py @@ -215,6 +215,7 @@ from api.config import ( load_settings, save_settings, set_hermes_default_model, + model_with_provider_context, get_reasoning_status, set_reasoning_display, set_reasoning_effort, @@ -397,20 +398,78 @@ def _model_matches_active_provider_family( return False -def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]: - """Return (effective_model, was_normalized) for persisted session models. +def _catalog_model_id_matches(candidate: str, model: str) -> bool: + candidate = str(candidate or "").strip() + if candidate.startswith("@") and ":" in candidate: + candidate = candidate.rsplit(":", 1)[1] + if "/" in candidate: + candidate = candidate.split("/", 1)[1] + return candidate.replace("-", ".").lower() == model.replace("-", ".").lower() + + +def _clean_session_model_provider(value: str | None) -> str | None: + provider = str(value or "").strip().lower() + if not provider or provider == "default": + return None + if provider.startswith("@"): + provider = provider[1:] + return provider or None + + +def _split_provider_qualified_model(model: str) -> tuple[str, str | None]: + model = str(model or "").strip() + if model.startswith("@") and ":" in model: + provider_hint, bare_model = model[1:].rsplit(":", 1) + provider = _clean_session_model_provider(provider_hint) + bare = bare_model.strip() + if provider and bare: + return bare, provider + return model, None + + +def _should_attach_codex_provider_context(model: str, raw_active_provider: str, catalog: dict) -> bool: + """Return True when a bare Codex model needs separate provider context. + + OpenAI, OpenAI Codex, Copilot, and OpenRouter can all expose GPT-looking + bare names. If a session stores only ``gpt-...`` while Codex is active, a + later provider-list/default-model round trip can lose the user's Codex + choice. Store the provider separately instead of converting the persisted + model to ``@openai-codex:model``. + """ + if raw_active_provider != "openai-codex": + return False + if not model.lower().startswith("gpt"): + return False + for group in catalog.get("groups") or []: + if str(group.get("provider_id") or "").strip().lower() != "openai-codex": + continue + return any( + _catalog_model_id_matches(entry.get("id"), model) + for entry in group.get("models", []) + if isinstance(entry, dict) + ) + return False + + +def _resolve_compatible_session_model_state( + model_id: str | None, + model_provider: str | None = None, +) -> tuple[str, str | None, bool]: + """Return (effective_model, effective_provider, model_was_normalized). Sessions can outlive provider changes. When an older session still points at a different provider namespace (for example `gemini/...` after switching the agent to OpenAI Codex), reusing that stale model causes chat startup to hit - the wrong backend and fail. Normalize only obvious cross-provider mismatches; - preserve bare model IDs and OpenRouter/custom setups. + the wrong backend and fail. Normalize only obvious cross-provider mismatches. + When a model has an explicit provider context, keep the model string itself + in its picker/API shape and carry the provider as separate state. """ catalog = get_available_models() default_model = str(catalog.get("default_model") or DEFAULT_MODEL or "").strip() model = str(model_id or "").strip() + requested_provider = _clean_session_model_provider(model_provider) if not model: - return default_model, bool(default_model) + return default_model, requested_provider, bool(default_model) active_provider = _normalize_provider_id(catalog.get("active_provider")) # Also keep the raw active_provider slug for cross-provider detection with @@ -420,15 +479,19 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]: # 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 + bare_model, explicit_provider = _split_provider_qualified_model(model) + return model, explicit_provider or requested_provider, False + + bare_for_context, explicit_provider = _split_provider_qualified_model(model) + if requested_provider and not explicit_provider: + return model, requested_provider, False if model.startswith("@") and ":" in model: - provider_hint, bare_model = model[1:].split(":", 1) - provider_raw = provider_hint.strip().lower() + provider_raw = explicit_provider or "" provider_normalized = _normalize_provider_id(provider_raw) - bare_model = bare_model.strip() + bare_model = bare_for_context.strip() if not provider_raw or not bare_model: - return model, False + return model, requested_provider, False raw_provider_ids, normalized_provider_ids = _catalog_provider_id_sets(catalog) hint_matches_active = ( @@ -444,7 +507,7 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]: # here would collapse duplicate model IDs from different providers back to the # bare ID, causing the first matching provider to win on the next UI render # and the wrong provider to be used for the agent run. (#1253) - return model, False + return model, provider_raw, False if _catalog_has_provider( provider_raw, @@ -452,13 +515,23 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]: raw_provider_ids, normalized_provider_ids, ): - return model, False + return model, provider_raw, False if _model_matches_active_provider_family(bare_model, active_provider): - return bare_model, True + provider_context = ( + raw_active_provider + if _should_attach_codex_provider_context(bare_model, raw_active_provider, catalog) + else None + ) + return bare_model, provider_context, True if default_model: - return default_model, True - return model, False + provider_context = ( + raw_active_provider + if _should_attach_codex_provider_context(default_model, raw_active_provider, catalog) + else None + ) + return default_model, provider_context, True + return model, provider_raw, False slash = model.find("/") if slash < 0: @@ -467,9 +540,19 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]: if model_lower.startswith(bare_prefix): model_provider = _normalize_provider_id(bare_prefix) if model_provider and model_provider != active_provider and default_model: - return default_model, True - return model, False - return model, False + provider_context = ( + raw_active_provider + if _should_attach_codex_provider_context(default_model, raw_active_provider, catalog) + else None + ) + return default_model, provider_context, True + provider_context = ( + raw_active_provider + if _should_attach_codex_provider_context(model, raw_active_provider, catalog) + else requested_provider + ) + return model, provider_context, False + return model, requested_provider, False model_provider = _normalize_provider_id(model[:slash]) @@ -481,7 +564,7 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]: if active_provider in {"custom", "openrouter"}: # These namespaces are always routable as-is — preserve them. if model_provider in {"", "custom", "openrouter"}: - return model, False + return model, requested_provider, False # Check if any catalog group can actually route this model's prefix. groups = catalog.get("groups") or [] routable_provider_ids = { @@ -492,11 +575,11 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]: (g.get("provider_id") or "") == "openrouter" for g in groups ) if model_provider in routable_provider_ids or has_openrouter_group: - return model, False + return model, requested_provider, False # Model prefix is not routable — stale cross-provider reference, clear it. if default_model: - return default_model, True - return model, False + return default_model, requested_provider, True + return model, requested_provider, False # Skip normalization for models on custom/openrouter namespaces — these are # user-controlled and should never be silently replaced. @@ -506,18 +589,35 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]: # 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 + return default_model, requested_provider, True + return model, requested_provider, False + + +def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]: + """Return (effective_model, model_was_normalized) for legacy callers.""" + effective_model, _provider, changed = _resolve_compatible_session_model_state(model_id) + return effective_model, changed def _normalize_session_model_in_place(session) -> str: original_model = getattr(session, "model", None) or "" - effective_model, changed = _resolve_compatible_session_model(original_model or None) + original_provider = _clean_session_model_provider( + getattr(session, "model_provider", None) + ) + effective_model, effective_provider, changed = _resolve_compatible_session_model_state( + original_model or None, + original_provider, + ) + provider_changed = effective_provider != original_provider # Only persist the correction if the session had an explicit model that needed changing. # Sessions with no model stored (empty/None) get the effective default returned without # a disk write — no need to rebuild the index for a fill-in-blank operation. - if changed and effective_model and original_model and original_model != effective_model: - session.model = effective_model + if original_model and effective_model and ( + (changed and original_model != effective_model) or provider_changed + ): + if changed and original_model != effective_model: + session.model = effective_model + session.model_provider = effective_provider session.save(touch_updated_at=False) return effective_model @@ -530,10 +630,46 @@ def _resolve_effective_session_model_for_display(session) -> str: effective model for the response payload only and leave disk state alone. """ original_model = getattr(session, "model", None) or "" - effective_model, _changed = _resolve_compatible_session_model(original_model or None) + effective_model, _provider, _changed = _resolve_compatible_session_model_state( + original_model or None, + getattr(session, "model_provider", None), + ) return effective_model or original_model +def _resolve_effective_session_model_provider_for_display(session) -> str | None: + original_model = getattr(session, "model", None) or "" + _model, provider, _changed = _resolve_compatible_session_model_state( + original_model or None, + getattr(session, "model_provider", None), + ) + return provider + + +def _session_model_state_from_request( + model: str | None, + requested_provider: str | None, + current_provider: str | None = None, +) -> tuple[str | None, str | None]: + model_value = str(model).strip() if model is not None else None + provider = ( + _clean_session_model_provider(requested_provider) + if requested_provider is not None + else None + ) + if model_value: + _bare, explicit_provider = _split_provider_qualified_model(model_value) + if explicit_provider: + provider = explicit_provider + elif requested_provider is None: + provider = _clean_session_model_provider(current_provider) + model_value, provider, _changed = _resolve_compatible_session_model_state( + model_value, + provider, + ) + return model_value, provider + + from api.models import ( Session, get_session, @@ -842,6 +978,102 @@ button:hover{background:rgba(124,185,255,.25)} """ +# ── Insights endpoint ────────────────────────────────────────────────────────── + +def _handle_insights(handler, parsed) -> bool: + """Return usage analytics from local WebUI session data.""" + import collections + import time as _time + + query = parse_qs(parsed.query) + try: + days = min(max(int(query.get("days", ["30"])[0]), 1), 365) + except (ValueError, TypeError): + days = 30 + + now = _time.time() + cutoff = now - (days * 86400) + + # Walk session index (fast, no full JSON parse) + sessions_data = [] + idx_path = SESSION_DIR / "_index.json" + if idx_path.exists(): + try: + idx = json.loads(idx_path.read_text(encoding="utf-8")) + except Exception: + idx = [] + else: + idx = [] + + for entry in idx: + created = entry.get("created_at", 0) or 0 + updated = entry.get("updated_at", 0) or 0 + # Session is relevant if it was created or updated within the window + if max(created, updated) < cutoff: + continue + sessions_data.append(entry) + + # Aggregate + total_sessions = len(sessions_data) + total_messages = 0 + total_input_tokens = 0 + total_output_tokens = 0 + total_cost = 0.0 + model_counts = collections.Counter() + # Activity by day of week (0=Mon .. 6=Sun) + dow_activity = collections.Counter() + # Activity by hour of day (0-23) + hod_activity = collections.Counter() + + for s in sessions_data: + total_messages += max(s.get("message_count", 0) or 0, 0) + total_input_tokens += max(s.get("input_tokens", 0) or 0, 0) + total_output_tokens += max(s.get("output_tokens", 0) or 0, 0) + cost = s.get("estimated_cost") + if cost is not None: + try: + total_cost += float(cost) + except (ValueError, TypeError): + pass + model = s.get("model") or "unknown" + if model: + model_counts[model] += 1 + # Activity patterns + ts = s.get("updated_at", s.get("created_at", 0)) or 0 + if ts: + try: + dt = _time.localtime(ts) + dow_activity[dt.tm_wday] += 1 + hod_activity[dt.tm_hour] += 1 + except Exception: + pass + + # Build model breakdown + models_breakdown = [] + for model, count in model_counts.most_common(): + models_breakdown.append({"model": model, "sessions": count}) + + # Day-of-week labels + dow_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + dow_data = [{"day": dow_labels[i], "sessions": dow_activity.get(i, 0)} for i in range(7)] + + # Hour-of-day data + hod_data = [{"hour": h, "sessions": hod_activity.get(h, 0)} for h in range(24)] + + return j(handler, { + "period_days": days, + "total_sessions": total_sessions, + "total_messages": total_messages, + "total_input_tokens": total_input_tokens, + "total_output_tokens": total_output_tokens, + "total_tokens": total_input_tokens + total_output_tokens, + "total_cost": round(total_cost, 6), + "models": models_breakdown, + "activity_by_day": dow_data, + "activity_by_hour": hod_data, + }) + + # ── GET routes ──────────────────────────────────────────────────────────────── @@ -944,6 +1176,10 @@ def handle_get(handler, parsed) -> bool: handler.end_headers() return True + # ── Insights ── + if parsed.path == "/api/insights": + return _handle_insights(handler, parsed) + if parsed.path == "/health": with STREAMS_LOCK: n_streams = len(STREAMS) @@ -1033,6 +1269,11 @@ def handle_get(handler, parsed) -> bool: if resolve_model else None ) + effective_provider = ( + _resolve_effective_session_model_provider_for_display(s) + if resolve_model + else None + ) _t3 = _time.monotonic() _all_msgs = s.messages if load_messages else [] if load_messages: @@ -1080,6 +1321,8 @@ def handle_get(handler, parsed) -> bool: _t4 = _time.monotonic() if effective_model: raw["model"] = effective_model + if effective_provider: + raw["model_provider"] = effective_provider redact = redact_session_data(raw) _t5 = _time.monotonic() resp = j(handler, {"session": redact}) @@ -1428,10 +1671,40 @@ def handle_get(handler, parsed) -> bool: if parsed.path == "/api/mcp/servers": return _handle_mcp_servers_list(handler) + # ── Checkpoints / Rollback (GET) ── + if parsed.path == "/api/rollback/list": + qs = parse_qs(parsed.query) + workspace = qs.get("workspace", [""])[0] + if not workspace: + return bad(handler, "workspace query parameter is required") + try: + from api.rollback import list_checkpoints + return j(handler, list_checkpoints(workspace)) + except ValueError as e: + return bad(handler, str(e)) + except Exception as e: + logger.exception("rollback/list failed") + return bad(handler, str(e), status=500) + + if parsed.path == "/api/rollback/diff": + qs = parse_qs(parsed.query) + workspace = qs.get("workspace", [""])[0] + checkpoint = qs.get("checkpoint", [""])[0] + if not workspace or not checkpoint: + return bad(handler, "workspace and checkpoint query parameters are required") + try: + from api.rollback import get_checkpoint_diff + return j(handler, get_checkpoint_diff(workspace, checkpoint)) + except ValueError as e: + return bad(handler, str(e)) + except Exception as e: + logger.exception("rollback/diff failed") + return bad(handler, str(e), status=500) + return False # 404 -# ── POST routes ─────────────────────────────────────────────────────────────── +# ── GET route helpers def handle_post(handler, parsed) -> bool: @@ -1455,9 +1728,18 @@ def handle_post(handler, parsed) -> bool: workspace = str(resolve_trusted_workspace(body.get("workspace"))) if body.get("workspace") else None except ValueError as e: return bad(handler, str(e)) + model, model_provider = _session_model_state_from_request( + body.get("model"), + body.get("model_provider"), + ) # Use the profile sent by the client tab (if any) so that two tabs on # different profiles never clobber each other via the process-level global. - s = new_session(workspace=workspace, model=body.get("model"), profile=body.get("profile") or None) + s = new_session( + workspace=workspace, + model=model, + model_provider=model_provider, + profile=body.get("profile") or None, + ) return j(handler, {"session": s.compact() | {"messages": s.messages}}) if parsed.path == "/api/default-model": @@ -1610,7 +1892,15 @@ def handle_post(handler, parsed) -> bool: return bad(handler, str(e)) with _get_session_agent_lock(body["session_id"]): s.workspace = new_ws - s.model = body.get("model", s.model) + if "model" in body or "model_provider" in body: + model, provider = _session_model_state_from_request( + body.get("model", s.model), + body.get("model_provider") if "model_provider" in body else None, + getattr(s, "model_provider", None), + ) + if model is not None: + s.model = model + s.model_provider = provider s.save() if str(old_ws or "") != str(new_ws or ""): try: @@ -2289,6 +2579,23 @@ def handle_post(handler, parsed) -> bool: handler.wfile.write(json.dumps({"ok": True}).encode()) return True + # ── Checkpoints / Rollback (POST) ── + if parsed.path == "/api/rollback/restore": + if not body: + return bad(handler, "request body is required") + workspace = body.get("workspace", "") + checkpoint = body.get("checkpoint", "") + if not workspace or not checkpoint: + return bad(handler, "workspace and checkpoint are required") + try: + from api.rollback import restore_checkpoint + return j(handler, restore_checkpoint(workspace, checkpoint)) + except ValueError as e: + return bad(handler, str(e)) + except Exception as e: + logger.exception("rollback/restore failed") + return bad(handler, str(e), status=500) + return False # 404 # ── GET route helpers ───────────────────────────────────────────────────────── @@ -3473,7 +3780,13 @@ def _handle_btw(handler, body): s.active_stream_id = None # Create ephemeral hidden session inheriting context from api.models import new_session as _new_session - ephemeral = _new_session(workspace=s.workspace, model=s.model, profile=getattr(s, 'profile', None)) + model_provider = getattr(s, 'model_provider', None) + ephemeral = _new_session( + workspace=s.workspace, + model=s.model, + model_provider=model_provider, + profile=getattr(s, 'profile', None), + ) # Copy conversation history for context (agent reads from messages) ephemeral.messages = list(s.messages or []) ephemeral.title = f"btw: {question[:60]}" @@ -3489,7 +3802,7 @@ def _handle_btw(handler, body): thr = threading.Thread( target=_run_agent_streaming, args=(ephemeral.session_id, question, s.model, s.workspace, stream_id, None), - kwargs={"ephemeral": True}, + kwargs={"ephemeral": True, "model_provider": model_provider}, daemon=True, ) thr.start() @@ -3515,7 +3828,13 @@ def _handle_background(handler, body): if not prompt: return bad(handler, "prompt is required") from api.models import new_session as _new_session - bg = _new_session(workspace=s.workspace, model=s.model, profile=getattr(s, 'profile', None)) + model_provider = getattr(s, 'model_provider', None) + bg = _new_session( + workspace=s.workspace, + model=s.model, + model_provider=model_provider, + profile=getattr(s, 'profile', None), + ) bg.title = f"bg: {prompt[:60]}" bg.save() stream_id = uuid.uuid4().hex @@ -3537,7 +3856,15 @@ def _handle_background(handler, body): `get_results()` would see a forever-`running` task and return nothing. """ try: - _run_agent_streaming(bg_sid, prompt, s.model, s.workspace, stream_id, None) + _run_agent_streaming( + bg_sid, + prompt, + s.model, + s.workspace, + stream_id, + None, + model_provider=model_provider, + ) # Reload the bg session from disk and extract the final assistant reply. try: from api.models import Session as _Session @@ -3591,7 +3918,15 @@ def _handle_chat_start(handler, body): except ValueError as e: return bad(handler, str(e)) requested_model = body.get("model") or s.model - model, normalized_model = _resolve_compatible_session_model(requested_model) + requested_provider = ( + body.get("model_provider") + if "model_provider" in body + else getattr(s, "model_provider", None) + ) + model, model_provider, normalized_model = _resolve_compatible_session_model_state( + requested_model, + requested_provider, + ) # Prevent duplicate runs in the same session while a stream is still active. # This commonly happens after page refresh/reconnect races and can produce # duplicated clarify cards for what appears to be a single user request. @@ -3614,6 +3949,7 @@ def _handle_chat_start(handler, body): with _get_session_agent_lock(s.session_id): s.workspace = workspace s.model = model + s.model_provider = model_provider s.active_stream_id = stream_id s.pending_user_message = msg s.pending_attachments = attachments @@ -3626,12 +3962,15 @@ def _handle_chat_start(handler, body): thr = threading.Thread( target=_run_agent_streaming, args=(s.session_id, msg, model, workspace, stream_id, attachments), + kwargs={"model_provider": model_provider}, daemon=True, ) thr.start() response = {"stream_id": stream_id, "session_id": s.session_id} if normalized_model: response["effective_model"] = model + if model_provider: + response["effective_model_provider"] = model_provider return j(handler, response) @@ -3677,7 +4016,12 @@ def _handle_chat_sync(handler, body): return bad(handler, str(e)) with _get_session_agent_lock(s.session_id): s.workspace = workspace - s.model = body.get("model") or s.model + model, model_provider = _resolve_compatible_session_model_state( + body.get("model") or s.model, + body.get("model_provider") if "model_provider" in body else getattr(s, "model_provider", None), + )[:2] + s.model = model + s.model_provider = model_provider from api.streaming import _ENV_LOCK with _ENV_LOCK: @@ -3693,7 +4037,9 @@ def _handle_chat_sync(handler, body): with CHAT_LOCK: from api.config import resolve_model_provider - _model, _provider, _base_url = resolve_model_provider(s.model) + _model, _provider, _base_url = resolve_model_provider( + model_with_provider_context(s.model, getattr(s, "model_provider", None)) + ) # Resolve API key via Hermes runtime provider (matches gateway behaviour) _api_key = None try: @@ -4356,7 +4702,9 @@ def _handle_session_compress(handler, body): import hermes_cli.runtime_provider as _runtime_provider import run_agent as _run_agent - resolved_model, resolved_provider, resolved_base_url = _cfg.resolve_model_provider(s.model) + resolved_model, resolved_provider, resolved_base_url = _cfg.resolve_model_provider( + _cfg.model_with_provider_context(s.model, getattr(s, "model_provider", None)) + ) resolved_api_key = None try: diff --git a/api/streaming.py b/api/streaming.py index 4ed12b83..1abbea64 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -26,6 +26,7 @@ from api.config import ( _get_session_agent_lock, _set_thread_env, _clear_thread_env, SESSION_AGENT_LOCKS, SESSION_AGENT_LOCKS_LOCK, resolve_model_provider, + model_with_provider_context, ) from api.helpers import redact_session_data from api.metering import meter @@ -1342,7 +1343,17 @@ def _last_resort_sync_from_core(session, stream_id, agent_lock): ) -def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, attachments=None, *, ephemeral=False): +def _run_agent_streaming( + session_id, + msg_text, + model, + workspace, + stream_id, + attachments=None, + *, + ephemeral=False, + model_provider=None, +): """Run agent in background thread, writing SSE events to STREAMS[stream_id]. When ephemeral=True, session mutations are skipped — used by /btw to get @@ -1418,6 +1429,12 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta s = get_session(session_id) s.workspace = str(Path(workspace).expanduser().resolve()) s.model = model + provider_context = ( + str(model_provider).strip().lower() + if model_provider is not None + else getattr(s, "model_provider", None) + ) + s.model_provider = provider_context or None _agent_lock = _get_session_agent_lock(session_id) # TD1: set thread-local env context so concurrent sessions don't clobber globals @@ -1701,7 +1718,9 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta _session_db = SessionDB() except Exception as _db_err: print(f"[webui] WARNING: SessionDB init failed — session_search will be unavailable: {_db_err}", flush=True) - resolved_model, resolved_provider, resolved_base_url = resolve_model_provider(model) + resolved_model, resolved_provider, resolved_base_url = resolve_model_provider( + model_with_provider_context(model, provider_context) + ) # Resolve API key via Hermes runtime provider (matches gateway behaviour). # Pass the resolved provider so non-default providers get their own credentials. diff --git a/static/boot.js b/static/boot.js index a15b1792..e4134258 100644 --- a/static/boot.js +++ b/static/boot.js @@ -199,6 +199,12 @@ $('btnSend').onclick=()=>{ _stopMic(); return; } + // Turn-based voice mode: let the voice mode system handle the send flow + if(typeof window._voiceModeActive==='function'&&window._voiceModeActive()){ + // Immediately send whatever is in the textarea + if(typeof window._voiceModeImmediateSend==='function') window._voiceModeImmediateSend(); + return; + } send(); }; $('btnAttach').onclick=()=>$('fileInput').click(); @@ -403,6 +409,284 @@ $('btnAttach').onclick=()=>$('fileInput').click(); })(); window._micActive=window._micActive||false; window._micPendingSend=window._micPendingSend||false; + +// ── Turn-based voice mode (#1333) ──────────────────────────────────────── +// Chained flow: listen → send → (agent processes) → TTS response → listen again +(function(){ + const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition; + const hasSTT=!(!SpeechRecognition); + const hasTTS=!!('speechSynthesis' in window); + + // Need both STT and TTS for turn-based voice mode + if(!hasSTT||!hasTTS) return; + + const modeBtn=$('btnVoiceMode'); + const bar=$('voiceModeBar'); + const indicator=$('voiceModeIndicator'); + const label=$('voiceModeLabel'); + const micBtn=$('btnMic'); + const ta=$('msg'); + + if(!modeBtn||!bar||!indicator||!label) return; + + // Show the voice mode button — browser supports both STT and TTS + modeBtn.style.display=''; + + let _voiceModeActive=false; + let _voiceModeState='idle'; // idle | listening | thinking | speaking + let _recognition=null; + let _silenceTimer=null; + // Capture the session id at thinking-time so the TTS callback won't read + // a different session's last assistant reply if the user navigated away + // between send and stream completion. (Opus pre-release advisor.) + let _voiceModeThinkingSid=null; + const SILENCE_MS=1800; // auto-send after 1.8s silence + + function _setState(state){ + _voiceModeState=state; + indicator.className='voice-mode-indicator '+state; + label.textContent=state==='listening'?t('voice_listening') + :state==='speaking'?t('voice_speaking') + :state==='thinking'?t('voice_thinking') + :''; + bar.style.display=_voiceModeActive?(state==='idle'?'none':''):'none'; + } + + function _startListening(){ + if(!_voiceModeActive) return; + _setState('listening'); + + _recognition=new SpeechRecognition(); + _recognition.continuous=false; + _recognition.interimResults=true; + _recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US'; + + let _finalText=''; + + _recognition.onstart=()=>{ _finalText=''; }; + + _recognition.onresult=(event)=>{ + // Reset silence timer on any result + clearTimeout(_silenceTimer); + let interim=''; + let final=_finalText; + for(let i=event.resultIndex;i{ + _voiceModeSend(); + },SILENCE_MS); + } + }; + + _recognition.onend=()=>{ + clearTimeout(_silenceTimer); + // If we have text and haven't sent yet, send it + if(_finalText&&_voiceModeActive&&_voiceModeState==='listening'){ + _voiceModeSend(); + } else if(_voiceModeActive&&_voiceModeState==='listening'){ + // No speech detected — restart listening + setTimeout(()=>{ if(_voiceModeActive) _startListening(); },500); + } + }; + + _recognition.onerror=(event)=>{ + clearTimeout(_silenceTimer); + if(event.error==='no-speech'||event.error==='aborted'){ + // Restart if still active + if(_voiceModeActive){ + setTimeout(()=>{ if(_voiceModeActive) _startListening(); },800); + } + return; + } + if(event.error==='not-allowed'||event.error==='service-not-allowed'||event.error==='audio-capture'){ + _deactivate(); + showToast(t('mic_denied')); + return; + } + // Other errors — try to restart + if(_voiceModeActive){ + setTimeout(()=>{ if(_voiceModeActive) _startListening(); },1500); + } + }; + + try{ _recognition.start(); }catch(e){ + // Already started or other error — retry shortly + setTimeout(()=>{ if(_voiceModeActive) _startListening(); },1000); + } + } + + function _voiceModeSend(){ + if(!_voiceModeActive) return; + const text=(ta.value||'').trim(); + if(!text){ + ta.value=''; + setTimeout(()=>{ if(_voiceModeActive) _startListening(); },300); + return; + } + _setState('thinking'); + // Pin the active session id so the TTS callback won't speak a different + // session's reply if the user navigates away mid-stream. + _voiceModeThinkingSid=(typeof S!=='undefined'&&S.session)?S.session.session_id:null; + try{ if(_recognition) _recognition.abort(); }catch(_){} + _recognition=null; + // send() is global from boot.js + if(typeof send==='function') send(); + } + + function _speakResponse(){ + if(!_voiceModeActive) return; + // Bail out if the user navigated to a different session between send and + // stream completion. The patched autoReadLastAssistant fires globally; + // without this guard it would TTS-read the wrong session's last assistant + // message. Drop back to listening on the new session instead. + const currentSid=(typeof S!=='undefined'&&S.session)?S.session.session_id:null; + if(_voiceModeThinkingSid && currentSid && currentSid!==_voiceModeThinkingSid){ + _voiceModeThinkingSid=null; + _startListening(); + return; + } + _voiceModeThinkingSid=null; + _setState('speaking'); + + // Find last assistant message + const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]'); + if(!rows.length){ _startListening(); return; } + const last=rows[rows.length-1]; + const rawText=last.dataset.rawText||''; + if(!rawText.trim()){ _startListening(); return; } + + // Strip for TTS (reuse existing helper if available) + let clean=rawText; + if(typeof _stripForTTS==='function') clean=_stripForTTS(rawText); + else{ + // Basic strip: remove code blocks, images, links + clean=clean.replace(/```[\s\S]*?```/g,' code block ') + .replace(/`([^`]*)`/g,'$1') + .replace(/!\[([^\]]*)\]\([^)]*\)/g,'$1') + .replace(/\[([^\]]*)\]\([^)]*\)/g,'$1') + .replace(/#{1,6}\s/g,'') + .replace(/[*_~]+/g,'') + .replace(/\n{2,}/g,'. ') + .replace(/\n/g,' ') + .trim(); + } + if(!clean){ _startListening(); return; } + + const utter=new SpeechSynthesisUtterance(clean); + + // Apply saved voice preferences + const savedVoice=localStorage.getItem('hermes-tts-voice'); + const voices=speechSynthesis.getVoices(); + if(savedVoice&&voices.length){ + const match=voices.find(v=>v.name===savedVoice); + if(match) utter.voice=match; + } + const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate')); + if(!isNaN(savedRate)) utter.rate=Math.min(2,Math.max(0.5,savedRate)); + const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch')); + if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch)); + + utter.onend=()=>{ + // After speaking, go back to listening + if(_voiceModeActive) setTimeout(()=>_startListening(),500); + }; + utter.onerror=()=>{ + if(_voiceModeActive) setTimeout(()=>_startListening(),1000); + }; + + speechSynthesis.speak(utter); + } + + // Hook into response completion — observe when the agent finishes + // We patch setComposerStatus to detect when a response completes + const _origSetComposerStatus=(typeof setComposerStatus==='function')?setComposerStatus.bind(window):null; + + window._voiceModeOnResponseComplete=function(){ + if(_voiceModeActive&&_voiceModeState==='thinking'){ + // Small delay to let DOM render the final message + setTimeout(()=>{ + if(_voiceModeActive&&_voiceModeState==='thinking'){ + _speakResponse(); + } + },400); + } + }; + + // Observe S.busy changes to detect response completion + // The existing code calls setBusy(false) when response completes + const _origSetBusy=(typeof setBusy==='function')?setBusy.bind(window):null; + if(_origSetBusy){ + // We use a MutationObserver-style approach via polling S.busy + // Actually, we'll use a simpler approach: hook into the message stream completion + } + + // Most reliable hook: use the existing autoReadLastAssistant call site. + // We override autoReadLastAssistant so that if voice mode is active, we use our + // own speak-and-resume flow instead of the default auto-read. + const _origAutoRead=(typeof autoReadLastAssistant==='function')?autoReadLastAssistant:null; + window.autoReadLastAssistant=function(){ + if(_voiceModeActive&&_voiceModeState==='thinking'){ + _speakResponse(); + return; + } + if(_origAutoRead) _origAutoRead.apply(this,arguments); + }; + + function _activate(){ + _voiceModeActive=true; + modeBtn.classList.add('active'); + modeBtn.title=t('voice_mode_active'); + showToast(t('voice_mode_active'),1500); + // If the agent is busy, wait — state will be 'thinking' and we'll detect completion + if(typeof S!=='undefined'&&S.busy){ + _setState('thinking'); + return; + } + // Cancel any existing TTS + if(typeof stopTTS==='function') stopTTS(); + _startListening(); + } + + function _deactivate(){ + _voiceModeActive=false; + _voiceModeState='idle'; + _voiceModeThinkingSid=null; + modeBtn.classList.remove('active'); + modeBtn.title=t('voice_toggle'); + bar.style.display='none'; + clearTimeout(_silenceTimer); + try{ if(_recognition) _recognition.abort(); }catch(_){} + _recognition=null; + if(typeof stopTTS==='function') stopTTS(); + // Restore original autoReadLastAssistant + if(_origAutoRead) window.autoReadLastAssistant=_origAutoRead; + // Clear textarea if it was only voice input + ta.value=''; + autoResize(); + } + + modeBtn.onclick=()=>{ + if(_voiceModeActive){ + _deactivate(); + showToast(t('voice_mode_off'),1500); + }else{ + _activate(); + } + }; + + // Expose for external use + window._voiceModeActive=()=>_voiceModeActive; + window._voiceModeDeactivate=_deactivate; + window._voiceModeImmediateSend=_voiceModeSend; +})(); $('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';}; $('btnNewChat').onclick=async()=>{ // If the current session has no messages, just focus the composer rather than @@ -463,21 +747,31 @@ $('btnClearPreview').onclick=handleWorkspaceClose; $('modelSelect').onchange=async()=>{ if(!S.session)return; const selectedModel=$('modelSelect').value; + const modelState=(typeof _modelStateForSelect==='function') + ? _modelStateForSelect($('modelSelect'),selectedModel) + : {model:selectedModel,model_provider:null}; if(typeof closeModelDropdown==='function') closeModelDropdown(); - localStorage.setItem('hermes-webui-model', selectedModel); - await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})}); - S.session.model=selectedModel; + if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider); + else localStorage.setItem('hermes-webui-model', modelState.model); + await api('/api/session/update',{method:'POST',body:JSON.stringify({ + session_id:S.session.session_id, + workspace:S.session.workspace, + model:modelState.model, + model_provider:modelState.model_provider||null, + })}); + S.session.model=modelState.model; + S.session.model_provider=modelState.model_provider||null; if(typeof syncModelChip==='function') syncModelChip(); syncTopbar(); + // Clarify scope: composer model changes are session-local, not the global default. + if(typeof showToast==='function'){ + showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000); + } // Warn if selected model belongs to a different provider than what Hermes is configured for if(typeof _checkProviderMismatch==='function'){ const warn=_checkProviderMismatch(selectedModel); if(warn&&typeof showToast==='function') showToast(warn,4000); } - // Clarify scope: composer model changes are session-local, not the global default. - if(typeof showToast==='function'){ - showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000); - } }; $('msg').addEventListener('input',()=>{ autoResize(); @@ -913,11 +1207,20 @@ function applyBotName(){ // options are enough for first paint; the dynamic provider list can settle // after the saved session is visible. const _modelDropdownReady=populateModelDropdown().then(()=>{ - const savedModel=localStorage.getItem('hermes-webui-model'); + const savedState=(typeof _readPersistedModelState==='function') + ? _readPersistedModelState() + : (localStorage.getItem('hermes-webui-model')?{model:localStorage.getItem('hermes-webui-model'),model_provider:null}:null); + const savedModel=savedState&&savedState.model; if(savedModel && $('modelSelect')){ - $('modelSelect').value=savedModel; + const applied=(typeof _applyModelToDropdown==='function') + ? _applyModelToDropdown(savedModel,$('modelSelect'),savedState.model_provider||null) + : null; + if(!applied) $('modelSelect').value=savedModel; // If the value didn't take (model not in list), clear the bad pref - if($('modelSelect').value!==savedModel) localStorage.removeItem('hermes-webui-model'); + if(!applied&&$('modelSelect').value!==savedModel){ + if(typeof _clearPersistedModelState==='function') _clearPersistedModelState(); + else localStorage.removeItem('hermes-webui-model'); + } else if(typeof syncModelChip==='function') syncModelChip(); } if(S.session) syncTopbar(); diff --git a/static/i18n.js b/static/i18n.js index b3b21b07..73c765d2 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -15,6 +15,14 @@ const LOCALES = { mic_no_speech: 'No speech detected. Try again.', mic_network: 'Speech recognition unavailable.', mic_error: 'Voice input error: ', + // Turn-based voice mode (#1333) + voice_toggle: 'Voice input', + voice_listening: 'Listening…', + voice_speaking: 'Speaking…', + voice_thinking: 'Thinking…', + voice_error: 'Voice not supported in this browser', + voice_mode_active: 'Voice mode on', + voice_mode_off: 'Voice mode off', session_imported: 'Session imported', import_failed: 'Import failed: ', import_invalid_json: 'Invalid JSON', @@ -107,6 +115,7 @@ const LOCALES = { model_custom_placeholder: 'e.g. openai/gpt-5.4', model_search_placeholder: 'Search models…', model_search_no_results: 'No models found', + model_group_configured: 'Configured', model_scope_advisory: 'Applies to this conversation from your next message.', model_scope_toast: 'Applies to this conversation from your next message.', // commands.js @@ -185,6 +194,7 @@ const LOCALES = { branch_failed:'Fork failed: ', fork_from_here:'Fork from here', forked_from:'Forked from', + subagent_children:'Subagent sessions', btw_asking:'Asking side question...', btw_label:'Side question — not in history', btw_done:'Side question answered', @@ -419,6 +429,7 @@ const LOCALES = { tab_workspaces: 'Spaces', tab_profiles: 'Profiles', tab_todos: 'Todos', + tab_insights: 'Insights', tab_settings: 'Settings', new_conversation: 'New conversation', filter_conversations: 'Filter conversations...', @@ -439,6 +450,22 @@ const LOCALES = { new_skill: 'New skill', personal_memory: 'Personal memory', current_task_list: 'Current task list', + // Insights + insights_title: 'Usage Analytics', + insights_sessions: 'Sessions', + insights_messages: 'Messages', + insights_tokens: 'Tokens', + insights_cost: 'Estimated Cost', + insights_no_cost: 'N/A', + insights_models: 'Models', + insights_activity_by_day: 'Activity by Day', + insights_activity_by_hour: 'Activity by Hour', + insights_peak_hour: 'Peak: {hour}', + insights_token_breakdown: 'Token Breakdown', + insights_input_tokens: 'Input', + insights_output_tokens: 'Output', + insights_total: 'Total', + insights_footer: 'Showing data from the last {days} days', workspace_desc: 'Add and switch workspaces for your sessions.', session_meta_messages: (n) => `${n} msg${n === 1 ? '' : 's'}`, new_profile: 'New profile', @@ -462,6 +489,8 @@ const LOCALES = { settings_label_notifications: 'Browser notifications', settings_desc_notifications: 'Show a system notification when a response completes while the app is in the background.', settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.', + settings_label_api_redact: 'Redact sensitive data in API responses', + settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', 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.', @@ -809,6 +838,22 @@ const LOCALES = { excalidraw_empty: 'Empty diagram', excalidraw_render_error: 'Failed to render diagram', excalidraw_simplified: 'Simplified SVG preview — not pixel-identical to Excalidraw canvas', + // ── Checkpoints / Rollback ── + checkpoint_title: 'Checkpoints', + checkpoint_empty: 'No checkpoints found for this workspace.', + checkpoint_loading: 'Loading checkpoints…', + checkpoint_error: 'Failed to load checkpoints', + checkpoint_date: 'Date', + checkpoint_message: 'Message', + checkpoint_files: 'Files', + checkpoint_view_diff: 'View diff', + checkpoint_restore: 'Restore', + checkpoint_restore_confirm_title: 'Restore checkpoint?', + checkpoint_restore_confirm_message: (ckpt) => `Restore workspace to checkpoint "${ckpt}"? This will overwrite files with the saved versions. Files added after this checkpoint will not be deleted.`, + checkpoint_restored: 'Checkpoint restored', + checkpoint_diff_title: 'Changes in checkpoint', + checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', + checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, }, ru: { @@ -821,6 +866,13 @@ const LOCALES = { mic_no_speech: 'Речь не распознана. Попробуйте ещё раз.', mic_network: 'Распознавание речи недоступно.', mic_error: 'Ошибка ввода речи: ', + voice_toggle: 'Голосовой ввод', + voice_listening: 'Слушаю…', + voice_speaking: 'Говорю…', + voice_thinking: 'Думаю…', + voice_error: 'Голосовой ввод не поддерживается в этом браузере', + voice_mode_active: 'Голосовой режим включён', + voice_mode_off: 'Голосовой режим выключен', session_imported: 'Сеанс импортирован', import_failed: 'Не удалось импортировать: ', import_invalid_json: 'Неверный JSON', @@ -960,6 +1012,7 @@ const LOCALES = { preserved_task_list_label: 'Сохранённый список задач', focus_label: 'Фокус', model_search_no_results: 'Модели не найдены', + model_group_configured: 'Настроенные', model_search_placeholder: 'Поиск моделей…', model_scope_advisory: 'Применяется к этой беседе со следующего сообщения.', model_scope_toast: 'Применяется к этой беседе со следующего сообщения.', @@ -1072,6 +1125,7 @@ const LOCALES = { tab_workspaces: 'Рабочие пространства', tab_profiles: 'Профили', tab_todos: 'Список дел', + tab_insights: 'Аналитика', tab_settings: 'Настройки', new_conversation: 'Новая беседа', filter_conversations: 'Фильтр бесед...', @@ -1559,6 +1613,39 @@ const LOCALES = { settings_label_tts_rate: 'Скорость речи', settings_label_tts_pitch: 'Тон речи', + checkpoint_date: 'Date', // TODO: translate + checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate + checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate + checkpoint_diff_title: 'Changes in checkpoint', // TODO: translate + checkpoint_empty: 'No checkpoints found for this workspace.', // TODO: translate + checkpoint_error: 'Failed to load checkpoints', // TODO: translate + checkpoint_files: 'Files', // TODO: translate + checkpoint_loading: 'Loading checkpoints…', // TODO: translate + checkpoint_message: 'Message', // TODO: translate + checkpoint_restore: 'Restore', // TODO: translate + checkpoint_restore_confirm_message: (ckpt) => `Restore workspace to checkpoint "${ckpt}"? This will overwrite files with the saved versions. Files added after this checkpoint will not be deleted.`, // TODO: translate + checkpoint_restore_confirm_title: 'Restore checkpoint?', // TODO: translate + checkpoint_restored: 'Checkpoint restored', // TODO: translate + checkpoint_title: 'Checkpoints', // TODO: translate + checkpoint_view_diff: 'View diff', // TODO: translate + insights_activity_by_day: 'Activity by Day', // TODO: translate + insights_activity_by_hour: 'Activity by Hour', // TODO: translate + insights_cost: 'Estimated Cost', // TODO: translate + insights_footer: 'Showing data from the last {days} days', // TODO: translate + insights_input_tokens: 'Input', // TODO: translate + insights_messages: 'Messages', // TODO: translate + insights_models: 'Models', // TODO: translate + insights_no_cost: 'N/A', // TODO: translate + insights_output_tokens: 'Output', // TODO: translate + insights_peak_hour: 'Peak: {hour}', // TODO: translate + insights_sessions: 'Sessions', // TODO: translate + insights_title: 'Usage Analytics', // TODO: translate + insights_token_breakdown: 'Token Breakdown', // TODO: translate + insights_tokens: 'Tokens', // TODO: translate + insights_total: 'Total', // TODO: translate + settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate + settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate + subagent_children: 'Subagent sessions', // TODO: translate }, es: { @@ -1652,6 +1739,7 @@ const LOCALES = { model_custom_placeholder: 'p. ej. openai/gpt-5.4', model_search_placeholder: 'Buscar modelos…', model_search_no_results: 'No se encontraron modelos', + model_group_configured: 'Configurados', model_scope_advisory: 'Se aplica a esta conversación desde tu próximo mensaje.', model_scope_toast: 'Se aplica a esta conversación desde tu próximo mensaje.', // commands.js @@ -1829,6 +1917,7 @@ const LOCALES = { tab_workspaces: 'Espacios', tab_profiles: 'Perfiles', tab_todos: 'Todos', + tab_insights: 'Analíticas', tab_settings: 'Ajustes', new_conversation: 'Nueva conversación', filter_conversations: 'Filtrar conversaciones...', @@ -2303,6 +2392,46 @@ const LOCALES = { settings_desc_tts_voice: 'Seleccionar voz para síntesis de voz', settings_label_tts_rate: 'Velocidad de voz', settings_label_tts_pitch: 'Tono de voz', + checkpoint_date: 'Date', // TODO: translate + checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate + checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate + checkpoint_diff_title: 'Changes in checkpoint', // TODO: translate + checkpoint_empty: 'No checkpoints found for this workspace.', // TODO: translate + checkpoint_error: 'Failed to load checkpoints', // TODO: translate + checkpoint_files: 'Files', // TODO: translate + checkpoint_loading: 'Loading checkpoints…', // TODO: translate + checkpoint_message: 'Message', // TODO: translate + checkpoint_restore: 'Restore', // TODO: translate + checkpoint_restore_confirm_message: (ckpt) => `Restore workspace to checkpoint "${ckpt}"? This will overwrite files with the saved versions. Files added after this checkpoint will not be deleted.`, // TODO: translate + checkpoint_restore_confirm_title: 'Restore checkpoint?', // TODO: translate + checkpoint_restored: 'Checkpoint restored', // TODO: translate + checkpoint_title: 'Checkpoints', // TODO: translate + checkpoint_view_diff: 'View diff', // TODO: translate + insights_activity_by_day: 'Activity by Day', // TODO: translate + insights_activity_by_hour: 'Activity by Hour', // TODO: translate + insights_cost: 'Estimated Cost', // TODO: translate + insights_footer: 'Showing data from the last {days} days', // TODO: translate + insights_input_tokens: 'Input', // TODO: translate + insights_messages: 'Messages', // TODO: translate + insights_models: 'Models', // TODO: translate + insights_no_cost: 'N/A', // TODO: translate + insights_output_tokens: 'Output', // TODO: translate + insights_peak_hour: 'Peak: {hour}', // TODO: translate + insights_sessions: 'Sessions', // TODO: translate + insights_title: 'Usage Analytics', // TODO: translate + insights_token_breakdown: 'Token Breakdown', // TODO: translate + insights_tokens: 'Tokens', // TODO: translate + insights_total: 'Total', // TODO: translate + settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate + settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate + voice_error: 'Voice not supported in this browser', // TODO: translate + voice_listening: 'Listening…', // TODO: translate + voice_mode_active: 'Voice mode on', // TODO: translate + voice_mode_off: 'Voice mode off', // TODO: translate + voice_speaking: 'Speaking…', // TODO: translate + voice_thinking: 'Thinking…', // TODO: translate + voice_toggle: 'Voice input', // TODO: translate + subagent_children: 'Subagent sessions', // TODO: translate }, de: { @@ -2576,6 +2705,7 @@ const LOCALES = { tab_workspaces: 'Spaces', tab_profiles: 'Profile', tab_todos: 'Todos', + tab_insights: 'Statistiken', tab_settings: 'Einstellungen', new_conversation: 'Neuer Chat', filter_conversations: 'Chats filtern...', @@ -2786,6 +2916,7 @@ const LOCALES = { model_custom_placeholder: 'z.B. openai/gpt-5.4', model_search_placeholder: 'Modelle suchen…', model_search_no_results: 'Keine Modelle gefunden', + model_group_configured: 'Konfiguriert', session_time_unknown: 'Unbekannt', session_time_minutes_ago: 'Vor {n} Minuten', session_time_hours_ago: 'Vor {n} Stunden', @@ -3051,6 +3182,46 @@ const LOCALES = { settings_label_tts_rate: 'Sprechgeschwindigkeit', settings_label_tts_pitch: 'Tonhöhe', + checkpoint_date: 'Date', // TODO: translate + checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate + checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate + checkpoint_diff_title: 'Changes in checkpoint', // TODO: translate + checkpoint_empty: 'No checkpoints found for this workspace.', // TODO: translate + checkpoint_error: 'Failed to load checkpoints', // TODO: translate + checkpoint_files: 'Files', // TODO: translate + checkpoint_loading: 'Loading checkpoints…', // TODO: translate + checkpoint_message: 'Message', // TODO: translate + checkpoint_restore: 'Restore', // TODO: translate + checkpoint_restore_confirm_message: (ckpt) => `Restore workspace to checkpoint "${ckpt}"? This will overwrite files with the saved versions. Files added after this checkpoint will not be deleted.`, // TODO: translate + checkpoint_restore_confirm_title: 'Restore checkpoint?', // TODO: translate + checkpoint_restored: 'Checkpoint restored', // TODO: translate + checkpoint_title: 'Checkpoints', // TODO: translate + checkpoint_view_diff: 'View diff', // TODO: translate + insights_activity_by_day: 'Activity by Day', // TODO: translate + insights_activity_by_hour: 'Activity by Hour', // TODO: translate + insights_cost: 'Estimated Cost', // TODO: translate + insights_footer: 'Showing data from the last {days} days', // TODO: translate + insights_input_tokens: 'Input', // TODO: translate + insights_messages: 'Messages', // TODO: translate + insights_models: 'Models', // TODO: translate + insights_no_cost: 'N/A', // TODO: translate + insights_output_tokens: 'Output', // TODO: translate + insights_peak_hour: 'Peak: {hour}', // TODO: translate + insights_sessions: 'Sessions', // TODO: translate + insights_title: 'Usage Analytics', // TODO: translate + insights_token_breakdown: 'Token Breakdown', // TODO: translate + insights_tokens: 'Tokens', // TODO: translate + insights_total: 'Total', // TODO: translate + settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate + settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate + voice_error: 'Voice not supported in this browser', // TODO: translate + voice_listening: 'Listening…', // TODO: translate + voice_mode_active: 'Voice mode on', // TODO: translate + voice_mode_off: 'Voice mode off', // TODO: translate + voice_speaking: 'Speaking…', // TODO: translate + voice_thinking: 'Thinking…', // TODO: translate + voice_toggle: 'Voice input', // TODO: translate + subagent_children: 'Subagent sessions', // TODO: translate }, zh: { @@ -3144,6 +3315,7 @@ const LOCALES = { model_custom_placeholder: '\u4f8b\u5982 openai/gpt-5.4', model_search_placeholder: '\u641c\u7d22\u6a21\u578b\u2026', model_search_no_results: '\u672a\u627e\u5230\u6a21\u578b', + model_group_configured: '已配置', model_scope_advisory: '\u4ece\u4e0b\u4e00\u6761\u6d88\u606f\u8d77\u5e94\u7528\u4e8e\u5f53\u524d\u5bf9\u8bdd\u3002', model_scope_toast: '\u4ece\u4e0b\u4e00\u6761\u6d88\u606f\u8d77\u5e94\u7528\u4e8e\u5f53\u524d\u5bf9\u8bdd\u3002', // commands.js @@ -3318,6 +3490,7 @@ const LOCALES = { tab_skills: '技能', tab_tasks: '任务', tab_todos: '待办', + tab_insights: '统计', tab_workspaces: '工作区', tab_profiles: '配置', tab_settings: '设置', @@ -3793,6 +3966,46 @@ const LOCALES = { settings_desc_tts_voice: '选择语音合成声音', settings_label_tts_rate: '语速', settings_label_tts_pitch: '音调', + checkpoint_date: 'Date', // TODO: translate + checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate + checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate + checkpoint_diff_title: 'Changes in checkpoint', // TODO: translate + checkpoint_empty: 'No checkpoints found for this workspace.', // TODO: translate + checkpoint_error: 'Failed to load checkpoints', // TODO: translate + checkpoint_files: 'Files', // TODO: translate + checkpoint_loading: 'Loading checkpoints…', // TODO: translate + checkpoint_message: 'Message', // TODO: translate + checkpoint_restore: 'Restore', // TODO: translate + checkpoint_restore_confirm_message: (ckpt) => `Restore workspace to checkpoint "${ckpt}"? This will overwrite files with the saved versions. Files added after this checkpoint will not be deleted.`, // TODO: translate + checkpoint_restore_confirm_title: 'Restore checkpoint?', // TODO: translate + checkpoint_restored: 'Checkpoint restored', // TODO: translate + checkpoint_title: 'Checkpoints', // TODO: translate + checkpoint_view_diff: 'View diff', // TODO: translate + insights_activity_by_day: 'Activity by Day', // TODO: translate + insights_activity_by_hour: 'Activity by Hour', // TODO: translate + insights_cost: 'Estimated Cost', // TODO: translate + insights_footer: 'Showing data from the last {days} days', // TODO: translate + insights_input_tokens: 'Input', // TODO: translate + insights_messages: 'Messages', // TODO: translate + insights_models: 'Models', // TODO: translate + insights_no_cost: 'N/A', // TODO: translate + insights_output_tokens: 'Output', // TODO: translate + insights_peak_hour: 'Peak: {hour}', // TODO: translate + insights_sessions: 'Sessions', // TODO: translate + insights_title: 'Usage Analytics', // TODO: translate + insights_token_breakdown: 'Token Breakdown', // TODO: translate + insights_tokens: 'Tokens', // TODO: translate + insights_total: 'Total', // TODO: translate + settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate + settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate + voice_error: 'Voice not supported in this browser', // TODO: translate + voice_listening: 'Listening…', // TODO: translate + voice_mode_active: 'Voice mode on', // TODO: translate + voice_mode_off: 'Voice mode off', // TODO: translate + voice_speaking: 'Speaking…', // TODO: translate + voice_thinking: 'Thinking…', // TODO: translate + voice_toggle: 'Voice input', // TODO: translate + subagent_children: 'Subagent sessions', // TODO: translate }, // Traditional Chinese (zh-Hant) @@ -4093,6 +4306,7 @@ const LOCALES = { tab_skills: '\u6280\u80fd', tab_tasks: '\u4efb\u52d9', tab_todos: '待辦', + tab_insights: '統計', tab_workspaces: '\u5de5\u4f5c\u5340', new_conversation: '新對話', filter_conversations: '篩選對話', @@ -4300,6 +4514,7 @@ const LOCALES = { model_custom_label: '\u81ea\u8a02\u6a21\u578b ID', model_custom_placeholder: '\u4f8b\u5982 openai/gpt-5.4', model_search_no_results: '\u627e\u4e0d\u5230\u6a21\u578b', + model_group_configured: '已設定', model_search_placeholder: '\u641c\u5c0b\u6a21\u578b\u2026', model_scope_advisory: '\u5f9e\u4e0b\u4e00\u5247\u8a0a\u606f\u8d77\u9069\u7528\u65bc\u6b64\u6703\u8a71\u3002', model_scope_toast: '\u5f9e\u4e0b\u4e00\u5247\u8a0a\u606f\u8d77\u9069\u7528\u65bc\u6b64\u6703\u8a71\u3002', @@ -4643,6 +4858,46 @@ const LOCALES = { settings_label_tts_rate: '語速', settings_label_tts_pitch: '音調', + checkpoint_date: 'Date', // TODO: translate + checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate + checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate + checkpoint_diff_title: 'Changes in checkpoint', // TODO: translate + checkpoint_empty: 'No checkpoints found for this workspace.', // TODO: translate + checkpoint_error: 'Failed to load checkpoints', // TODO: translate + checkpoint_files: 'Files', // TODO: translate + checkpoint_loading: 'Loading checkpoints…', // TODO: translate + checkpoint_message: 'Message', // TODO: translate + checkpoint_restore: 'Restore', // TODO: translate + checkpoint_restore_confirm_message: (ckpt) => `Restore workspace to checkpoint "${ckpt}"? This will overwrite files with the saved versions. Files added after this checkpoint will not be deleted.`, // TODO: translate + checkpoint_restore_confirm_title: 'Restore checkpoint?', // TODO: translate + checkpoint_restored: 'Checkpoint restored', // TODO: translate + checkpoint_title: 'Checkpoints', // TODO: translate + checkpoint_view_diff: 'View diff', // TODO: translate + insights_activity_by_day: 'Activity by Day', // TODO: translate + insights_activity_by_hour: 'Activity by Hour', // TODO: translate + insights_cost: 'Estimated Cost', // TODO: translate + insights_footer: 'Showing data from the last {days} days', // TODO: translate + insights_input_tokens: 'Input', // TODO: translate + insights_messages: 'Messages', // TODO: translate + insights_models: 'Models', // TODO: translate + insights_no_cost: 'N/A', // TODO: translate + insights_output_tokens: 'Output', // TODO: translate + insights_peak_hour: 'Peak: {hour}', // TODO: translate + insights_sessions: 'Sessions', // TODO: translate + insights_title: 'Usage Analytics', // TODO: translate + insights_token_breakdown: 'Token Breakdown', // TODO: translate + insights_tokens: 'Tokens', // TODO: translate + insights_total: 'Total', // TODO: translate + settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate + settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate + voice_error: 'Voice not supported in this browser', // TODO: translate + voice_listening: 'Listening…', // TODO: translate + voice_mode_active: 'Voice mode on', // TODO: translate + voice_mode_off: 'Voice mode off', // TODO: translate + voice_speaking: 'Speaking…', // TODO: translate + voice_thinking: 'Thinking…', // TODO: translate + voice_toggle: 'Voice input', // TODO: translate + subagent_children: 'Subagent sessions', // TODO: translate }, pt: { @@ -4711,6 +4966,7 @@ const LOCALES = { model_custom_placeholder: 'ex: openai/gpt-5.4', model_search_placeholder: 'Buscar modelos…', model_search_no_results: 'Nenhum modelo encontrado', + model_group_configured: 'Configurados', // commands.js cmd_clear: 'Limpar mensagens da conversa', cmd_compress: 'Comprimir manualmente o contexto (uso: /compress [tópico])', @@ -4981,6 +5237,7 @@ const LOCALES = { tab_workspaces: 'Spaces', tab_profiles: 'Perfis', tab_todos: 'Todos', + tab_insights: 'Estatísticas', tab_settings: 'Configurações', new_conversation: 'Nova conversa', filter_conversations: 'Filtrar conversas...', @@ -5305,6 +5562,46 @@ const LOCALES = { settings_desc_tts_voice: 'Selecionar voz para síntese de voz', settings_label_tts_rate: 'Velocidade da fala', settings_label_tts_pitch: 'Tom da fala', + checkpoint_date: 'Date', // TODO: translate + checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate + checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate + checkpoint_diff_title: 'Changes in checkpoint', // TODO: translate + checkpoint_empty: 'No checkpoints found for this workspace.', // TODO: translate + checkpoint_error: 'Failed to load checkpoints', // TODO: translate + checkpoint_files: 'Files', // TODO: translate + checkpoint_loading: 'Loading checkpoints…', // TODO: translate + checkpoint_message: 'Message', // TODO: translate + checkpoint_restore: 'Restore', // TODO: translate + checkpoint_restore_confirm_message: (ckpt) => `Restore workspace to checkpoint "${ckpt}"? This will overwrite files with the saved versions. Files added after this checkpoint will not be deleted.`, // TODO: translate + checkpoint_restore_confirm_title: 'Restore checkpoint?', // TODO: translate + checkpoint_restored: 'Checkpoint restored', // TODO: translate + checkpoint_title: 'Checkpoints', // TODO: translate + checkpoint_view_diff: 'View diff', // TODO: translate + insights_activity_by_day: 'Activity by Day', // TODO: translate + insights_activity_by_hour: 'Activity by Hour', // TODO: translate + insights_cost: 'Estimated Cost', // TODO: translate + insights_footer: 'Showing data from the last {days} days', // TODO: translate + insights_input_tokens: 'Input', // TODO: translate + insights_messages: 'Messages', // TODO: translate + insights_models: 'Models', // TODO: translate + insights_no_cost: 'N/A', // TODO: translate + insights_output_tokens: 'Output', // TODO: translate + insights_peak_hour: 'Peak: {hour}', // TODO: translate + insights_sessions: 'Sessions', // TODO: translate + insights_title: 'Usage Analytics', // TODO: translate + insights_token_breakdown: 'Token Breakdown', // TODO: translate + insights_tokens: 'Tokens', // TODO: translate + insights_total: 'Total', // TODO: translate + settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate + settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate + voice_error: 'Voice not supported in this browser', // TODO: translate + voice_listening: 'Listening…', // TODO: translate + voice_mode_active: 'Voice mode on', // TODO: translate + voice_mode_off: 'Voice mode off', // TODO: translate + voice_speaking: 'Speaking…', // TODO: translate + voice_thinking: 'Thinking…', // TODO: translate + voice_toggle: 'Voice input', // TODO: translate + subagent_children: 'Subagent sessions', // TODO: translate }, ko: { _lang: 'ko', @@ -5397,6 +5694,7 @@ const LOCALES = { model_custom_placeholder: 'e.g. openai/gpt-5.4', model_search_placeholder: 'Search models…', model_search_no_results: 'No models found', + model_group_configured: '구성됨', model_scope_advisory: '다음 메시지부터 이 대화에 적용됩니다.', model_scope_toast: '다음 메시지부터 이 대화에 적용됩니다.', // commands.js @@ -5694,6 +5992,7 @@ const LOCALES = { tab_workspaces: '공간', tab_profiles: 'Agent 프로필', tab_todos: 'Todos', + tab_insights: '통계', tab_settings: '설정', new_conversation: '새 대화', filter_conversations: '대화 필터…', @@ -6101,6 +6400,46 @@ const LOCALES = { settings_desc_tts_voice: '음성 합성 음성 선택', settings_label_tts_rate: '말 속도', settings_label_tts_pitch: '말 톤', + checkpoint_date: 'Date', // TODO: translate + checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate + checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate + checkpoint_diff_title: 'Changes in checkpoint', // TODO: translate + checkpoint_empty: 'No checkpoints found for this workspace.', // TODO: translate + checkpoint_error: 'Failed to load checkpoints', // TODO: translate + checkpoint_files: 'Files', // TODO: translate + checkpoint_loading: 'Loading checkpoints…', // TODO: translate + checkpoint_message: 'Message', // TODO: translate + checkpoint_restore: 'Restore', // TODO: translate + checkpoint_restore_confirm_message: (ckpt) => `Restore workspace to checkpoint "${ckpt}"? This will overwrite files with the saved versions. Files added after this checkpoint will not be deleted.`, // TODO: translate + checkpoint_restore_confirm_title: 'Restore checkpoint?', // TODO: translate + checkpoint_restored: 'Checkpoint restored', // TODO: translate + checkpoint_title: 'Checkpoints', // TODO: translate + checkpoint_view_diff: 'View diff', // TODO: translate + insights_activity_by_day: 'Activity by Day', // TODO: translate + insights_activity_by_hour: 'Activity by Hour', // TODO: translate + insights_cost: 'Estimated Cost', // TODO: translate + insights_footer: 'Showing data from the last {days} days', // TODO: translate + insights_input_tokens: 'Input', // TODO: translate + insights_messages: 'Messages', // TODO: translate + insights_models: 'Models', // TODO: translate + insights_no_cost: 'N/A', // TODO: translate + insights_output_tokens: 'Output', // TODO: translate + insights_peak_hour: 'Peak: {hour}', // TODO: translate + insights_sessions: 'Sessions', // TODO: translate + insights_title: 'Usage Analytics', // TODO: translate + insights_token_breakdown: 'Token Breakdown', // TODO: translate + insights_tokens: 'Tokens', // TODO: translate + insights_total: 'Total', // TODO: translate + settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate + settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate + voice_error: 'Voice not supported in this browser', // TODO: translate + voice_listening: 'Listening…', // TODO: translate + voice_mode_active: 'Voice mode on', // TODO: translate + voice_mode_off: 'Voice mode off', // TODO: translate + voice_speaking: 'Speaking…', // TODO: translate + voice_thinking: 'Thinking…', // TODO: translate + voice_toggle: 'Voice input', // TODO: translate + subagent_children: 'Subagent sessions', // TODO: translate }, }; diff --git a/static/index.html b/static/index.html index b89c3263..a4b15258 100644 --- a/static/index.html +++ b/static/index.html @@ -88,6 +88,7 @@ +
@@ -101,6 +102,7 @@ + @@ -153,6 +155,23 @@
+ +
+
+ Insights +
+ +
+
+
+ +
+
@@ -359,6 +378,10 @@
+ +
+
+
Usage Analytics
+
+
+
Loading...
+
+
@@ -767,6 +808,13 @@
Group thinking and tool calls into one collapsed activity section per assistant turn.
+
+ +
Self-hosted users can disable for transparency (not recommended for shared instances).
+
for a given model ID. @@ -239,9 +240,60 @@ function _getOptionProviderId(opt){ return group.dataset.provider; } const value=String(opt.value||''); - if(value.startsWith('@') && value.includes(':')) return value.slice(1,value.indexOf(':')); + if(value.startsWith('@') && value.includes(':')) return value.slice(1,value.lastIndexOf(':')); return ''; } +function _providerFromModelValue(modelId){ + const value=String(modelId||'').trim(); + if(value.startsWith('@')&&value.includes(':')) return value.slice(1,value.lastIndexOf(':')); + return ''; +} +function _modelStateForSelect(sel, modelId){ + const value=String(modelId||'').trim(); + if(!value) return {model:'',model_provider:null}; + const explicitProvider=_providerFromModelValue(value); + if(explicitProvider) return {model:value,model_provider:explicitProvider}; + const opt=sel&&sel.selectedOptions&&sel.selectedOptions[0]; + const provider=String(_getOptionProviderId(opt)||'').trim(); + return {model:value,model_provider:(provider&&provider!=='default')?provider:null}; +} +function _providerQualifiedModelValueForSelect(sel, modelId){ + return _modelStateForSelect(sel,modelId).model; +} +function _readPersistedModelState(){ + try{ + const raw=localStorage.getItem(MODEL_STATE_KEY); + if(raw){ + const parsed=JSON.parse(raw); + if(parsed&&parsed.model){ + return { + model:String(parsed.model||''), + model_provider:parsed.model_provider?String(parsed.model_provider):(_providerFromModelValue(parsed.model)||null), + }; + } + } + }catch(_){} + const legacy=localStorage.getItem('hermes-webui-model'); + if(!legacy) return null; + return {model:legacy,model_provider:_providerFromModelValue(legacy)||null}; +} +function _writePersistedModelState(model, modelProvider){ + const value=String(model||'').trim(); + const provider=modelProvider?String(modelProvider).trim():(_providerFromModelValue(value)||null); + if(!value){ + localStorage.removeItem('hermes-webui-model'); + localStorage.removeItem(MODEL_STATE_KEY); + return; + } + localStorage.setItem('hermes-webui-model', value); + try{ + localStorage.setItem(MODEL_STATE_KEY, JSON.stringify({model:value,model_provider:provider||null})); + }catch(_){} +} +function _clearPersistedModelState(){ + localStorage.removeItem('hermes-webui-model'); + localStorage.removeItem(MODEL_STATE_KEY); +} function _findModelInDropdown(modelId, sel, preferredProviderId){ if(!modelId||!sel) return null; const options=Array.from(sel.options); @@ -250,7 +302,12 @@ function _findModelInDropdown(modelId, sel, preferredProviderId){ // Also strip @provider: prefix from deduplicated model IDs (#1228, #1313). const norm=s=>s.toLowerCase().replace(/^[^/]+\//,'').replace(/^@([^:]+:)+/,'').replace(/-/g,'.'); const target=norm(modelId); - const preferred=String(preferredProviderId||'').toLowerCase(); + let explicitProvider=''; + const rawModel=String(modelId||''); + if(rawModel.startsWith('@')&&rawModel.includes(':')){ + explicitProvider=rawModel.slice(1,rawModel.lastIndexOf(':')); + } + const preferred=String(preferredProviderId||explicitProvider||'').toLowerCase(); if(preferred){ const providerMatch=options.find(o=>norm(o.value)===target && _getOptionProviderId(o).toLowerCase()===preferred); if(providerMatch) return providerMatch.value; @@ -314,7 +371,7 @@ async function populateModelDropdown(){ sel.appendChild(og); } // Set default model from server if no localStorage preference - if(data.default_model && !localStorage.getItem('hermes-webui-model')){ + if(data.default_model && !(typeof _readPersistedModelState==='function'&&_readPersistedModelState()) && !localStorage.getItem('hermes-webui-model')){ _applyModelToDropdown(data.default_model, sel, data.active_provider||null); } if(typeof syncModelChip==='function') syncModelChip(); @@ -367,7 +424,7 @@ function _addLiveModelsToSelect(provider, models, sel){ const existingNorm=new Set([...sel.options].map(o=>_normId(o.value))); let added=0; const _ap=(window._activeProvider||'').toLowerCase(); - const _isPortalFetch=_ap && _ap!=='openrouter' && _ap!=='custom' && provider===_ap; + const _isPortalFetch=_ap && _ap!=='openrouter' && _ap!=='custom' && _ap!=='openai-codex' && provider===_ap; for(const m of models){ let mid=m.id; if(_isPortalFetch && !mid.startsWith('@')){ @@ -383,13 +440,14 @@ function _addLiveModelsToSelect(provider, models, sel){ _dynamicModelLabels[mid]=m.label||m.id; added++; } - if(added>0 && currentVal) _applyModelToDropdown(currentVal, sel); + const currentProvider=(S.session&&S.session.model_provider)||null; + if(added>0 && currentVal) _applyModelToDropdown(currentVal, sel, currentProvider); // After live models are added, re-apply the session's model in case it was // absent from the static list and syncTopbar() fired before the live fetch // completed (#1169). This ensures the session model wins over any premature // fallback that may have set sel.value to the first available option. if(S.session && S.session.model && sel.id==='modelSelect'){ - const reapplied=_applyModelToDropdown(S.session.model, sel); + const reapplied=_applyModelToDropdown(S.session.model, sel, S.session.model_provider||null); if(reapplied && typeof syncModelChip==='function') syncModelChip(); } return added; @@ -1885,7 +1943,10 @@ function setBusy(v){ // Note: profile is NOT restored — full profile switch requires server interaction if(next.model&&S.session&&next.model!==S.session.model){ S.session.model=next.model; - if(typeof _applyModelToDropdown==='function'&&$('modelSelect')) _applyModelToDropdown(next.model,$('modelSelect')); + } + if(next.model_provider&&S.session) S.session.model_provider=next.model_provider; + if(next.model&&S.session){ + if(typeof _applyModelToDropdown==='function'&&$('modelSelect')) _applyModelToDropdown(next.model,$('modelSelect'),S.session.model_provider||null); if(typeof syncModelChip==='function') syncModelChip(); } autoResize(); @@ -1968,8 +2029,9 @@ function _renderQueueChips(sid){ const _doMerge=(snapshot)=>{ const combined=snapshot.map(e=>e&&(e.text||e.message||e.content||'')).filter(Boolean).join('\n\n'); const liveQ=_getSessionQueue(sid,false); + const first=snapshot.find(e=>e)||{}; const firstFiles=(snapshot.find(e=>e&&Array.isArray(e.files)&&e.files.length)||{files:[]}).files; - liveQ.length=0;liveQ.push({text:combined,files:firstFiles,_queued_at:Date.now()}); + liveQ.length=0;liveQ.push({text:combined,files:firstFiles,model:first.model||'',model_provider:first.model_provider||null,_queued_at:Date.now()}); SESSION_QUEUES[sid]=liveQ; try{sessionStorage.setItem('hermes-queue-'+sid,JSON.stringify(liveQ));}catch(_){} delete _queueRenderKeys[sid]; @@ -2731,10 +2793,12 @@ function syncTopbar(){ let currentModel=S.session.model||''; if(modelOverride){ S._pendingProfileModel=null; - _applyModelToDropdown(modelOverride,$('modelSelect')); + const providerOverride=S._pendingProfileModelProvider||null; + S._pendingProfileModelProvider=null; + _applyModelToDropdown(modelOverride,$('modelSelect'),providerOverride); currentModel=modelOverride; } else { - const applied=_applyModelToDropdown(currentModel,$('modelSelect')); + const applied=_applyModelToDropdown(currentModel,$('modelSelect'),S.session.model_provider||null); // If the model isn't in the current provider list, silently reset to the // first available model so stale values don't pollute the picker (#829). if(!applied && currentModel){ @@ -2756,11 +2820,12 @@ function syncTopbar(){ modelSel.value=first.value; if(!deferModelCorrection){ S.session.model=first.value; + S.session.model_provider=_getOptionProviderId(first)||null; // Persist the correction so the session doesn't re-inject on next load. fetch(new URL('api/session/update',document.baseURI||location.href).href,{ method:'POST',credentials:'include', headers:{'Content-Type':'application/json'}, - body:JSON.stringify({session_id:S.session.id||S.session.session_id,model:first.value}) + body:JSON.stringify({session_id:S.session.id||S.session.session_id,model:first.value,model_provider:S.session.model_provider||null}) }).catch(()=>{}); } } diff --git a/tests/test_465_session_branching.py b/tests/test_465_session_branching.py index d8a68b7f..058a0862 100644 --- a/tests/test_465_session_branching.py +++ b/tests/test_465_session_branching.py @@ -235,8 +235,8 @@ def test_parent_indicator_clickable(): src = f.read() # Find the parent indicator block parent_block = re.search( - r'parent_session_id\).*?(?=titleRow\.appendChild)', - src, re.DOTALL + r'branch-indicator[\s\S]*?parent_session_id[\s\S]*?titleRow\.appendChild', + src ) assert parent_block, "Could not find parent indicator block" block = parent_block.group(0) diff --git a/tests/test_499_tts_playback.py b/tests/test_499_tts_playback.py index 21630dc9..efbc1933 100644 --- a/tests/test_499_tts_playback.py +++ b/tests/test_499_tts_playback.py @@ -193,3 +193,57 @@ class TestTtsStyles: src = _read('style.css') assert 'tts-pulse' in src, \ "tts-pulse animation not found in style.css" + + +class TestIssue1409TtsToggleBodyClass: + """Regression: #1409 — TTS toggle had no effect because of CSS specificity collision. + + Original bug: ``_applyTtsEnabled`` set ``btn.style.display=enabled?'':'none'``. + The empty-string branch removes the inline override, after which the + ``.msg-tts-btn { display:none; }`` rule from style.css applies — so both + "enabled" and "disabled" states left the button hidden. + + Fix: toggle a body-level class (``body.tts-enabled``) and gate the speaker + icon on a compound selector ``body.tts-enabled .msg-tts-btn``. This bypasses + the inline-style cascade collision and survives ``renderMd()`` re-renders. + """ + + def test_apply_tts_enabled_uses_body_class(self): + """_applyTtsEnabled must toggle the document body's `tts-enabled` class.""" + src = _read('panels.js') + # The new shape: toggle body class instead of writing inline display + assert "document.body.classList.toggle('tts-enabled'" in src, ( + "_applyTtsEnabled must toggle the body.tts-enabled class — see #1409. " + "Reverting to inline `style.display` will silently break the toggle " + "again because of the .msg-action-btn / .msg-tts-btn cascade." + ) + + def test_apply_tts_enabled_does_not_use_inline_display(self): + """_applyTtsEnabled must NOT set inline `style.display` on .msg-tts-btn.""" + src = _read('panels.js') + # Find the function body and check it doesn't set inline display + # on individual buttons (the broken pattern). + m = re.search( + r'function _applyTtsEnabled\([^)]*\)\s*\{(?P[^}]*)\}', + src, + ) + assert m, "_applyTtsEnabled function body not found in panels.js" + body = m.group('body') + assert '.style.display' not in body, ( + "_applyTtsEnabled body must not set inline style.display — that's " + "the #1409 bug. Use body.classList.toggle('tts-enabled') instead." + ) + + def test_body_class_selector_in_css(self): + """style.css must show .msg-tts-btn only when body.tts-enabled is set.""" + src = _read('style.css') + assert 'body.tts-enabled .msg-tts-btn' in src, ( + "Missing `body.tts-enabled .msg-tts-btn` selector in style.css — " + "without this rule the body class has no visual effect (#1409)." + ) + # The default-hidden rule must still be present (so no body class = no icon). + assert '.msg-tts-btn{display:none;}' in src or \ + re.search(r'\.msg-tts-btn\s*\{[^}]*display\s*:\s*none', src), ( + "Default `.msg-tts-btn{display:none;}` rule must remain so the " + "icon is hidden by default (#1409)." + ) diff --git a/tests/test_issue856_pinned_indicator_layout.py b/tests/test_issue856_pinned_indicator_layout.py index aeb0f412..f90cc7b0 100644 --- a/tests/test_issue856_pinned_indicator_layout.py +++ b/tests/test_issue856_pinned_indicator_layout.py @@ -11,7 +11,8 @@ def test_pinned_indicator_renders_inside_title_row(): title_row_idx = SESSIONS_JS.find("titleRow.className='session-title-row';") assert title_row_idx != -1, "session title row construction not found" - assert "body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))" in SESSIONS_JS + assert ("body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))" in SESSIONS_JS + or "body.appendChild(parentEl)" in SESSIONS_JS) assert "function _renderOneSession(s, isPinnedGroup=false)" in SESSIONS_JS assert "if(s.pinned&&!isPinnedGroup){" in SESSIONS_JS diff --git a/tests/test_model_picker_badges.py b/tests/test_model_picker_badges.py index 3b2ffa73..b14ec5c8 100644 --- a/tests/test_model_picker_badges.py +++ b/tests/test_model_picker_badges.py @@ -97,6 +97,19 @@ def test_ui_badge_lookup_prefers_row_provider_for_duplicate_model_ids(): assert "const providerMatch=matches.find(badge=>String(badge&&badge.provider||'').toLowerCase()===provider);" in js +def test_configured_model_group_label_has_i18n_key(): + """The Configured model group must not render the raw i18n key.""" + root = Path(__file__).resolve().parent.parent + i18n = (root / "static" / "i18n.js").read_text(encoding="utf-8") + + locale_count = i18n.count("_lang:") + key_count = i18n.count("model_group_configured:") + assert key_count == locale_count, ( + "model_group_configured must be present in every locale block so " + "t('model_group_configured') never falls back to the raw key." + ) + + def test_get_available_models_cache_preserves_configured_model_badges(tmp_path, monkeypatch): cache_path = tmp_path / "models_cache.json" old_cfg = config.cfg diff --git a/tests/test_provider_management.py b/tests/test_provider_management.py index 05adb75c..002f6ade 100644 --- a/tests/test_provider_management.py +++ b/tests/test_provider_management.py @@ -372,3 +372,92 @@ class TestProvidersEndpoints: """POST /api/providers/delete without provider should return 400.""" body, status = _post("/api/providers/delete", {}) assert status == 400 + + +class TestIssue1410OllamaEnvVarBleed: + """Regression: Ollama Cloud key must not flip local Ollama to has_key=True. + + Both providers used to share OLLAMA_API_KEY in _PROVIDER_ENV_VAR. After + a user added a key for Ollama Cloud, the local Ollama card also lit up + "API key configured" — incorrect because the runtime in + hermes_cli/runtime_provider.py only consumes OLLAMA_API_KEY when the + base URL hostname is ollama.com. Local Ollama is keyless by default. + + Fix: drop bare "ollama" from _PROVIDER_ENV_VAR so the env-var check is + only applied to ollama-cloud. Local Ollama users who genuinely need a + key can still set providers.ollama.api_key in config.yaml. + """ + + def test_ollama_local_not_configured_when_only_cloud_env_var_set( + self, monkeypatch, tmp_path, + ): + """OLLAMA_API_KEY in env should mark ollama-cloud configured but not bare ollama.""" + _install_fake_hermes_cli(monkeypatch) + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.setenv("OLLAMA_API_KEY", "sk-cloud-key-xyz") + + old_cfg = dict(config.cfg) + old_mtime = config._cfg_mtime + config.cfg.clear() + config.cfg["model"] = {} + try: + config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime + except Exception: + config._cfg_mtime = 0.0 + + from api.providers import get_providers + try: + result = get_providers() + by_id = {p["id"]: p for p in result["providers"]} + assert "ollama-cloud" in by_id, "ollama-cloud should appear in provider list" + assert "ollama" in by_id, "ollama (local) should appear in provider list" + assert by_id["ollama-cloud"]["has_key"] is True, \ + "ollama-cloud should be has_key=True when OLLAMA_API_KEY is set" + assert by_id["ollama"]["has_key"] is False, ( + "ollama (local) must NOT be has_key=True when only the cloud env " + "var is set — local Ollama is keyless and shares no env var with " + "Ollama Cloud (#1410)." + ) + # ollama-cloud should be configurable, but local ollama should not + # (it has no env var mapping — keys go through providers.ollama.api_key + # in config.yaml if the user explicitly opts in). + assert by_id["ollama-cloud"]["configurable"] is True + assert by_id["ollama"]["configurable"] is False + finally: + config.cfg.clear() + config.cfg.update(old_cfg) + config._cfg_mtime = old_mtime + + def test_ollama_local_still_configured_via_config_yaml( + self, monkeypatch, tmp_path, + ): + """providers.ollama.api_key in config.yaml should still mark local ollama configured.""" + _install_fake_hermes_cli(monkeypatch) + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + # Important: clear the env var so the only signal is config.yaml. + monkeypatch.delenv("OLLAMA_API_KEY", raising=False) + + old_cfg = dict(config.cfg) + old_mtime = config._cfg_mtime + config.cfg.clear() + config.cfg["model"] = {} + config.cfg["providers"] = {"ollama": {"api_key": "local-token-abc"}} + try: + config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime + except Exception: + config._cfg_mtime = 0.0 + + from api.providers import get_providers + try: + result = get_providers() + by_id = {p["id"]: p for p in result["providers"]} + assert by_id["ollama"]["has_key"] is True, ( + "Local Ollama users with providers.ollama.api_key in config.yaml " + "should still report configured (#1410 fix must not regress this)." + ) + # And ollama-cloud should NOT be configured by ollama's config entry. + assert by_id["ollama-cloud"]["has_key"] is False + finally: + config.cfg.clear() + config.cfg.update(old_cfg) + config._cfg_mtime = old_mtime diff --git a/tests/test_provider_mismatch.py b/tests/test_provider_mismatch.py index 1efe347c..66ce09b8 100644 --- a/tests/test_provider_mismatch.py +++ b/tests/test_provider_mismatch.py @@ -294,6 +294,274 @@ def test_api_models_includes_active_provider(): ) +def test_codex_provider_qualified_model_routes_to_codex_not_openrouter(): + """@openai-codex:gpt-5.5 must route through OpenAI Codex, not OpenRouter.""" + import api.config as config + + old_cfg = dict(config.cfg) + config.cfg["model"] = { + "provider": "openrouter", + "base_url": "https://openrouter.ai/api/v1", + } + try: + model, provider, base_url = config.resolve_model_provider( + "@openai-codex:gpt-5.5" + ) + finally: + config.cfg.clear() + config.cfg.update(old_cfg) + + assert model == "gpt-5.5" + assert provider == "openai-codex" + assert provider != "openrouter" + assert base_url is None + + +def test_default_model_save_persists_codex_provider_for_qualified_model(tmp_path, monkeypatch): + """Saving @openai-codex:gpt-5.5 must persist model.provider=openai-codex.""" + import yaml + import api.config as config + + config_file = tmp_path / "config.yaml" + config_file.write_text( + "model:\n" + " provider: openrouter\n" + " default: openai/gpt-5.4\n" + " base_url: https://openrouter.ai/api/v1\n", + encoding="utf-8", + ) + old_cfg = dict(config.cfg) + old_mtime = config._cfg_mtime + monkeypatch.setattr(config, "_get_config_path", lambda: config_file) + config.cfg["model"] = { + "provider": "openrouter", + "default": "openai/gpt-5.4", + "base_url": "https://openrouter.ai/api/v1", + } + config._cfg_mtime = config_file.stat().st_mtime + try: + result = config.set_hermes_default_model("@openai-codex:gpt-5.5") + saved = yaml.safe_load(config_file.read_text(encoding="utf-8")) + finally: + config.cfg.clear() + config.cfg.update(old_cfg) + config._cfg_mtime = old_mtime + config.invalidate_models_cache() + + assert result["ok"] is True + assert result["model"] == "gpt-5.5" + assert saved["model"]["default"] == "gpt-5.5" + assert saved["model"]["provider"] == "openai-codex" + assert saved["model"].get("base_url") != "https://openrouter.ai/api/v1" + + +def test_active_codex_at_provider_session_model_preserved(monkeypatch): + """@openai-codex:gpt-5.5 session selections must keep their provider hint.""" + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_available_models", + lambda: { + "active_provider": "openai-codex", + "default_model": "gpt-5.5", + "groups": [ + { + "provider": "OpenAI Codex", + "provider_id": "openai-codex", + "models": [{"id": "gpt-5.5", "label": "GPT-5.5"}], + }, + { + "provider": "OpenRouter", + "provider_id": "openrouter", + "models": [{"id": "openai/gpt-5.5", "label": "GPT-5.5"}], + }, + ], + }, + ) + + effective, changed = routes._resolve_compatible_session_model( + "@openai-codex:gpt-5.5" + ) + + assert changed is False + assert effective == "@openai-codex:gpt-5.5" + + +def test_bare_codex_gpt_session_model_gets_separate_provider_context(monkeypatch): + """A bare GPT model under active Codex stays bare and carries model_provider.""" + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_available_models", + lambda: { + "active_provider": "openai-codex", + "default_model": "gpt-5.5", + "groups": [ + { + "provider": "OpenAI Codex", + "provider_id": "openai-codex", + "models": [{"id": "gpt-5.5", "label": "GPT-5.5"}], + }, + { + "provider": "OpenRouter", + "provider_id": "openrouter", + "models": [{"id": "openai/gpt-5.5", "label": "GPT-5.5"}], + }, + ], + }, + ) + + effective, provider, changed = routes._resolve_compatible_session_model_state("gpt-5.5") + + assert changed is False + assert effective == "gpt-5.5" + assert provider == "openai-codex" + + +def test_session_model_normalizer_keeps_bare_codex_model_and_saves_provider(monkeypatch): + """Write-path normalization must persist model_provider without adding @.""" + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_available_models", + lambda: { + "active_provider": "openai-codex", + "default_model": "gpt-5.5", + "groups": [ + { + "provider": "OpenAI Codex", + "provider_id": "openai-codex", + "models": [{"id": "gpt-5.5", "label": "GPT-5.5"}], + }, + ], + }, + ) + + save_calls = [] + + class DummySession: + def __init__(self): + self.model = "gpt-5.5" + self.model_provider = None + + def save(self, touch_updated_at=True): + save_calls.append(touch_updated_at) + + session = DummySession() + effective = routes._normalize_session_model_in_place(session) + + assert effective == "gpt-5.5" + assert session.model == "gpt-5.5" + assert session.model_provider == "openai-codex" + assert save_calls == [False] + + +def test_bare_codex_gpt_runtime_bridge_routes_to_codex(monkeypatch): + """Bare model + model_provider=openai-codex must route Codex at runtime.""" + import api.config as config + + old_cfg = dict(config.cfg) + config.cfg["model"] = { + "provider": "openrouter", + "default": "openai/gpt-5.4", + "base_url": "https://openrouter.ai/api/v1", + } + try: + runtime_model = config.model_with_provider_context( + "gpt-5.5", + "openai-codex", + ) + model, provider, base_url = config.resolve_model_provider(runtime_model) + finally: + config.cfg.clear() + config.cfg.update(old_cfg) + + assert runtime_model == "@openai-codex:gpt-5.5" + assert model == "gpt-5.5" + assert provider == "openai-codex" + assert base_url is None + + +def test_non_openrouter_slash_model_provider_context_stays_unqualified(): + """Portal/custom slash IDs must not be blindly wrapped as @provider:model.""" + import api.config as config + + runtime_model = config.model_with_provider_context( + "anthropic/claude-sonnet-4.6", + "nous", + ) + + assert runtime_model == "anthropic/claude-sonnet-4.6" + + +def test_api_session_new_persists_model_provider_context(): + """POST /api/session/new returns compact session model_provider metadata.""" + created, status = _post( + "/api/session/new", + {"model": "gpt-5.5", "model_provider": "openai-codex"}, + ) + + assert status == 200 + assert created["session"]["model"] == "gpt-5.5" + assert created["session"]["model_provider"] == "openai-codex" + + +def test_explicit_openrouter_selection_supported_with_codex_base_url(): + """OpenRouter slash and @openrouter selections must remain routable.""" + import api.config as config + + old_cfg = dict(config.cfg) + config.cfg["model"] = { + "provider": "openai-codex", + "default": "gpt-5.5", + "base_url": "https://chatgpt.com/backend-api/codex", + } + try: + slash_model, slash_provider, slash_base_url = config.resolve_model_provider( + "openai/gpt-5.5" + ) + at_model, at_provider, at_base_url = config.resolve_model_provider( + "@openrouter:openai/gpt-5.5" + ) + finally: + config.cfg.clear() + config.cfg.update(old_cfg) + + assert slash_model == "openai/gpt-5.5" + assert slash_provider == "openrouter" + assert slash_base_url is None + assert at_model == "openai/gpt-5.5" + assert at_provider == "openrouter" + assert at_base_url is None + + +def test_real_provider_custom_base_url_slash_model_stays_on_configured_endpoint(): + """A real-provider proxy base_url must not be silently rerouted to OpenRouter.""" + import api.config as config + + old_cfg = dict(config.cfg) + config.cfg["model"] = { + "provider": "openai", + "default": "google/gemma-4-26b-a4b", + "base_url": "http://proxy.local/v1", + } + try: + model, provider, base_url = config.resolve_model_provider( + "google/gemma-4-26b-a4b" + ) + finally: + config.cfg.clear() + config.cfg.update(old_cfg) + + assert model == "gemma-4-26b-a4b" + assert provider == "openai" + assert provider != "openrouter" + assert base_url == "http://proxy.local/v1" + + def test_bare_gemini_session_model_normalizes_to_active_provider_default(monkeypatch): """Persisted bare Gemini IDs must not survive a provider switch.""" import api.routes as routes @@ -689,6 +957,30 @@ class TestChatStartEffectiveModelRecovery: assert "localStorage.setItem('hermes-webui-model', startData.effective_model)" in src, ( "effective_model correction must update the saved model preference" ) + assert "startData.effective_model_provider" in src, ( + "send() must preserve provider context returned by /api/chat/start" + ) + + +class TestFrontendModelProviderState: + """Frontend model persistence should store provider separately.""" + + def test_boot_session_update_sends_model_provider(self): + src = _read("static/boot.js") + assert "_modelStateForSelect" in src + assert "model_provider:modelState.model_provider||null" in src + + def test_new_session_sends_model_provider(self): + src = _read("static/sessions.js") + assert "_modelStateForSelect(modelSel,selectedDefaultModel)" in src + assert "model_provider:newModelState.model_provider||null" in src + + def test_ui_has_json_model_state_storage(self): + src = _read("static/ui.js") + assert "hermes-webui-model-state" in src + assert "function _writePersistedModelState" in src + assert "_providerQualifiedModelValueForSelect(sel, modelId)" in src + assert "return _modelStateForSelect(sel,modelId).model" in src def test_unknown_prefix_model_passes_through_unchanged(monkeypatch): diff --git a/tests/test_v050255_opus_followups.py b/tests/test_v050255_opus_followups.py new file mode 100644 index 00000000..a67986ee --- /dev/null +++ b/tests/test_v050255_opus_followups.py @@ -0,0 +1,220 @@ +"""Regression tests for v0.50.255 Opus pre-release follow-ups. + +The v0.50.255 batch (#1390 + #1405) had four Opus advisor findings: + +1. MUST-FIX — `api/rollback.py::checkpoint` parameter wasn't validated; the + path-join `_checkpoint_root() / ws_hash / checkpoint` does NOT normalize + `..`, so an authenticated caller could pass `..//` and + read or restore from another allowlisted workspace's checkpoint store. + Fix: regex validation that rejects `/`, `..`, and `.`. + +2. SHOULD-FIX — `api/helpers.py::_redact_text` called uncached `load_settings()` + per string, recursed across all messages and tool_calls. For a 50-message + session that's hundreds of disk reads per `/api/session?session_id=X`. Fix: + thread `_enabled` once through `redact_session_data()`. + +3. SHOULD-FIX — `static/boot.js` voice mode: the patched `autoReadLastAssistant` + fires globally; if the user navigates to a different session between send + and stream completion, TTS would speak the wrong session's last assistant + message. Fix: capture the active session id in `_voiceModeSend` and bail + out in `_speakResponse` if it doesn't match. + +4. NIT — `api/rollback.py::_inspect_checkpoint` had a bare `Exception` in the + except tuple alongside specific catches, swallowing everything (incl. + KeyboardInterrupt's siblings). Fix: drop to the specific tuple. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] + + +# ── 1: rollback checkpoint id validation ───────────────────────────────────── + + +def test_rollback_validates_checkpoint_id_against_path_traversal(): + """The checkpoint param must reject `..`, `/`, and any path-component + traversal vector. Without this guard, a caller can join the checkpoint + root with `..//` and escape the workspace allowlist + (Path() / '..' does NOT normalize).""" + src = (REPO / "api" / "rollback.py").read_text(encoding="utf-8") + # Validator function exists. + assert "def _validate_checkpoint_id(" in src, ( + "_validate_checkpoint_id must exist as a defense-in-depth guard for " + "the checkpoint parameter; without it, ..// escapes the " + "workspace allowlist." + ) + # Both diff + restore call it. + assert src.count("_validate_checkpoint_id(checkpoint)") >= 2, ( + "both get_checkpoint_diff and restore_checkpoint must call " + "_validate_checkpoint_id() on their checkpoint parameter." + ) + # Validator rejects '..' and '.' explicitly. + assert 'in (".", "..")' in src, ( + "_validate_checkpoint_id must reject literal '.' and '..' explicitly " + "(not just rely on the regex)." + ) + + +def test_rollback_validate_checkpoint_id_runtime_behavior(): + """End-to-end test of the validator: traversal attempts raise ValueError.""" + import sys + sys.path.insert(0, str(REPO)) + from api.rollback import _validate_checkpoint_id + + # Valid SHA-style IDs pass. + assert _validate_checkpoint_id("abc123def456") == "abc123def456" + assert _validate_checkpoint_id("a1b2c3-d4e5") == "a1b2c3-d4e5" + assert _validate_checkpoint_id("checkpoint_2026-05-01") == "checkpoint_2026-05-01" + + # Traversal attempts blocked. + for bad in ( + "../escape", + "..", + ".", + "../../../etc/passwd", + "abc/def", + "abc def", # space + "abc\x00def", # null byte + "", + " ", + ".hidden", # leading dot → looks like dotfile escape + "/abs/path", + "x" * 65, # too long + ): + with pytest.raises(ValueError): + _validate_checkpoint_id(bad) + + +# ── 2: redact_session_data settings.json read-once optimization ────────────── + + +def test_redact_session_data_reads_settings_once(): + """`redact_session_data()` must read `api_redact_enabled` ONCE per call + and thread it through the recursive walk via the `_enabled` keyword. + Calling load_settings per string was a hot-path perf regression.""" + src = (REPO / "api" / "helpers.py").read_text(encoding="utf-8") + + # The function reads settings once and threads _enabled through. + redact_fn_idx = src.find("def redact_session_data(") + assert redact_fn_idx != -1, "redact_session_data missing" + body = src[redact_fn_idx : redact_fn_idx + 1500] + assert "load_settings()" in body, ( + "redact_session_data must read load_settings() once at the top" + ) + assert body.count("_enabled=_enabled") >= 3, ( + "redact_session_data must thread _enabled through to title, " + "messages, and tool_calls (3 call sites)" + ) + + # _redact_text and _redact_value accept _enabled kwarg. + assert "def _redact_text(text: str, *, _enabled" in src + assert "def _redact_value(v, *, _enabled" in src + + +def test_redact_session_data_threads_enabled_once_across_recursion(): + """End-to-end: a session payload with N strings should result in 1 read + of api_redact_enabled, not N. We verify by counting load_settings calls + via monkeypatch.""" + import sys + sys.path.insert(0, str(REPO)) + from api import helpers + + call_count = [0] + real_load_settings = helpers.__dict__.get("load_settings") + + def counting_load_settings(): + call_count[0] += 1 + return {"api_redact_enabled": True} + + # The from-import inside redact_session_data resolves at call time, so + # patch in api.config where it lives. + from api import config + original = config.load_settings + config.load_settings = counting_load_settings + try: + # Simulate a session payload with many strings + session = { + "title": "Test session", + "messages": [ + {"role": "user", "content": "hello world " * 10} + for _ in range(20) + ], + "tool_calls": [ + {"name": "tool", "args": {"x": "y", "z": ["a", "b", "c"]}} + for _ in range(10) + ], + } + helpers.redact_session_data(session) + finally: + config.load_settings = original + + # Should be called exactly once for the entire response, not per string. + assert call_count[0] == 1, ( + f"redact_session_data called load_settings() {call_count[0]} times; " + f"expected exactly 1 (read-once + thread-through optimization)." + ) + + +# ── 3: voice mode session-id capture ───────────────────────────────────────── + + +def test_voice_mode_speakresponse_guards_against_session_switch(): + """The `_speakResponse` callback fires from a global override of + `autoReadLastAssistant`. If the user navigates to a different session + between sending and stream completion, the callback would TTS-read the + new session's last assistant message instead of the one they sent to. + Fix: capture session_id at thinking-time, bail in _speakResponse if it + doesn't match the current S.session.session_id.""" + src = (REPO / "static" / "boot.js").read_text(encoding="utf-8") + + # Session-id capture state exists. + assert "let _voiceModeThinkingSid=" in src, ( + "voice mode must declare _voiceModeThinkingSid to pin the active " + "session id at send-time" + ) + + # _voiceModeSend captures current session_id at thinking transition. + send_idx = src.find("function _voiceModeSend(") + assert send_idx != -1 + send_body = src[send_idx : send_idx + 1200] + assert "_voiceModeThinkingSid=" in send_body, ( + "_voiceModeSend must capture the current session_id at thinking-time" + ) + assert "S.session.session_id" in send_body, ( + "_voiceModeSend must read S.session.session_id" + ) + + # _speakResponse compares current sid to captured sid and bails on mismatch. + speak_idx = src.find("function _speakResponse(") + assert speak_idx != -1 + speak_body = src[speak_idx : speak_idx + 1500] + assert "_voiceModeThinkingSid" in speak_body, ( + "_speakResponse must consult _voiceModeThinkingSid" + ) + assert "_startListening()" in speak_body, ( + "_speakResponse mismatch path must drop back to listening, not silently exit" + ) + + +# ── 4: rollback _inspect_checkpoint except tuple ───────────────────────────── + + +def test_rollback_inspect_checkpoint_except_no_bare_exception(): + """The bare `Exception` in `(subprocess.TimeoutExpired, OSError, Exception)` + swallowed everything including KeyboardInterrupt's siblings and made the + specific catches redundant. Should be the specific tuple only.""" + src = (REPO / "api" / "rollback.py").read_text(encoding="utf-8") + # No bare Exception in the inspect-checkpoint except tuple. + assert "(subprocess.TimeoutExpired, OSError, Exception)" not in src, ( + "_inspect_checkpoint must not catch bare Exception alongside specific " + "catches — the bare Exception swallows everything and makes the " + "specific ones redundant." + ) + # The specific tuple is in place. + assert "(subprocess.TimeoutExpired, OSError)" in src