Merge pull request #1412 from nesquena/stage-255

v0.50.255 — batch release: 2 PRs (#1390 + #1405) + 4 Opus follow-ups
This commit is contained in:
nesquena-hermes
2026-05-01 10:40:10 -07:00
committed by GitHub
23 changed files with 2730 additions and 104 deletions
+30
View File
@@ -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/<sha256-of-canonical-workspace>/<commit_hash>/.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 `../<other-ws-hash>/<sha>` 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
+52
View File
@@ -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})?$")
+39 -9
View File
@@ -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
+6 -2
View File
@@ -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:
+8 -1
View File
@@ -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",
}
+320
View File
@@ -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/<hash>/`` 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 `../<other-ws-hash>/<sha>`
# 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 <ckpt_dir>/<commit_hash>/
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,
}
+387 -39
View File
@@ -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)}
<script src="/static/login.js"></script>
</body></html>"""
# ── 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:
+21 -2
View File
@@ -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.
+313 -10
View File
@@ -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<event.results.length;i++){
const txt=event.results[i][0].transcript;
if(event.results[i].isFinal){ final+=txt; _finalText=final; }
else{ interim+=txt; }
}
ta.value=final||interim;
autoResize();
// Auto-send on silence after final result
if(_finalText){
_silenceTimer=setTimeout(()=>{
_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();
+339
View File
@@ -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
},
};
+48
View File
@@ -88,6 +88,7 @@
<button class="rail-btn nav-tab" data-panel="workspaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces" aria-label="Spaces"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="rail-btn nav-tab" data-panel="profiles" onclick="switchPanel('profiles')" title="Agent profiles" data-i18n-title="tab_profiles" aria-label="Agent profiles"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button class="rail-btn nav-tab" data-panel="todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos" aria-label="Todos"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
<button class="rail-btn nav-tab" data-panel="insights" onclick="switchPanel('insights')" title="Insights" data-i18n-title="tab_insights" aria-label="Insights"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></button>
<div class="rail-spacer"></div>
<button class="rail-btn nav-tab" data-panel="settings" onclick="switchPanel('settings')" title="Settings" data-i18n-title="tab_settings" aria-label="Settings"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
</nav>
@@ -101,6 +102,7 @@
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="nav-tab" data-panel="profiles" data-label="Profiles" onclick="switchPanel('profiles')" title="Agent profiles" data-i18n-title="tab_profiles"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
<button class="nav-tab" data-panel="insights" data-label="Insights" onclick="switchPanel('insights')" title="Insights" data-i18n-title="tab_insights"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></button>
<!-- Settings button mirrored here for mobile (rail is desktop-only via @media >=768px). Keep in sync with rail entry. -->
<button class="nav-tab" data-panel="settings" onclick="switchPanel('settings')" title="Settings" data-i18n-title="tab_settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
</div>
@@ -153,6 +155,23 @@
</div>
<div id="todoPanel" style="flex:1;overflow-y:auto;padding:8px 12px"></div>
</div>
<!-- Insights panel -->
<div class="panel-view" id="panelInsights">
<div class="panel-head">
<span data-i18n="tab_insights">Insights</span>
<div class="panel-head-actions">
<button class="panel-head-btn" id="insightsRefreshBtn" onclick="loadInsights(true)" title="Refresh" aria-label="Refresh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></button>
</div>
</div>
<div class="panel-head-sub" style="padding:0 12px 8px">
<select id="insightsPeriod" onchange="loadInsights()" style="width:100%;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px">
<option value="7">7 days</option>
<option value="30" selected>30 days</option>
<option value="90">90 days</option>
<option value="365">365 days</option>
</select>
</div>
</div>
<!-- Workspaces panel -->
<div class="panel-view" id="panelWorkspaces">
<div class="panel-head">
@@ -359,6 +378,10 @@
</div>
<div class="attach-tray" id="attachTray"></div>
<div class="mic-status" id="micStatus" style="display:none"><span class="mic-dot"></span> Listening…</div>
<div class="voice-mode-bar" id="voiceModeBar" style="display:none">
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>
<span class="voice-mode-label" id="voiceModeLabel"></span>
</div>
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
<div class="composer-footer">
<div class="composer-left">
@@ -374,6 +397,16 @@
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</button>
<button class="icon-btn voice-mode-btn" id="btnVoiceMode" title="Turn-based voice mode" style="display:none" data-i18n-title="voice_toggle">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
<path d="M20 3l-1.5 1.5" opacity=".5"/>
<path d="M4 3l1.5 1.5" opacity=".5"/>
</svg>
</button>
<div class="composer-divider" aria-hidden="true"></div>
<button class="yolo-pill" id="yoloPill" type="button" onclick="cmdYolo()" style="display:none" title="YOLO mode — click to disable" data-i18n-title="yolo_pill_title_active">
<span class="yolo-pill-icon" aria-hidden="true"></span>
@@ -594,6 +627,14 @@
<div class="main-view-empty-sub" data-i18n="profiles_empty_sub">Pick an agent profile from the sidebar to view and edit its settings, or create a new one.</div>
</div>
</div>
<div id="mainInsights" class="main-view">
<div class="main-view-header">
<div class="main-view-title" data-i18n="insights_title">Usage Analytics</div>
</div>
<div class="main-view-content" id="insightsContent" style="padding:16px;overflow-y:auto">
<div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div>
</div>
</div>
<div id="mainSettings" class="main-view">
<div class="settings-main">
<div class="settings-pane active" id="settingsPaneConversation">
@@ -767,6 +808,13 @@
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Group thinking and tool calls into one collapsed activity section per assistant turn.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsApiRedact" checked style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_api_redact">Redact sensitive data in API responses</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_api_redact">Self-hosted users can disable for transparency (not recommended for shared instances).</div>
</div>
<div class="settings-field">
<label for="settingsSidebarDensity" data-i18n="settings_label_sidebar_density">Sidebar density</label>
<select id="settingsSidebarDensity" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
+14 -4
View File
@@ -82,7 +82,7 @@ async function send(){
S.pendingFiles=[];renderTray();
} else if(busyMode==='interrupt'){
// Queue the message, then cancel so drain re-sends it.
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',model_provider:S.session&&S.session.model_provider||null,profile:S.activeProfile||'default'});
updateQueueBadge(S.session.session_id);
$('msg').value='';autoResize();
S.pendingFiles=[];renderTray();
@@ -95,7 +95,7 @@ async function send(){
} else {
// Default: queue mode (current behavior). Also the fallback for
// 'steer' mode when no stream is active or _trySteer is unavailable.
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',model_provider:S.session&&S.session.model_provider||null,profile:S.activeProfile||'default'});
$('msg').value='';autoResize();
S.pendingFiles=[];renderTray();
updateQueueBadge(S.session.session_id);
@@ -202,12 +202,21 @@ async function send(){
const startData=await api('/api/chat/start',{method:'POST',body:JSON.stringify({
session_id:activeSid,message:msgText,
model:S.session.model||$('modelSelect').value,workspace:S.session.workspace,
model_provider:S.session.model_provider||null,
attachments:uploaded.length?uploaded:undefined
})});
if(startData.effective_model && S.session){
S.session.model=startData.effective_model;
S.session.model_provider=startData.effective_model_provider||S.session.model_provider||null;
localStorage.setItem('hermes-webui-model', startData.effective_model);
if($('modelSelect')) _applyModelToDropdown(startData.effective_model, $('modelSelect'));
if(typeof _writePersistedModelState==='function') _writePersistedModelState(startData.effective_model,S.session.model_provider||null);
if($('modelSelect')) _applyModelToDropdown(startData.effective_model, $('modelSelect'),S.session.model_provider||null);
if(typeof syncTopbar==='function') syncTopbar();
}else if(startData.effective_model_provider && S.session){
S.session.model_provider=startData.effective_model_provider;
if(typeof _writePersistedModelState==='function') _writePersistedModelState(S.session.model||'',S.session.model_provider||null);
if($('modelSelect')&&typeof _applyModelToDropdown==='function') _applyModelToDropdown(S.session.model||'', $('modelSelect'), S.session.model_provider||null);
if(typeof syncModelChip==='function') syncModelChip();
if(typeof syncTopbar==='function') syncTopbar();
}
streamId=startData.stream_id;
@@ -233,7 +242,7 @@ async function send(){
stopApprovalPolling();
stopClarifyPolling();
// Keep the user's attempted turn by queueing it for after the current run.
queueSessionMessage(activeSid,{text:msgText,files:[],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
queueSessionMessage(activeSid,{text:msgText,files:[],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',model_provider:S.session&&S.session.model_provider||null,profile:S.activeProfile||'default'});
updateQueueBadge(activeSid);
showToast('Current session is still running. Reconnected and queued your message.',2600);
try{
@@ -898,6 +907,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
queueSessionMessage(sid,{
text:txt,files:[],
model:S.session&&S.session.model||'',
model_provider:S.session&&S.session.model_provider||null,
profile:S.activeProfile||'default',
});
if(typeof updateQueueBadge==='function') updateQueueBadge(sid);
+249 -11
View File
@@ -160,11 +160,11 @@ async function switchPanel(name, opts = {}) {
document.querySelectorAll('.panel-view').forEach(p => p.classList.remove('active'));
const panelEl = $('panel' + nextPanel.charAt(0).toUpperCase() + nextPanel.slice(1));
if (panelEl) panelEl.classList.add('active');
// Toggle main content view. Each entry in MAIN_VIEW_PANELS gets a matching
// Update main content view. Each entry in MAIN_VIEW_PANELS gets a matching
// showing-<name> class on <main>; no class means chat (the default).
const mainEl = document.querySelector('main.main');
if (mainEl) {
['settings','skills','memory','tasks','workspaces','profiles'].forEach(p => {
['settings','skills','memory','tasks','workspaces','profiles','insights'].forEach(p => {
mainEl.classList.toggle('showing-' + p, nextPanel === p);
});
}
@@ -175,6 +175,7 @@ async function switchPanel(name, opts = {}) {
if (nextPanel === 'workspaces') await loadWorkspacesPanel();
if (nextPanel === 'profiles') await loadProfilesPanel();
if (nextPanel === 'todos') loadTodos();
if (nextPanel === 'insights') await loadInsights();
if (nextPanel === 'settings') {
switchSettingsSection(_currentSettingsSection);
loadSettingsPanel();
@@ -848,6 +849,112 @@ function loadTodos() {
</div>`).join('');
}
// ── Insights panel ──
async function loadInsights(animate) {
const box = $('insightsContent');
const refreshBtn = $('insightsRefreshBtn');
if (!box) return;
if (animate && refreshBtn) {
refreshBtn.style.opacity = '0.5';
refreshBtn.disabled = true;
}
const period = ($('insightsPeriod') || {}).value || '30';
try {
const data = await api(`/api/insights?days=${period}`);
_renderInsights(data, box);
} catch(e) {
box.innerHTML = `<div style="color:var(--accent);font-size:12px">${esc(t('error_prefix') + e.message)}</div>`;
} finally {
if (animate && refreshBtn) {
refreshBtn.style.opacity = '';
refreshBtn.disabled = false;
}
}
}
function _renderInsights(d, box) {
const fmtNum = n => n.toLocaleString();
const fmtCost = c => c > 0 ? '$' + c.toFixed(4) : t('insights_no_cost');
const fmtTokens = n => n >= 1e6 ? (n/1e6).toFixed(1) + 'M' : n >= 1e3 ? (n/1e3).toFixed(1) + 'K' : fmtNum(n);
// Overview cards
const overviewCards = [
{ label: t('insights_sessions'), value: fmtNum(d.total_sessions), icon: li('message-square', 18) },
{ label: t('insights_messages'), value: fmtNum(d.total_messages), icon: li('hash', 18) },
{ label: t('insights_tokens'), value: fmtTokens(d.total_tokens), icon: li('cpu', 18) },
{ label: t('insights_cost'), value: fmtCost(d.total_cost), icon: li('dollar-sign', 18) },
];
// Models table
let modelsHtml = '';
if (d.models && d.models.length) {
const totalSess = d.models.reduce((a, m) => a + m.sessions, 0) || 1;
modelsHtml = `<div class="insights-card"><div class="insights-card-title">${esc(t('insights_models'))}</div><div class="insights-table"><div class="insights-table-head"><span>Model</span><span>Sessions</span><span>Share</span></div>` +
d.models.map(m => {
const pct = ((m.sessions / totalSess) * 100).toFixed(0);
return `<div class="insights-table-row"><span class="insights-model-name" title="${esc(m.model)}">${esc(m.model)}</span><span>${m.sessions}</span><span>${pct}%</span></div>`;
}).join('') +
`</div></div>`;
}
// Activity by day of week
let dowHtml = '';
if (d.activity_by_day) {
const maxDow = Math.max(...d.activity_by_day.map(x => x.sessions), 1);
dowHtml = `<div class="insights-card"><div class="insights-card-title">${esc(t('insights_activity_by_day'))}</div><div class="insights-bars">` +
d.activity_by_day.map(r => {
const pct = (r.sessions / maxDow * 100).toFixed(0);
return `<div class="insights-bar-row"><span class="insights-bar-label">${r.day}</span><div class="insights-bar-track"><div class="insights-bar-fill" style="width:${pct}%"></div></div><span class="insights-bar-value">${r.sessions}</span></div>`;
}).join('') +
`</div></div>`;
}
// Activity by hour
let hodHtml = '';
if (d.activity_by_hour) {
const maxHod = Math.max(...d.activity_by_hour.map(x => x.sessions), 1);
const peakHour = d.activity_by_hour.reduce((a, b) => b.sessions > a.sessions ? b : a, {hour:0,sessions:0});
hodHtml = `<div class="insights-card"><div class="insights-card-title">${esc(t('insights_activity_by_hour'))} <span style="font-weight:400;font-size:11px;color:var(--muted)">${esc(t('insights_peak_hour').replace('{hour}', peakHour.hour + ':00'))}</span></div><div class="insights-bars">` +
d.activity_by_hour.map(r => {
const pct = (r.sessions / maxHod * 100).toFixed(0);
const isPeak = r.hour === peakHour.hour && peakHour.sessions > 0;
return `<div class="insights-bar-row"><span class="insights-bar-label">${String(r.hour).padStart(2,'0')}</span><div class="insights-bar-track"><div class="insights-bar-fill${isPeak ? ' insights-bar-peak' : ''}" style="width:${pct}%"></div></div><span class="insights-bar-value">${r.sessions}</span></div>`;
}).join('') +
`</div></div>`;
}
// Token breakdown
const tokenCards = `
<div class="insights-card">
<div class="insights-card-title">${esc(t('insights_token_breakdown'))}</div>
<div class="insights-token-row">
<span class="insights-token-label">${esc(t('insights_input_tokens'))}</span>
<span class="insights-token-value">${fmtTokens(d.total_input_tokens)}</span>
</div>
<div class="insights-token-row">
<span class="insights-token-label">${esc(t('insights_output_tokens'))}</span>
<span class="insights-token-value">${fmtTokens(d.total_output_tokens)}</span>
</div>
<div class="insights-token-row insights-token-total">
<span class="insights-token-label">${esc(t('insights_total'))}</span>
<span class="insights-token-value">${fmtTokens(d.total_tokens)}</span>
</div>
</div>`;
box.innerHTML = `
<div class="insights-grid">
${overviewCards.map(c => `<div class="insights-stat"><div class="insights-stat-icon">${c.icon}</div><div class="insights-stat-info"><div class="insights-stat-value">${c.value}</div><div class="insights-stat-label">${esc(c.label)}</div></div></div>`).join('')}
</div>
<div class="insights-row">
${tokenCards}
${modelsHtml}
</div>
${dowHtml}
${hodHtml}
<div style="text-align:center;color:var(--muted);font-size:10px;margin-top:12px;opacity:.6">${esc(t('insights_footer').replace('{days}', d.period_days))}</div>
`;
}
async function clearConversation() {
if(!S.session) return;
const _clrMsg=await showConfirmDialog({title:t('clear_conversation_title'),message:t('clear_conversation_message'),confirmLabel:t('clear'),danger:true,focusCancel:true});
@@ -1698,11 +1805,18 @@ function _renderWorkspaceDetail(ws){
<div class="detail-row"><div class="detail-row-label">Path</div><div class="detail-row-value"><code>${esc(ws.path)}</code></div></div>
<div class="detail-row"><div class="detail-row-label">Status</div><div class="detail-row-value">${statusBadge}${defaultBadge}</div></div>
</div>
<div class="detail-card" style="margin-top:12px">
<div class="detail-card-title">${esc(t('checkpoint_title'))}</div>
<div id="checkpointListContainer">
<div style="color:var(--muted);font-size:12px;padding:8px 0">${esc(t('checkpoint_loading'))}</div>
</div>
</div>
</div>`;
body.style.display = '';
if (empty) empty.style.display = 'none';
_workspaceMode = 'read';
_setWorkspaceHeaderButtons('read', ws);
_loadCheckpoints(ws.path);
}
function _setWorkspaceHeaderButtons(mode, ws){
@@ -2002,7 +2116,7 @@ async function switchToWorkspace(path,name){
try{
closeWsDropdown();
await api('/api/session/update',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id, workspace:path, model:S.session.model
session_id:S.session.session_id, workspace:path, model:S.session.model, model_provider:S.session.model_provider||null
})});
S.session.workspace=path;
// Explicit workspace switch = user overriding any pending profile-switch default.
@@ -2252,10 +2366,15 @@ async function switchToProfile(name) {
const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) });
S.activeProfile = data.active || name;
// Update composer placeholder and title bar while the core profile-switch
// state is still close to the profile API response.
if (typeof applyBotName === 'function') applyBotName();
// ── Model + Workspace (parallelized) ───────────────────────────────────
// populateModelDropdown hits /api/models; loadWorkspaceList hits /api/workspaces.
// They are fully independent — run both simultaneously to cut switch time ~50%.
localStorage.removeItem('hermes-webui-model');
if(typeof _clearPersistedModelState==='function') _clearPersistedModelState();
else localStorage.removeItem('hermes-webui-model');
_skillsData = null;
_workspaceList = null;
await Promise.all([populateModelDropdown(), loadWorkspaceList()]);
@@ -2265,10 +2384,15 @@ async function switchToProfile(name) {
const sel = $('modelSelect');
const resolved = _applyModelToDropdown(data.default_model, sel, window._activeProvider||null);
const modelToUse = resolved || data.default_model;
const modelState = (typeof _modelStateForSelect==='function')
? _modelStateForSelect(sel, modelToUse)
: {model:modelToUse,model_provider:null};
S._pendingProfileModel = modelToUse;
S._pendingProfileModelProvider = modelState.model_provider||null;
// Only patch the in-memory session model if we're NOT about to replace the session
if (S.session && !sessionInProgress) {
S.session.model = modelToUse;
S.session.model_provider = modelState.model_provider||null;
}
}
@@ -2288,6 +2412,7 @@ async function switchToProfile(name) {
session_id: S.session.session_id,
workspace: data.default_workspace,
model: S.session.model,
model_provider: S.session.model_provider||null,
})});
S.session.workspace = data.default_workspace;
} catch (_) {}
@@ -2308,6 +2433,7 @@ async function switchToProfile(name) {
session_id: S.session.session_id,
workspace: S._profileDefaultWorkspace,
model: S.session.model,
model_provider: S.session.model_provider||null,
})});
S.session.workspace = S._profileDefaultWorkspace;
} catch (_) {}
@@ -2334,9 +2460,6 @@ async function switchToProfile(name) {
if (_currentPanel === 'profiles') await loadProfilesPanel();
if (_currentPanel === 'workspaces') await loadWorkspacesPanel();
// Update composer placeholder and title bar to reflect profile name
if (typeof applyBotName === 'function') applyBotName();
} catch (e) {
// Revert the optimistic name update on error
if (_chipLabel) _chipLabel.textContent = _prevProfileName;
@@ -2624,11 +2747,13 @@ function _markSettingsDirty(){
_settingsDirty = true;
}
// Apply TTS enabled state: show/hide TTS buttons on all assistant messages
// Apply TTS enabled state: toggles a body class so the CSS rule
// `body.tts-enabled .msg-tts-btn` shows/hides the speaker icon. We toggle the
// body class instead of writing inline `style.display` because the parent
// `.msg-action-btn` has no display rule, so clearing the inline style let the
// `.msg-tts-btn{display:none;}` cascade re-hide the button (#1409).
function _applyTtsEnabled(enabled){
document.querySelectorAll('.msg-tts-btn').forEach(btn=>{
btn.style.display=enabled?'':'none';
});
document.body.classList.toggle('tts-enabled', !!enabled);
}
function _appearancePayloadFromUi(){
@@ -2708,6 +2833,8 @@ function _preferencesPayloadFromUi(){
if(showUsageCb) payload.show_token_usage=showUsageCb.checked;
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
if(simplifiedToolCb) payload.simplified_tool_calling=simplifiedToolCb.checked;
const apiRedactCb=$('settingsApiRedact');
if(apiRedactCb) payload.api_redact_enabled=apiRedactCb.checked;
const showCliCb=$('settingsShowCliSessions');
if(showCliCb) payload.show_cli_sessions=showCliCb.checked;
const syncCb=$('settingsSyncInsights');
@@ -2900,6 +3027,8 @@ async function loadSettingsPanel(){
if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
if(simplifiedToolCb){simplifiedToolCb.checked=settings.simplified_tool_calling!==false;simplifiedToolCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const apiRedactCb=$('settingsApiRedact');
if(apiRedactCb){apiRedactCb.checked=settings.api_redact_enabled!==false;apiRedactCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const showCliCb=$('settingsShowCliSessions');
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const syncCb=$('settingsSyncInsights');
@@ -3344,6 +3473,7 @@ async function saveSettings(andClose){
body.language=language;
body.show_token_usage=showTokenUsage;
body.simplified_tool_calling=!!($('settingsSimplifiedToolCalling')||{}).checked;
body.api_redact_enabled=!!($('settingsApiRedact')||{}).checked;
body.show_cli_sessions=showCliSessions;
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
@@ -3631,3 +3761,111 @@ switchSettingsSection=function(name){
_origSwitchSettings(name);
if(name==='system') loadMcpServers();
};
// ── Checkpoints / Rollback ──────────────────────────────────────────────────
async function _loadCheckpoints(workspace){
const container=$('checkpointListContainer');
if(!container) return;
try{
const data=await api(`/api/rollback/list?workspace=${encodeURIComponent(workspace)}`);
const checkpoints=data.checkpoints||[];
if(!checkpoints.length){
container.innerHTML=`<div style="color:var(--muted);font-size:12px;padding:8px 0">${esc(t('checkpoint_empty'))}</div>`;
return;
}
let html='';
for(const ck of checkpoints){
const shortId=ck.id||ck.commit||'?';
const msg=ck.message||'checkpoint';
const date=ck.date_display||ck.date||'';
const files=ck.files||0;
html+=`
<div class="detail-row" style="align-items:center;padding:6px 0;border-bottom:1px solid var(--border,rgba(255,255,255,0.08))">
<div style="flex:1;min-width:0">
<div style="font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(msg)}">${esc(msg)}</div>
<div style="font-size:11px;color:var(--muted);margin-top:2px">
<code style="font-size:10px">${esc(shortId)}</code>
${date ? ` · ${esc(date)}` : ''}
${files ? ` · ${esc(t('checkpoint_files'))}: ${files}` : ''}
</div>
</div>
<div style="display:flex;gap:4px;flex-shrink:0;margin-left:8px">
<button class="panel-head-btn" title="${esc(t('checkpoint_view_diff'))}" onclick="event.stopPropagation();_viewCheckpointDiff('${esc(workspace)}','${esc(ck.id)}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
</button>
<button class="panel-head-btn" title="${esc(t('checkpoint_restore'))}" onclick="event.stopPropagation();_restoreCheckpoint('${esc(workspace)}','${esc(ck.id)}','${esc(msg.replace(/'/g,"\\'"))}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
</button>
</div>
</div>`;
}
container.innerHTML=html;
}catch(e){
container.innerHTML=`<div style="color:var(--error,#f87171);font-size:12px;padding:8px 0">${esc(t('checkpoint_error'))}: ${esc(e.message)}</div>`;
}
}
async function _viewCheckpointDiff(workspace,checkpoint){
const modal=document.getElementById('checkpointDiffModal');
if(!modal){
const m=document.createElement('div');
m.id='checkpointDiffModal';
m.style.cssText='position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6)';
m.innerHTML=`
<div style="background:var(--bg,${getComputedStyle(document.documentElement).getPropertyValue('--bg')||'#1a1a2e'});border:1px solid var(--border,rgba(255,255,255,0.12));border-radius:12px;width:90vw;max-width:800px;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 8px 32px rgba(0,0,0,0.4)">
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border,rgba(255,255,255,0.08))">
<div id="checkpointDiffModalTitle" style="font-weight:600;font-size:14px"></div>
<button onclick="document.getElementById('checkpointDiffModal').style.display='none'" style="background:none;border:none;color:var(--fg);cursor:pointer;font-size:18px;padding:0 4px">&times;</button>
</div>
<div id="checkpointDiffModalBody" style="flex:1;overflow:auto;padding:12px 16px">
<div style="color:var(--muted);font-size:12px">${esc(t('checkpoint_loading'))}</div>
</div>
</div>`;
m.onclick=(e)=>{if(e.target===m) m.style.display='none';};
document.body.appendChild(m);
}
modal.style.display='flex';
$('checkpointDiffModalTitle').textContent=t('checkpoint_diff_title');
$('checkpointDiffModalBody').innerHTML=`<div style="color:var(--muted);font-size:12px">${esc(t('checkpoint_loading'))}</div>`;
try{
const data=await api(`/api/rollback/diff?workspace=${encodeURIComponent(workspace)}&checkpoint=${encodeURIComponent(checkpoint)}`);
const body=$('checkpointDiffModalBody');
if(!data.total_changes){
body.innerHTML=`<div style="color:var(--muted);font-size:12px">${esc(t('checkpoint_diff_no_changes'))}</div>`;
return;
}
let html=`<div style="font-size:12px;margin-bottom:8px">${esc(t('checkpoint_diff_files_changed',data.total_changes))}</div>`;
if(data.files_changed){
html+='<div style="margin-bottom:8px">';
for(const f of data.files_changed){
const icon=f.status==='deleted'?'':'~';
const color=f.status==='deleted'?'var(--error,#f87171)':'var(--accent,#60a5fa)';
html+=`<div style="font-size:12px;padding:2px 0"><span style="color:${color};font-weight:bold;margin-right:6px">${icon}</span><code style="font-size:11px">${esc(f.file)}</code></div>`;
}
html+='</div>';
}
if(data.diff){
html+=`<pre style="background:var(--bg-secondary,rgba(0,0,0,0.3));border:1px solid var(--border,rgba(255,255,255,0.08));border-radius:8px;padding:12px;font-size:11px;line-height:1.4;overflow-x:auto;white-space:pre-wrap;word-break:break-all;max-height:50vh;overflow-y:auto;color:var(--fg)">${esc(data.diff)}</pre>`;
}
body.innerHTML=html;
}catch(e){
$('checkpointDiffModalBody').innerHTML=`<div style="color:var(--error,#f87171);font-size:12px">${esc(e.message)}</div>`;
}
}
async function _restoreCheckpoint(workspace,checkpoint,message){
const label=message||checkpoint;
const ok=await showConfirmDialog({title:t('checkpoint_restore_confirm_title'),message:t('checkpoint_restore_confirm_message',label),confirmLabel:t('checkpoint_restore'),danger:true,focusCancel:true});
if(!ok) return;
try{
const data=await api('/api/rollback/restore',{method:'POST',body:JSON.stringify({workspace,checkpoint})});
if(data&&data.ok){
showToast(t('checkpoint_restored')+(data.files_restored_count?` (${data.files_restored_count} ${t('checkpoint_files').toLowerCase()})`:''));
}else{
showToast((data&&data.error)||'Restore failed','error');
}
}catch(e){
showToast(t('checkpoint_restore')+': '+e.message,'error');
}
}
+87 -8
View File
@@ -276,8 +276,22 @@ async function newSession(flash){
// default_model (from Settings) takes priority over the chat-header dropdown
// value, which reflects the *previous* session's model. Fall back to the
// dropdown value only when no default_model is configured.
const newModel=window._defaultModel||$('modelSelect').value;
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:newModel,workspace:inheritWs,profile:S.activeProfile||'default'})});
const modelSel=$('modelSelect');
const selectedDefaultModel=window._defaultModel||(modelSel&&modelSel.value)||'';
let defaultApplied=false;
if(window._defaultModel&&modelSel&&typeof _applyModelToDropdown==='function'){
defaultApplied=!!_applyModelToDropdown(window._defaultModel,modelSel,window._activeProvider||null);
}
const canQualify=!window._defaultModel||defaultApplied||(modelSel&&modelSel.value===selectedDefaultModel);
const newModelState=(canQualify&&typeof _modelStateForSelect==='function')
? _modelStateForSelect(modelSel,selectedDefaultModel)
: {model:selectedDefaultModel,model_provider:null};
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({
model:newModelState.model,
model_provider:newModelState.model_provider||null,
workspace:inheritWs,
profile:S.activeProfile||'default',
})});
S.session=data.session;S.messages=data.session.messages||[];
S.lastUsage={...(data.session.last_usage||{})};
if(flash)S.session._flash=true;
@@ -287,7 +301,7 @@ async function newSession(flash){
// Sync chat-header dropdown to the session's model so the UI reflects
// the default model the server actually used (#872).
if(S.session.model && S.session.model!==$('modelSelect').value && typeof _applyModelToDropdown==='function'){
_applyModelToDropdown(S.session.model,$('modelSelect'));
_applyModelToDropdown(S.session.model,$('modelSelect'),S.session.model_provider||null);
if(typeof syncModelChip==='function') syncModelChip();
}
// Reset per-session visual state: a fresh chat is idle even if another
@@ -515,8 +529,10 @@ function _resolveSessionModelForDisplaySoon(sid){
try{
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=1`);
const model=data&&data.session&&data.session.model;
const provider=data&&data.session&&data.session.model_provider;
if(!model||!S.session||S.session.session_id!==sid) return;
S.session.model=model;
S.session.model_provider=provider||null;
S.session._modelResolutionDeferred=false;
syncTopbar();
}catch(_){
@@ -897,7 +913,7 @@ function _openSessionActionMenu(session, anchorEl){
async()=>{
closeSessionActionMenu();
try{
const res=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:session.workspace,model:session.model})});
const res=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:session.workspace,model:session.model,model_provider:session.model_provider||null})});
if(res.session){
await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:res.session.session_id,title:(session.title||'Untitled')+' (copy)'})});
await loadSession(res.session.session_id);
@@ -1248,8 +1264,13 @@ function _sessionTimeBucketLabel(timestampMs, nowMs) {
return t('session_time_bucket_older');
}
function _sessionLineageKey(s){
function _sessionLineageKey(s, sessionIdsInList){
if(!s||!s.session_id) return null;
// If parent_session_id points to another session in the current list,
// this is a subagent child — don't collapse it into lineage (#494).
if(s.parent_session_id && sessionIdsInList && sessionIdsInList.has(s.parent_session_id)){
return null;
}
return s._lineage_root_id || s.lineage_root_id || s.parent_session_id || null;
}
@@ -1262,9 +1283,10 @@ function _sessionLineageContainsSession(s, sid){
function _collapseSessionLineageForSidebar(sessions){
const result=[];
const sessionIdsInList=new Set((sessions||[]).map(s=>s.session_id));
const groups=new Map();
for(const s of sessions||[]){
const key=_sessionLineageKey(s);
const key=_sessionLineageKey(s, sessionIdsInList);
if(!key){result.push(s);continue;}
if(!groups.has(key)) groups.set(key,[]);
groups.get(key).push(s);
@@ -1308,6 +1330,23 @@ function renderSessionListFromCache(){
// Filter archived unless toggle is on
const sessionsRaw=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
const sessions=_collapseSessionLineageForSidebar(sessionsRaw);
// Build parent→children map for subagent tree (#494).
// Only children whose parent exists in the current (post-collapse) list are grouped.
const _sessionIdsInList=new Set(sessions.map(s=>s.session_id));
const _parentChildrenMap=new Map();
const _topLevelSessions=[];
for(const s of sessions){
if(s.parent_session_id && _sessionIdsInList.has(s.parent_session_id)){
if(!_parentChildrenMap.has(s.parent_session_id)) _parentChildrenMap.set(s.parent_session_id,[]);
_parentChildrenMap.get(s.parent_session_id).push(s);
} else {
_topLevelSessions.push(s);
}
}
// Collapse state for subagent tree groups — persisted in localStorage (#494)
let _treeCollapsed={};
try{_treeCollapsed=JSON.parse(localStorage.getItem('hermes-tree-collapsed')||'{}');}catch(e){}
const _saveTreeCollapsed=()=>{try{localStorage.setItem('hermes-tree-collapsed',JSON.stringify(_treeCollapsed));}catch(e){}};
const archivedCount=projectFiltered.filter(s=>s.archived).length;
const list=$('sessionList');list.innerHTML='';
// Batch select bar (when in select mode)
@@ -1399,7 +1438,7 @@ function renderSessionListFromCache(){
empty.textContent='No sessions in this project yet.';
list.appendChild(empty);
}
const orderedSessions=[...sessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
const orderedSessions=[..._topLevelSessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
// Separate pinned from unpinned
const pinned=orderedSessions.filter(s=>s.pinned);
const unpinned=orderedSessions.filter(s=>!s.pinned);
@@ -1445,7 +1484,47 @@ function renderSessionListFromCache(){
_saveCollapsed();
};
wrapper.appendChild(hdr);
for(const s of g.items){ body.appendChild(_renderOneSession(s, Boolean(g.isPinned))); }
for(const s of g.items){
const parentEl=_renderOneSession(s, Boolean(g.isPinned));
body.appendChild(parentEl);
// Render subagent children as indented tree (#494)
const children=_parentChildrenMap.get(s.session_id);
if(children&&children.length){
parentEl.classList.add('session-parent');
const treeCaret=document.createElement('span');
treeCaret.className='session-tree-caret';
treeCaret.textContent='\u25B8'; // right-pointing triangle (collapsed)
treeCaret.title=t('subagent_children');
parentEl.querySelector('.session-title-row').prepend(treeCaret);
const childCount=children.length;
const childBadge=document.createElement('span');
childBadge.className='session-tree-badge';
childBadge.textContent=childCount;
childBadge.title=t('subagent_children');
parentEl.querySelector('.session-title-row').appendChild(childBadge);
const isCollapsed=_treeCollapsed[s.session_id]!==false; // collapsed by default
const childContainer=document.createElement('div');
childContainer.className='session-tree-children';
if(isCollapsed){childContainer.style.display='none';treeCaret.classList.add('collapsed');}
else{treeCaret.classList.remove('collapsed');treeCaret.textContent='\u25BE';}
const sortedChildren=[...children].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
for(const child of sortedChildren){
const childEl=_renderOneSession(child, Boolean(g.isPinned));
childEl.classList.add('session-tree-child');
childContainer.appendChild(childEl);
}
body.appendChild(childContainer);
treeCaret.onclick=(e)=>{
e.stopPropagation();
const hidden=childContainer.style.display==='none';
childContainer.style.display=hidden?'':'none';
treeCaret.textContent=hidden?'\u25BE':'\u25B8';
treeCaret.classList.toggle('collapsed',!hidden);
_treeCollapsed[s.session_id]=!hidden;
_saveTreeCollapsed();
};
}
}
wrapper.appendChild(body);
list.appendChild(wrapper);
}
+69 -4
View File
@@ -958,6 +958,18 @@
@keyframes mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(239,83,80,.3);}50%{box-shadow:0 0 0 6px rgba(239,83,80,0);}}
.mic-status{font-size:11px;color:var(--error);padding:4px 12px;display:flex;align-items:center;gap:6px;}
.mic-dot{width:6px;height:6px;border-radius:50%;background:var(--error);animation:mic-pulse 1.2s ease-in-out infinite;flex-shrink:0;}
/* ── Turn-based voice mode (#1333) ── */
.voice-mode-btn{transition:color .15s,background .15s;}
.voice-mode-btn.active{color:var(--accent);background:rgba(var(--accent-rgb,99,102,241),.15);}
.voice-mode-btn.active svg{filter:drop-shadow(0 0 3px rgba(var(--accent-rgb,99,102,241),.5));}
.voice-mode-bar{font-size:11px;padding:4px 12px;display:flex;align-items:center;gap:8px;border-bottom:1px solid rgba(255,255,255,.05);}
.voice-mode-indicator{width:8px;height:8px;border-radius:50%;flex-shrink:0;}
.voice-mode-indicator.listening{background:var(--error);animation:voice-mode-pulse 1s ease-in-out infinite;}
.voice-mode-indicator.speaking{background:var(--accent);animation:voice-mode-pulse 1.5s ease-in-out infinite;}
.voice-mode-indicator.thinking{background:var(--warning,#f59e0b);animation:voice-mode-pulse 2s ease-in-out infinite;}
.voice-mode-label{color:var(--muted);font-size:11px;}
@keyframes voice-mode-pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:.5;transform:scale(.85);}}
.status-text{font-size:11px;color:var(--muted);padding-left:4px;}
.send-btn{width:34px;height:34px;border-radius:50%;background:var(--accent);border:none;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s,transform .15s,box-shadow .15s;box-shadow:0 2px 8px var(--accent-bg-strong);}
.send-btn.stop,.send-btn.interrupt{background:var(--error);box-shadow:0 2px 10px rgba(0,0,0,.18);}
@@ -1253,7 +1265,7 @@
#terminalWorkspaceLabel{max-width:110px;}
#terminalDockWorkspaceLabel{max-width:96px;}
/* Touch targets — minimum 44px */
.icon-btn,.mic-btn{min-width:44px;min-height:44px;}
.icon-btn,.mic-btn,.voice-mode-btn{min-width:44px;min-height:44px;}
.session-item{min-height:44px;padding:10px 40px 10px 12px;}
.session-item.streaming,.session-item.unread{padding-right:40px;}
.session-actions{opacity:1;pointer-events:auto;}
@@ -1411,8 +1423,12 @@
.msg-row:hover .msg-actions{opacity:1;}
.msg-action-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:5px;transition:color .12s,background .12s;line-height:1;}
.msg-action-btn:hover{color:var(--accent-text);background:var(--accent-bg);}
/* TTS speaker button: hidden by default, shown when TTS enabled */
/* TTS speaker button: hidden by default, shown when TTS is enabled.
* Use body-class selector instead of JS inline-style so the rule survives
* subsequent renderMd() passes and is not subject to inline-style cascade
* collisions with the .msg-action-btn parent (#1409). */
.msg-tts-btn{display:none;}
body.tts-enabled .msg-tts-btn{display:inline-flex;align-items:center;}
.msg-tts-btn[data-speaking="1"]{color:var(--accent);animation:tts-pulse 1s ease-in-out infinite;}
@keyframes tts-pulse{0%,100%{opacity:1}50%{opacity:.5}}
@@ -1996,8 +2012,9 @@ main.main > #mainSkills,
main.main > #mainMemory,
main.main > #mainTasks,
main.main > #mainWorkspaces,
main.main > #mainProfiles{display:none;}
main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-workspaces):not(.showing-profiles) > #mainChat{display:flex;}
main.main > #mainProfiles,
main.main > #mainInsights{display:none;}
main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-workspaces):not(.showing-profiles):not(.showing-insights) > #mainChat{display:flex;}
main.main.showing-settings > #mainSettings{display:flex;overflow-y:auto;}
main.main.showing-skills > #mainSkills{display:flex;}
main.main.showing-memory > #mainMemory{display:flex;}
@@ -2298,6 +2315,17 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
.session-item.archived{opacity:.5;}
.session-item.archived .session-title{font-style:italic;}
/* ── Subagent session tree (#494) ── */
.session-tree-children{margin-left:16px;border-left:1px solid var(--border,rgba(255,255,255,.1));padding-left:4px;}
.session-tree-child.session-item{font-size:12px;opacity:.85;border-radius:6px;padding:6px 8px;}
.session-tree-child.session-item:hover{opacity:1;}
.session-tree-child.session-item.active{opacity:1;}
.session-tree-child.session-item .session-meta{font-size:10px;}
.session-tree-caret{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;font-size:10px;cursor:pointer;color:var(--muted);flex-shrink:0;transition:transform .15s;user-select:none;border-radius:3px;margin-right:2px;}
.session-tree-caret:hover{color:var(--text);background:rgba(255,255,255,.06);}
.session-tree-caret.collapsed{transform:none;}
.session-tree-badge{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;font-size:9px;font-weight:700;padding:0 4px;border-radius:8px;background:rgba(99,179,237,.2);color:#63b3ed;margin-left:auto;flex-shrink:0;user-select:none;}
/* ── Session tags ── */
.session-tag{display:inline-block;font-size:9px;font-weight:600;padding:1px 5px;margin-left:4px;border-radius:3px;background:rgba(99,179,237,.2);color:#63b3ed;cursor:pointer;vertical-align:middle;}
.session-tag:hover{background:rgba(99,179,237,.35);}
@@ -2856,3 +2884,40 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
.html-preview-iframe{width:100%;height:400px;border:none;display:block;background:#fff;}
.html-preview-fallback{padding:8px;font-size:13px;}
.html-preview-spinner{animation:pulse 1.5s ease-in-out infinite;}
/* ── Insights panel (#464) ────────────────────────────────────────────────── */
main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.insights-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:16px;}
.insights-stat{background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:14px;}
.insights-stat-value{font-size:22px;font-weight:700;color:var(--text);}
.insights-stat-label{font-size:11px;color:var(--muted);margin-top:4px;}
.insights-row{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;}
.insights-card{background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:14px;}
.insights-card-title{font-size:13px;font-weight:600;color:var(--text);margin-bottom:10px;}
.insights-table{width:100%;font-size:12px;}
.insights-table-head{display:grid;grid-template-columns:1fr 80px;padding:4px 0;border-bottom:1px solid var(--border);font-weight:600;color:var(--muted);font-size:11px;}
.insights-table-row{display:grid;grid-template-columns:1fr 80px;padding:6px 0;border-bottom:1px solid var(--border,.05);}
.insights-model-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.insights-bars{display:flex;flex-direction:column;gap:6px;}
.insights-bar-row{display:grid;grid-template-columns:40px 1fr 40px;align-items:center;gap:8px;}
.insights-bar-label{font-size:11px;color:var(--muted);text-align:right;}
.insights-bar-track{height:16px;background:var(--border,.15);border-radius:4px;overflow:hidden;}
.insights-bar-fill{height:100%;background:var(--accent);border-radius:4px;min-width:2px;transition:width .3s;}
.insights-bar-fill.peak{background:#f6ad55;}
.insights-bar-value{font-size:11px;color:var(--text);}
.insights-token-row{display:flex;justify-content:space-between;padding:4px 0;font-size:12px;border-bottom:1px solid var(--border,.05);}
.insights-token-label{color:var(--muted);}
.insights-token-value{font-weight:600;}
/* ── Checkpoints / Rollback UI (#466) ─────────────────────────────────────── */
.checkpoint-list{display:flex;flex-direction:column;gap:8px;}
.checkpoint-item{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;background:var(--surface-2);border:1px solid var(--border);border-radius:6px;font-size:12px;}
.checkpoint-item-actions{display:flex;gap:6px;}
.checkpoint-item-actions button{background:none;border:none;color:var(--muted);cursor:pointer;padding:2px 4px;border-radius:4px;}
.checkpoint-item-actions button:hover{color:var(--accent);background:rgba(255,255,255,.06);}
.checkpoint-item-actions button.danger:hover{color:#fc8181;}
.checkpoint-diff{position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;}
.checkpoint-diff-modal{background:var(--surface);border:1px solid var(--border);border-radius:8px;max-width:700px;width:90%;max-height:80vh;display:flex;flex-direction:column;}
.checkpoint-diff-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--border);}
.checkpoint-diff-body{padding:12px 16px;overflow-y:auto;flex:1;}
.checkpoint-diff-body pre{font-size:11px;line-height:1.4;white-space:pre-wrap;word-break:break-all;}
+76 -11
View File
@@ -225,6 +225,7 @@ setTimeout(_initMediaPlaybackObserver,0);
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
let _dynamicModelLabels={};
window._configuredModelBadges=window._configuredModelBadges||{};
const MODEL_STATE_KEY='hermes-webui-model-state';
// ── Smart model resolver ────────────────────────────────────────────────────
// Finds the best matching option value in a <select> 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(()=>{});
}
}
+2 -2
View File
@@ -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)
+54
View File
@@ -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<body>[^}]*)\}',
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)."
)
@@ -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
+13
View File
@@ -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
+89
View File
@@ -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
+292
View File
@@ -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):
+220
View File
@@ -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 `../<other-ws-hash>/<sha>` 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 `../<other-ws-hash>/<sha>` 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, ../<ws>/<sha> 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