mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 19:50:15 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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">×</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
@@ -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
@@ -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
@@ -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(()=>{});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user