Merge pull request #2214 from nesquena/stage-351

stage-351: net-positive ready batch — perf CLI scan cache #2149 + thinking-tag leading-only #2213 + MCP tools pagination #2210 + per-target update summaries #2207 + sweep animation tune #2212 + agent-mode cron badge #2206
This commit is contained in:
nesquena-hermes
2026-05-13 17:16:30 -07:00
committed by GitHub
26 changed files with 1788 additions and 215 deletions
+22
View File
@@ -4,6 +4,26 @@
### Fixed
- **PR #2210** by @Jordan-SkyLF — MCP Tools list in Settings → System no longer renders an unbounded inventory that makes the settings panel scroll-trapping. Added a toolbar (result-count summary, page-size 5/10/20/50/all, search input), bounded scroll area with consistent height, paginated rendering, and focused regression coverage for the large-inventory case. Existing WebUI-only/runtime-only contract preserved (no MCP server probing, no agent-side changes). Visual before/after evidence shipped under `docs/pr-media/2210/`.
- **PR #2213** by @franksong2702 (fixes #2152) — Literal `<think>`/`</think>` discussions in normal assistant prose are no longer stripped from saved messages and re-renders. The old server cleanup and stored-message render regexes stripped the first closed thinking-looking block anywhere in the content. PR aligns saved/static paths with the existing streaming rule: provider reasoning wrappers (`<think>...</think>`, MiniMax `<|channel>thought...<channel|>`, Gemma 4 `<|turn|>thinking...<turn|>`) are stripped only when they lead the response (i.e. the wrapper is the first non-whitespace content).
- **PR #2149** by @starship-s — `/api/session` loads no longer pay the cost of full external CLI session discovery when opening an ordinary WebUI-native chat. Caches CLI/external session scans briefly, skips CLI metadata lookup for ordinary WebUI-native session loads, and reuses a single in-memory ID snapshot during session-index pruning. Messaging, read-only, external-agent, and CLI-marked sidecars still take the CLI metadata path; CLI-only sessions still use the existing fallback. Stage-351 maintainer fix renamed the existing `_needs_cli_session_metadata()` gate to the broader `_session_requires_cli_metadata_lookup()` from this PR — strictly more inclusive (now also covers `read_only=True` sidecars, `session_source` markers, and source_tag/raw_source/platform metadata so legacy-imported sidecars still get the slow path when they need it).
### Added
- **PR #2207** by @Jordan-SkyLF (fixes #1579) — Update banner now shows target-aware "What's new?" links: WebUI updates link to the WebUI comparison, Agent updates link to the Agent comparison. Agent-only and WebUI-only update states no longer show a misleading cross-target comparison action. Opt-in settings toggle enables human-readable LLM-generated update summaries for each target's diff; users can still open the original diff from the summary. Cached/generated-summary button states persist across refreshes. Extended update-banner regression coverage for the diff-link and summary flows. Visual evidence: `docs/images/update-banner-whats-new-{before,after}.png` + summary on/off variants.
- **PR #2206** by @vcavichini — Cron list now shows a 🤖 emoji badge for jobs running in agent mode (`no_agent=false`). Cron detail view shows the configured provider/model next to the Mode badge, falling back to "default" when neither is explicitly set for agent-mode crons. UI-only change.
- **PR #2212** by @dobby-d-elf — Tunes the Activity sweep animation introduced in PR #2203 (stage-350) — softer color stops, less aggressive contrast, smoother fade. CSS-only follow-up.
## [v0.51.57] — 2026-05-13 — Release AG (stage-350 — 7-PR medium-risk batch — auth trilogy + cancel-status with conflict resolution + Ollama label guard + provider precedence + Activity animation + Opus dedup tightening)
### Fixed
- **Issue #2152** — Literal discussions of reasoning tags such as `<think>` and `</think>` no longer disappear from saved or re-rendered assistant messages. WebUI now treats `<think>...</think>`, MiniMax `<|channel>thought...<channel|>`, and Gemma 4 `<|turn|>thinking...<turn|>` blocks as hidden reasoning metadata only when the wrapper is the first non-whitespace content in the response; provider wrappers with leading whitespace still strip as before.
- **PR #2191** by @lucasrc (auth refactor 1/3) — Thread-safe login rate limiter (new `_LOGIN_ATTEMPTS_LOCK`) + PBKDF2 key separation (new `_pbkdf2_key()` reading `.pbkdf2_key` separately from `_signing_key()` reading `.signing_key` — previously both shared `.signing_key`, a key-reuse anti-pattern across HMAC and PBKDF2 primitives) + transparent migration in `verify_password()` that re-salts legacy hashes with the new key on next successful login. 241-line regression suite covering the lock + migration paths. Split from earlier #2167 per maintainer review request.
- **PR #2192** by @lucasrc (auth refactor 2/3, depends on #2191) — Invalidate password-hash cache when password changes via the Settings panel. The PR #2191 cache lives for the process lifetime, but `save_settings({'_set_password': ...})` could mutate `settings.json.password_hash` without telling the auth module — leaving the cache stale and verifying against the old password until restart. Now `save_settings()` calls `_invalidate_password_hash_cache()` on both `_set_password` and `_clear_password` paths. 52-line regression suite + `verify_password()` simplified to rely on the new hook instead of doing the invalidation itself.
@@ -36,6 +56,8 @@
### Fixed
- MCP Tools in Settings → System now uses a bounded scroll region with 5-item default pages, a per-page selector up to 40 tools, and a visible result summary, so large MCP tool inventories no longer make the settings panel balloon indefinitely.
- **PR #2201** by @MrFant — Multi-turn conversations with thinking-mode providers (MiMo/Xiaomi, DeepSeek, Kimi/Moonshot) no longer 400 with `Param Incorrect: reasoning_content must be passed back`. WebUI's `_sanitize_messages_for_api()` strips fields not in `_API_SAFE_MSG_KEYS` before sending conversation history to the LLM; `reasoning_content` was missing from the whitelist, so when history was replayed on the second turn, the assistant message with `tool_calls` arrived without `reasoning_content` and providers enforcing thinking-mode echo-back rejected it. One-line fix: adds `'reasoning_content'` to `_API_SAFE_MSG_KEYS`. CLI was unaffected because `run_agent.py` has its own `_copy_reasoning_content_for_api()` that doesn't go through this filter.
- **PR #2198** by @Michaelyklam — Fork-from-here keep-count was off-by-one (or larger) for truncated sessions where the visible-message index didn't match the absolute transcript index. JS now sends `_oldestIdx + msgIdx` (the absolute message index in the full transcript) as `keep_count` instead of the visible-window-relative index — captured *before* `_ensureAllMessagesLoaded()` resets `_oldestIdx`, so the index remains stable. Backend `source_messages[:keep_count]` then forks from the correct point even when the user has only loaded a tail window. When the full transcript is loaded (`_oldestIdx==0`), behavior is unchanged. 186-line regression suite in `tests/test_issue2184_fork_from_here_absolute_index.py` explicitly pins `keep_count: absoluteKeepCount` (and forbids the old `keep_count: msgIdx` form).
+2
View File
@@ -3909,6 +3909,7 @@ _SETTINGS_DEFAULTS = {
"show_cli_sessions": False, # merge CLI sessions from state.db into the sidebar
"sync_to_insights": False, # mirror WebUI token usage to state.db for /insights
"check_for_updates": True, # check if webui/agent repos are behind upstream
"whats_new_summary_enabled": False, # show an LLM-written What's New summary before diff links
"theme": "dark", # light | dark | system
"skin": "default", # accent color skin: default | ares | mono | slate | poseidon | sisyphus | charizard
"font_size": "default", # small | default | large
@@ -4037,6 +4038,7 @@ _SETTINGS_BOOL_KEYS = {
"show_cli_sessions",
"sync_to_insights",
"check_for_updates",
"whats_new_summary_enabled",
"sound_enabled",
"notifications_enabled",
"show_thinking",
+194 -104
View File
@@ -1,5 +1,6 @@
"""Hermes Web UI -- Session model and in-memory session store."""
import collections
import copy
import datetime
import hashlib
import json
@@ -22,6 +23,9 @@ from api.agent_sessions import read_importable_agent_session_rows, read_session_
logger = logging.getLogger(__name__)
CLI_VISIBLE_SESSION_LIMIT = 20
_CLI_SESSIONS_CACHE_TTL_SECONDS = 5.0
_CLI_SESSIONS_CACHE_LOCK = threading.Lock()
_CLI_SESSIONS_CACHE = {}
# ---------------------------------------------------------------------------
# Stale temp-file cleanup
@@ -383,6 +387,7 @@ class Session:
self.raw_source = kwargs.get('raw_source')
self.session_source = kwargs.get('session_source')
self.source_label = kwargs.get('source_label')
self.read_only = bool(kwargs.get('read_only', False))
self.enabled_toolsets = enabled_toolsets # List[str] or None — per-session toolset override
self.composer_draft = composer_draft if isinstance(composer_draft, dict) else {}
self._metadata_message_count = None
@@ -426,7 +431,7 @@ class Session:
'gateway_routing', 'gateway_routing_history', 'llm_title_generated',
'parent_session_id',
'worktree_path', 'worktree_branch', 'worktree_repo_root', 'worktree_created_at',
'is_cli_session', 'source_tag', 'raw_source', 'session_source', 'source_label',
'is_cli_session', 'source_tag', 'raw_source', 'session_source', 'source_label', 'read_only',
'enabled_toolsets', 'composer_draft',
]
meta = {k: getattr(self, k, None) for k in METADATA_FIELDS}
@@ -610,6 +615,7 @@ class Session:
'raw_source': self.raw_source,
'session_source': self.session_source,
'source_label': self.source_label,
'read_only': self.read_only,
'enabled_toolsets': self.enabled_toolsets,
'composer_draft': self.composer_draft if isinstance(self.composer_draft, dict) else {},
'is_streaming': _is_streaming_session(
@@ -1016,9 +1022,11 @@ def all_sessions(diag=None):
_diag_stage(diag, "all_sessions.read_index")
index = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
_diag_stage(diag, "all_sessions.prune_index")
with LOCK:
in_memory_ids = set(SESSIONS.keys())
index = [
s for s in index
if _index_entry_exists(s.get('session_id'))
if _index_entry_exists(s.get('session_id'), in_memory_ids=in_memory_ids)
]
backfilled = []
for i, s in enumerate(index):
@@ -1536,20 +1544,51 @@ def get_claude_code_session_messages(sid, projects_dir: Path | str | None = None
return []
def get_cli_sessions() -> list:
"""Read CLI sessions from the agent's SQLite store and return them as
dicts in a format the WebUI sidebar can render alongside local sessions.
def clear_cli_sessions_cache() -> None:
with _CLI_SESSIONS_CACHE_LOCK:
_CLI_SESSIONS_CACHE.clear()
Returns empty list if the SQLite DB is missing or any error occurs -- the
bridge is purely additive and never crashes the WebUI.
"""
import os
cli_sessions = []
def _copy_cli_sessions(sessions: list) -> list:
return copy.deepcopy(sessions)
def _cli_sessions_cache_ttl_seconds() -> float:
try:
cli_sessions.extend(get_claude_code_sessions())
except Exception:
logger.debug("Claude Code session scan failed", exc_info=True)
return max(0.0, float(_CLI_SESSIONS_CACHE_TTL_SECONDS))
except (TypeError, ValueError):
return 5.0
def _path_cache_key(path) -> str | None:
if path is None:
return None
try:
return str(Path(path).expanduser().resolve(strict=False))
except Exception:
return str(path)
def _path_stat_cache_key(path):
if path is None:
return None
try:
st = Path(path).stat()
return (st.st_mtime_ns, st.st_size)
except OSError:
return None
def _sqlite_file_stat_cache_key(db_path: Path):
"""Return a cheap invalidation key for a SQLite DB and WAL sidecars."""
return (
_path_stat_cache_key(db_path),
_path_stat_cache_key(Path(f"{db_path}-wal")),
_path_stat_cache_key(Path(f"{db_path}-shm")),
)
def _resolve_cli_sessions_context():
# Use the active WebUI profile's HERMES_HOME to find state.db.
# The active profile is determined by what the user has selected in the UI
# (stored in the server's runtime config). This means:
@@ -1564,17 +1603,35 @@ def get_cli_sessions() -> list:
except Exception:
hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser().resolve()
db_path = hermes_home / 'state.db'
if not db_path.exists():
return cli_sessions
# Try to resolve the active CLI profile so imported sessions integrate
# with the WebUI profile filter (available since Sprint 22).
try:
from api.profiles import get_active_profile_name
_cli_profile = get_active_profile_name()
except ImportError:
_cli_profile = None # older agent -- fall back to no profile
cli_profile = get_active_profile_name()
except Exception:
cli_profile = None
db_path = hermes_home / 'state.db'
projects_dir = _default_claude_code_projects_dir()
cache_key = (
str(hermes_home),
str(cli_profile or ''),
str(db_path),
_sqlite_file_stat_cache_key(db_path),
_path_cache_key(projects_dir),
_path_stat_cache_key(projects_dir),
_path_stat_cache_key(SESSION_INDEX_FILE),
)
return hermes_home, db_path, cli_profile, cache_key
def _load_cli_sessions_uncached(hermes_home: Path, db_path: Path, _cli_profile) -> list:
cli_sessions = []
try:
cli_sessions.extend(get_claude_code_sessions())
except Exception:
logger.debug("Claude Code session scan failed", exc_info=True)
if not db_path.exists():
return cli_sessions
# Memoize the cron project ID for this scan so we don't pay a lock-acquire +
# disk-read of projects.json per cron session in the loop below.
@@ -1585,96 +1642,129 @@ def get_cli_sessions() -> list:
_cron_pid_cache[0] = ensure_cron_project()
return _cron_pid_cache[0]
try:
for row in read_importable_agent_session_rows(
db_path,
limit=CLI_VISIBLE_SESSION_LIMIT,
log=logger,
exclude_sources=None,
):
sid = row['id']
raw_ts = row['last_activity'] or row['started_at']
# Prefer the CLI session's own profile from the DB; fall back to
# the active CLI profile so sidebar filtering works either way.
profile = _cli_profile # CLI DB has no profile column; use active profile
for row in read_importable_agent_session_rows(
db_path,
limit=CLI_VISIBLE_SESSION_LIMIT,
log=logger,
exclude_sources=None,
):
sid = row['id']
raw_ts = row['last_activity'] or row['started_at']
# Prefer the CLI session's own profile from the DB; fall back to
# the active CLI profile so sidebar filtering works either way.
profile = _cli_profile # CLI DB has no profile column; use active profile
_source = row['source'] or 'cli'
_title = row['title']
if not _title and _source == 'cron' and sid.startswith('cron_'):
# Extract job_id from session ID (cron_{job_id}_{timestamp})
# and look up the human-friendly job name from jobs.json
parts = sid.split('_')
if len(parts) >= 3:
_job_id = parts[1]
try:
_jobs_path = hermes_home / 'cron' / 'jobs.json'
if _jobs_path.exists():
import json as _json
_jobs_data = _json.loads(_jobs_path.read_text())
for _j in _jobs_data.get('jobs', []):
if _j.get('id') == _job_id:
_title = _j.get('name') or _title
break
except Exception:
pass # degrade gracefully
# If a WebUI JSON file exists for this session (e.g. previously
# imported or renamed in the sidebar), prefer its title over the
# state.db title. This fixes rename-not-persisting for CLI sessions
# after compression chain extension (#1486).
_source = row['source'] or 'cli'
_title = row['title']
if not _title and _source == 'cron' and sid.startswith('cron_'):
# Extract job_id from session ID (cron_{job_id}_{timestamp})
# and look up the human-friendly job name from jobs.json
parts = sid.split('_')
if len(parts) >= 3:
_job_id = parts[1]
try:
_jobs_path = hermes_home / 'cron' / 'jobs.json'
if _jobs_path.exists():
import json as _json
_jobs_data = _json.loads(_jobs_path.read_text())
for _j in _jobs_data.get('jobs', []):
if _j.get('id') == _job_id:
_title = _j.get('name') or _title
break
except Exception:
pass # degrade gracefully
# If a WebUI JSON file exists for this session (e.g. previously
# imported or renamed in the sidebar), prefer its title over the
# state.db title. This fixes rename-not-persisting for CLI sessions
# after compression chain extension (#1486).
try:
_webui_meta = Session.load_metadata_only(sid)
if _webui_meta and getattr(_webui_meta, 'title', None):
_title = _webui_meta.title
except Exception:
pass
_display_title = _title or f'{_source.title()} Session'
cli_sessions.append({
'session_id': sid,
'title': _display_title,
'workspace': str(get_last_workspace()),
'model': row['model'] or None,
'message_count': row['message_count'] or row['actual_message_count'] or 0,
'created_at': row['started_at'],
'updated_at': raw_ts,
'pinned': False,
'archived': False,
'project_id': _cron_pid() if is_cron_session(sid, _source) else None,
'profile': profile,
'source_tag': _source,
'raw_source': row.get('raw_source'),
'user_id': row.get('user_id'),
'chat_id': row.get('chat_id') or row.get('origin_chat_id'),
'chat_type': row.get('chat_type'),
'thread_id': row.get('thread_id'),
'session_key': row.get('session_key'),
'platform': row.get('platform'),
'session_source': row.get('session_source'),
'source_label': row.get('source_label'),
'parent_session_id': row.get('parent_session_id'),
'parent_title': row.get('parent_title'),
'parent_source': row.get('parent_source'),
'relationship_type': row.get('relationship_type'),
'_parent_lineage_root_id': row.get('_parent_lineage_root_id'),
'end_reason': row.get('end_reason'),
'actual_message_count': row.get('actual_message_count'),
'user_message_count': row.get('actual_user_message_count'),
'_lineage_root_id': row.get('_lineage_root_id'),
'_lineage_tip_id': row.get('_lineage_tip_id'),
'_compression_segment_count': row.get('_compression_segment_count'),
'is_cli_session': True,
})
return cli_sessions
def get_cli_sessions() -> list:
"""Read CLI sessions from the agent's SQLite store and return them as
dicts in a format the WebUI sidebar can render alongside local sessions.
Returns empty list if the SQLite DB is missing or any error occurs -- the
bridge is purely additive and never crashes the WebUI.
"""
hermes_home, db_path, cli_profile, cache_key = _resolve_cli_sessions_context()
ttl = _cli_sessions_cache_ttl_seconds()
now = time.monotonic()
if ttl > 0:
with _CLI_SESSIONS_CACHE_LOCK:
cached = _CLI_SESSIONS_CACHE.get(cache_key)
if cached:
expires_at, cached_sessions = cached
if expires_at > now:
return _copy_cli_sessions(cached_sessions)
_CLI_SESSIONS_CACHE.pop(cache_key, None)
try:
_webui_meta = Session.load_metadata_only(sid)
if _webui_meta and getattr(_webui_meta, 'title', None):
_title = _webui_meta.title
except Exception:
pass
_display_title = _title or f'{_source.title()} Session'
cli_sessions.append({
'session_id': sid,
'title': _display_title,
'workspace': str(get_last_workspace()),
'model': row['model'] or None,
'message_count': row['message_count'] or row['actual_message_count'] or 0,
'created_at': row['started_at'],
'updated_at': raw_ts,
'pinned': False,
'archived': False,
'project_id': _cron_pid() if is_cron_session(sid, _source) else None,
'profile': profile,
'source_tag': _source,
'raw_source': row.get('raw_source'),
'user_id': row.get('user_id'),
'chat_id': row.get('chat_id') or row.get('origin_chat_id'),
'chat_type': row.get('chat_type'),
'thread_id': row.get('thread_id'),
'session_key': row.get('session_key'),
'platform': row.get('platform'),
'session_source': row.get('session_source'),
'source_label': row.get('source_label'),
'parent_session_id': row.get('parent_session_id'),
'parent_title': row.get('parent_title'),
'parent_source': row.get('parent_source'),
'relationship_type': row.get('relationship_type'),
'_parent_lineage_root_id': row.get('_parent_lineage_root_id'),
'end_reason': row.get('end_reason'),
'actual_message_count': row.get('actual_message_count'),
'user_message_count': row.get('actual_user_message_count'),
'_lineage_root_id': row.get('_lineage_root_id'),
'_lineage_tip_id': row.get('_lineage_tip_id'),
'_compression_segment_count': row.get('_compression_segment_count'),
'is_cli_session': True,
})
sessions = _load_cli_sessions_uncached(hermes_home, db_path, cli_profile)
except Exception as _cli_err:
logger.warning(
"get_cli_sessions() failed — check state.db schema or path (%s): %s",
db_path, _cli_err,
)
return []
_CLI_SESSIONS_CACHE[cache_key] = (
time.monotonic() + ttl,
_copy_cli_sessions(sessions),
)
return _copy_cli_sessions(sessions)
try:
return _load_cli_sessions_uncached(hermes_home, db_path, cli_profile)
except Exception as _cli_err:
# DB schema changed, locked, or corrupted -- log warning so admins can diagnose.
# Still degrade gracefully (don't crash the WebUI).
import logging as _logging
_logging.getLogger(__name__).warning(
logger.warning(
"get_cli_sessions() failed — check state.db schema or path (%s): %s",
db_path, _cli_err,
)
return []
return cli_sessions
def _json_loads_if_string(value):
if not isinstance(value, str):
+99 -13
View File
@@ -1525,18 +1525,6 @@ def _lookup_cli_session_metadata(session_id: str) -> dict:
return {}
def _needs_cli_session_metadata(session) -> bool:
"""Return true when /api/session should pay for Agent/CLI metadata lookup."""
if not session:
return False
is_cli = (
bool(session.get("is_cli_session"))
if isinstance(session, dict)
else bool(getattr(session, "is_cli_session", False))
)
return is_cli or _is_messaging_session_record(session)
def _messaging_session_identity(session: dict, raw_source: str) -> str:
metadata = _lookup_gateway_session_identity(session.get("session_id"))
session_key = _safe_first(
@@ -1684,6 +1672,43 @@ def _messages_include_tool_metadata(messages) -> bool:
return False
def _session_requires_cli_metadata_lookup(session) -> bool:
"""Return True when a sidecar/session row still needs CLI metadata.
Legacy imported sidecars may predate the ``read_only`` field and therefore
load with ``read_only=False``. They still persist ``is_cli_session`` and/or
source metadata from import time, so those markers intentionally keep them
on the CLI lookup path while ordinary WebUI-native sessions take the fast
path.
Supersedes the simpler is-cli-or-messaging gate from PR #1822 — the new
gate is strictly more inclusive (also covers ``read_only=True`` sidecars,
``session_source`` markers, and source_tag/raw_source/platform metadata)
so all sessions that previously took the slow path still do, plus a few
more legacy shapes.
"""
if not session:
return False
def _field(name):
return session.get(name) if isinstance(session, dict) else getattr(session, name, None)
if _is_messaging_session_record(session):
return True
if bool(_field("is_cli_session")) or bool(_field("read_only")):
return True
session_source = _normalize_messaging_source(_safe_first(_field("session_source")))
if session_source in {"messaging", "external_agent", "external-agent"}:
return True
return bool(_safe_first(
_field("source_tag"),
_field("raw_source"),
_field("source"),
_field("source_label"),
_field("platform"),
))
def _is_messaging_session_id(sid: str) -> bool:
"""Detect messaging-backed sessions from WebUI metadata or Agent rows."""
try:
@@ -3252,7 +3277,7 @@ def handle_get(handler, parsed) -> bool:
_t1 = _time.monotonic()
s = get_session(sid, metadata_only=(not load_messages))
_clear_stale_stream_state(s)
cli_meta = _lookup_cli_session_metadata(sid) if _needs_cli_session_metadata(s) else {}
cli_meta = _lookup_cli_session_metadata(sid) if _session_requires_cli_metadata_lookup(s) else {}
is_messaging_session = _is_messaging_session_record(s) or _is_messaging_session_record(cli_meta)
cli_messages = []
if is_messaging_session:
@@ -3718,6 +3743,8 @@ def handle_get(handler, parsed) -> bool:
"current_sha": "abc1234",
"latest_sha": "def5678",
"branch": "master",
"repo_url": "https://github.com/nesquena/hermes-webui",
"compare_url": "https://github.com/nesquena/hermes-webui/compare/abc1234...def5678",
},
"agent": {
"name": "agent",
@@ -3725,6 +3752,8 @@ def handle_get(handler, parsed) -> bool:
"current_sha": "aaa0001",
"latest_sha": "bbb0002",
"branch": "master",
"repo_url": "https://github.com/NousResearch/hermes-agent",
"compare_url": "https://github.com/NousResearch/hermes-agent/compare/aaa0001...bbb0002",
},
"checked_at": 0,
},
@@ -5272,6 +5301,63 @@ def handle_post(handler, parsed) -> bool:
return j(handler, apply_force_update(target))
if parsed.path == "/api/updates/summary":
from api.updates import summarize_update_payload
updates = body.get("updates") if isinstance(body, dict) else {}
target = body.get("target") if isinstance(body, dict) else None
def _llm_update_summary(system_prompt: str, user_prompt: str) -> str:
from run_agent import AIAgent
from api.config import (
get_effective_default_model,
resolve_model_provider,
resolve_custom_provider_connection,
)
_model, _provider, _base_url = resolve_model_provider(get_effective_default_model())
_api_key = None
try:
from api.oauth import resolve_runtime_provider_with_anthropic_env_lock
from hermes_cli.runtime_provider import resolve_runtime_provider
_rt = resolve_runtime_provider_with_anthropic_env_lock(
resolve_runtime_provider,
requested=_provider,
)
_api_key = _rt.get("api_key")
if not _provider:
_provider = _rt.get("provider")
if not _base_url:
_base_url = _rt.get("base_url")
except Exception as _e:
logger.debug("update summary runtime provider resolution failed: %s", _e)
if isinstance(_provider, str) and _provider.startswith("custom:"):
_cp_key, _cp_base = resolve_custom_provider_connection(_provider)
if not _api_key and _cp_key:
_api_key = _cp_key
if not _base_url and _cp_base:
_base_url = _cp_base
agent = AIAgent(
model=_model,
provider=_provider,
base_url=_base_url,
api_key=_api_key,
platform="webui",
quiet_mode=True,
enabled_toolsets=[],
session_id=f"updates-summary-{uuid.uuid4().hex[:8]}",
)
result = agent.run_conversation(
user_message=user_prompt,
system_message=system_prompt,
conversation_history=[],
task_id=f"updates-summary-{uuid.uuid4().hex[:8]}",
)
return str(result.get("final_response") or "").strip()
return j(handler, summarize_update_payload(updates, llm_callback=_llm_update_summary, target=target))
# ── CLI session import (POST) ──
if parsed.path == "/api/session/import_cli":
return _handle_session_import_cli(handler, body)
+6 -3
View File
@@ -689,9 +689,12 @@ def _strip_thinking_markup(text: str) -> str:
if not text:
return ''
s = str(text)
s = re.sub(r'<think>.*?</think>', ' ', s, flags=re.IGNORECASE | re.DOTALL)
s = re.sub(r'<\|channel\|>thought.*?<channel\|>', ' ', s, flags=re.IGNORECASE | re.DOTALL)
s = re.sub(r'<\|turn\|>thinking\n.*?<turn\|>', ' ', s, flags=re.IGNORECASE | re.DOTALL) # Gemma 4
# Treat provider thinking wrappers as metadata only when they lead the
# response. Literal discussion of these tags later in normal prose should
# stay visible (#2152).
s = re.sub(r'^\s*<think>.*?</think>\s*', ' ', s, flags=re.IGNORECASE | re.DOTALL)
s = re.sub(r'^\s*<\|channel\|?>thought\n?.*?<channel\|>\s*', ' ', s, flags=re.IGNORECASE | re.DOTALL)
s = re.sub(r'^\s*<\|turn\|>thinking\n.*?<turn\|>\s*', ' ', s, flags=re.IGNORECASE | re.DOTALL) # Gemma 4
s = re.sub(r'^\s*(the|ther)\s+user\s+is\s+asking.*$', ' ', s, flags=re.IGNORECASE | re.MULTILINE)
# Strip plain-text thinking preambles from models that don't use <think> tags (e.g. Qwen3).
# These appear as the very first sentence of the assistant response and are not useful as titles.
+244
View File
@@ -8,10 +8,14 @@ at most twice per hour regardless of client count.
Skips repos that are not git checkouts (e.g. Docker baked images where
.git does not exist).
"""
import hashlib
import json
import re
import subprocess
import threading
import time
from pathlib import Path
from urllib.parse import urlparse
from api.config import REPO_ROOT, STREAMS, STREAMS_LOCK
@@ -22,6 +26,7 @@ except ImportError:
_AGENT_DIR = None
_update_cache = {'webui': None, 'agent': None, 'checked_at': 0}
_summary_cache = {}
_cache_lock = threading.Lock()
_check_in_progress = False
_apply_lock = threading.Lock() # prevents concurrent stash/pull/pop on same repo
@@ -169,6 +174,16 @@ def _normalize_remote_url(remote_url):
return remote_url.rstrip('/')
def _build_compare_url(repo_url, current_sha, latest_sha):
"""Return a safe browser compare URL, or None when any piece is missing."""
if not (repo_url and current_sha and latest_sha):
return None
parsed = urlparse(repo_url)
if parsed.scheme not in ('http', 'https') or not parsed.netloc:
return None
return f"{repo_url}/compare/{current_sha}...{latest_sha}"
def _split_remote_ref(ref):
"""Split 'origin/branch-name' into ('origin', 'branch-name').
@@ -262,6 +277,7 @@ def _check_repo(path, name):
'latest_sha': latest,
'branch': compare_ref,
'repo_url': remote_url,
'compare_url': _build_compare_url(remote_url, current, latest),
}
@@ -289,6 +305,234 @@ def check_for_updates(force=False):
_check_in_progress = False
def _repo_path_for_update_target(target: str):
if target == 'webui':
return REPO_ROOT
if target == 'agent':
return _AGENT_DIR
return None
def _commit_subjects_for_update(info: dict, *, limit: int = 24) -> list[str]:
"""Return commit subjects for an update range, if the local git refs exist."""
if not isinstance(info, dict):
return []
target = info.get('name')
if target not in ('webui', 'agent'):
target = 'webui' if info.get('repo_url', '').endswith('hermes-webui') else target
path = _repo_path_for_update_target(target)
if path is None or not (Path(path) / '.git').exists():
return []
current = str(info.get('current_sha') or '').strip()
latest = str(info.get('latest_sha') or '').strip()
if not (current and latest):
return []
out, ok = _run_git(['log', '--format=%s', f'{current}..{latest}', f'-n{limit}'], path, timeout=5)
if not ok or not out:
return []
return [line.strip() for line in out.splitlines() if line.strip()]
def _summary_cache_key(updates: dict, details: list[dict]) -> str:
"""Stable key for the exact update range being summarized."""
payload = []
for item in details:
payload.append({
'name': item.get('name'),
'behind': item.get('behind'),
'current_sha': item.get('current_sha'),
'latest_sha': item.get('latest_sha'),
'compare_url': item.get('compare_url'),
})
blob = json.dumps(payload, sort_keys=True, separators=(',', ':'))
return hashlib.sha256(blob.encode('utf-8')).hexdigest()
def _clean_summary_bullet(line: str) -> str:
line = re.sub(r'^\s*(?:[-*•]+|\d+[.)])\s*', '', str(line or '')).strip()
line = re.sub(r'\s+', ' ', line)
if not line:
return ''
if line[-1] not in '.!?':
line += '.'
return line[:240]
def _summary_bullets_from_text(text: str, *, fallback_items: list[str]) -> list[str]:
raw = str(text or '').strip()
candidates = []
for line in raw.splitlines():
cleaned = _clean_summary_bullet(line)
if cleaned:
candidates.append(cleaned)
if len(candidates) <= 1 and raw:
candidates = [_clean_summary_bullet(part) for part in re.split(r'(?<=[.!?])\s+', raw)]
candidates = [item for item in candidates if item]
if not candidates:
candidates = [_clean_summary_bullet(item) for item in fallback_items]
seen = set()
bullets = []
for item in candidates:
key = item.lower()
if item and key not in seen:
bullets.append(item)
seen.add(key)
if len(bullets) >= 5:
break
return bullets or ['Updates are available.']
def _fallback_update_bullets(details: list[dict]) -> list[str]:
bullets = []
for item in details:
label = item.get('label') or item.get('name') or 'Hermes'
behind = item.get('behind') or 0
commits = item.get('commits') or []
if commits:
highlights = '; '.join(commits[:3])
bullets.append(f"{label} has {behind} update(s), including: {highlights}.")
else:
bullets.append(f"{label} has {behind} update(s) available.")
return bullets or ['Updates are available.']
def _worth_knowing_bullets(details: list[dict]) -> list[str]:
targets = [
f"{item.get('label') or item.get('name') or 'Hermes'} ({item.get('behind') or 0} update{'s' if (item.get('behind') or 0) != 1 else ''})"
for item in details
if item.get('behind')
]
if len(targets) > 1:
return ['This summary combines updates from ' + ' and '.join(targets) + '.']
if targets:
return ['This summary covers ' + targets[0] + '.']
return ['No update details were available to summarize.']
def _format_update_summary_sections(summary_text: str, details: list[dict]) -> tuple[list[dict], str]:
bullets = _summary_bullets_from_text(summary_text, fallback_items=_fallback_update_bullets(details))
if len(bullets) > 1:
notice_items = bullets[:3]
worth_items = bullets[3:] or bullets[1:]
else:
notice_items = bullets
worth_items = []
worth_items = worth_items[:2] or _worth_knowing_bullets(details)
sections = [
{
'title': "What you'll notice",
'items': notice_items,
},
{
'title': 'Worth knowing',
'items': worth_items,
},
]
lines = []
for section in sections:
lines.append(section['title'])
lines.extend(f"- {item}" for item in section['items'])
lines.append('')
return sections, '\n'.join(lines).strip()
def _fallback_update_summary(updates: dict, details: list[dict]) -> str:
_sections, summary = _format_update_summary_sections('', details)
return summary
def _update_summary_prompt(details: list[dict]) -> tuple[str, str]:
system = (
"You write human-readable release summaries for Hermes users. "
"Focus on what the user will notice in the product. Keep it simple, specific, and short. "
"avoid technical jargon, implementation details, SHA names, branch names, and file paths unless necessary. "
"Return only bullets. Do not include headings, markdown tables, intro paragraphs, or closing notes."
)
user_lines = [
"Summarize these available updates as 3-5 concise bullets.",
"Use everyday language and explain visible behavior changes, not code mechanics.",
"Return only bullets; the WebUI will add the fixed section headings separately.",
"",
]
for item in details:
user_lines.append(f"{item['label']}: {item['behind']} commit(s) behind")
commits = item.get('commits') or []
if commits:
user_lines.extend(f"- {subject}" for subject in commits)
else:
user_lines.append("- No local commit subjects available; summarize only the update count.")
user_lines.append("")
return system, '\n'.join(user_lines)
def summarize_update_payload(updates: dict, llm_callback=None, *, target: str | None = None, use_cache: bool = True) -> dict:
"""Build a human-readable What's New summary and keep regular diff comparison links.
``llm_callback`` receives ``(system_prompt, user_prompt)`` and returns text.
The caller may wire that to AIAgent; this module keeps a deterministic
fallback so the banner remains useful when no LLM provider is configured.
Summaries are cached per exact update range so refreshes do not generate
slightly different wording for the same available updates.
"""
if not isinstance(updates, dict):
updates = {}
requested_target = target if target in ('webui', 'agent') else None
details = []
for key, label in (('webui', 'WebUI'), ('agent', 'Agent')):
if requested_target and key != requested_target:
continue
info = updates.get(key)
if not isinstance(info, dict) or int(info.get('behind') or 0) <= 0:
continue
item = {
'name': key,
'label': label,
'behind': int(info.get('behind') or 0),
'current_sha': info.get('current_sha'),
'latest_sha': info.get('latest_sha'),
'compare_url': info.get('compare_url'),
'commits': _commit_subjects_for_update({'name': key, **info}),
}
details.append(item)
cache_key = _summary_cache_key(updates, details)
if use_cache:
with _cache_lock:
cached = _summary_cache.get(cache_key)
if cached:
result = dict(cached)
result['cached'] = True
return result
generated_by = 'fallback'
candidate = ''
if details and callable(llm_callback):
system, prompt = _update_summary_prompt(details)
try:
candidate = (llm_callback(system, prompt) or '').strip()
if candidate:
generated_by = 'llm'
except Exception:
candidate = ''
sections, summary = _format_update_summary_sections(candidate, details)
result = {
'ok': True,
'summary': summary,
'summary_sections': sections,
'generated_by': generated_by,
'cached': False,
'cache_key': cache_key,
'target': requested_target,
'targets': details,
}
if use_cache:
with _cache_lock:
_summary_cache[cache_key] = dict(result)
return result
# ── Self-update application ───────────────────────────────────────────────────
def _schedule_restart(delay: float = 2.0) -> None:
"""Re-exec this process after *delay* seconds.
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+5 -3
View File
@@ -1379,6 +1379,10 @@ function applyBotName(){
window._showCliSessions=!!s.show_cli_sessions;
window._soundEnabled=!!s.sound_enabled;
window._notificationsEnabled=!!s.notifications_enabled;
// Persist default workspace so the blank new-chat page can show it
// and workspace actions (New file/folder) work before the first session (#804).
if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
window._whatsNewSummaryEnabled=!!s.whats_new_summary_enabled;
window._showThinking=s.show_thinking!==false;
window._simplifiedToolCalling=s.simplified_tool_calling!==false;
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
@@ -1386,9 +1390,6 @@ function applyBotName(){
window._sessionEndlessScrollEnabled=!!s.session_endless_scroll;
window._botName=s.bot_name||'Hermes';
if(s.default_model) window._defaultModel=s.default_model;
// Persist default workspace so the blank new-chat page can show it
// and workspace actions (New file/folder) work before the first session (#804).
if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
window._sessionJumpButtonsEnabled=!!s.session_jump_buttons;
const appearance=_normalizeAppearance(s.theme,s.skin);
localStorage.setItem('hermes-theme',appearance.theme);
@@ -1415,6 +1416,7 @@ function applyBotName(){
window._showCliSessions=false;
window._soundEnabled=false;
window._notificationsEnabled=false;
window._whatsNewSummaryEnabled=false;
window._showThinking=true;
window._simplifiedToolCalling=true;
window._sessionJumpButtonsEnabled=false;
+162
View File
@@ -92,6 +92,20 @@ const LOCALES = {
mcp_tools_load_failed: 'Failed to load MCP tools.',
mcp_tools_schema_empty: 'No schema parameters.',
mcp_tools_runtime_note: 'Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.',
mcp_tools_summary_no_matches: (query,total) => `No MCP tools matching “${query}” (${total} total MCP tools).`,
mcp_tools_summary_none: 'No MCP tools to show.',
mcp_tools_summary_matching: (query) => ` matching “${query}`,
mcp_tools_summary_total_note: (total) => ` (${total} total MCP tools)`,
mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `Showing ${start}-${end} of ${filtered} MCP tools${searchNote}${totalNote}. Page ${page} of ${pages}.`,
mcp_tools_page_size_prefix: 'Show',
mcp_tools_page_size_suffix: 'per page',
mcp_tools_per_page_aria: 'MCP tools per page',
mcp_tools_inactive_configured_servers: (servers) => `Configured but inactive in this WebUI runtime: ${servers}.`,
mcp_tools_pagination_label: 'MCP tools pagination',
mcp_tools_previous_page: ' Prev',
mcp_tools_previous_page_aria: 'Previous MCP tools page',
mcp_tools_next_page: 'Next ',
mcp_tools_next_page_aria: 'Next MCP tools page',
// PDF preview (#480)
pdf_loading: 'Loading PDF {0}…',
pdf_too_large: 'PDF too large for inline preview',
@@ -513,6 +527,7 @@ const LOCALES = {
settings_label_external_sessions: 'Show non-WebUI sessions',
settings_label_sync_insights: 'Sync to insights',
settings_label_check_updates: 'Check for updates',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: 'Assistant Name',
settings_label_password: 'Access Password',
settings_saved: 'Settings saved',
@@ -744,6 +759,7 @@ const LOCALES = {
settings_desc_external_sessions: 'Show conversations from CLI, Telegram, Discord, Slack, and other channels in the session list. Click to import and continue.',
settings_desc_sync_insights: 'Mirrors WebUI token usage to state.db so hermes /insights includes browser session data. Off by default.',
settings_desc_check_updates: 'Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.',
settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.",
settings_desc_bot_name: 'Display name for the assistant throughout the UI. Defaults to Hermes.',
settings_desc_password: 'Enter a new password to set or change it. Leave blank to keep current setting.',
password_placeholder: 'Enter new password…',
@@ -1217,6 +1233,20 @@ const LOCALES = {
mcp_tools_load_failed: 'Caricamento strumenti MCP fallito.',
mcp_tools_schema_empty: 'Nessun parametro schema.',
mcp_tools_runtime_note: "L'inventario strumenti usa solo dati runtime MCP già noti; la WebUI non avvia né interroga i server.",
mcp_tools_summary_no_matches: (query,total) => `No MCP tools matching “${query}” (${total} total MCP tools).`,
mcp_tools_summary_none: 'No MCP tools to show.',
mcp_tools_summary_matching: (query) => ` matching “${query}`,
mcp_tools_summary_total_note: (total) => ` (${total} total MCP tools)`,
mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `Showing ${start}-${end} of ${filtered} MCP tools${searchNote}${totalNote}. Page ${page} of ${pages}.`,
mcp_tools_page_size_prefix: 'Show',
mcp_tools_page_size_suffix: 'per page',
mcp_tools_per_page_aria: 'MCP tools per page',
mcp_tools_inactive_configured_servers: (servers) => `Configured but inactive in this WebUI runtime: ${servers}.`,
mcp_tools_pagination_label: 'MCP tools pagination',
mcp_tools_previous_page: ' Prev',
mcp_tools_previous_page_aria: 'Previous MCP tools page',
mcp_tools_next_page: 'Next ',
mcp_tools_next_page_aria: 'Next MCP tools page',
// PDF preview (#480)
pdf_loading: 'Caricamento PDF {0}…',
pdf_too_large: "PDF troppo grande per l'anteprima inline",
@@ -1638,6 +1668,7 @@ const LOCALES = {
settings_label_external_sessions: 'Mostra sessioni non-WebUI',
settings_label_sync_insights: 'Sincronizza con insights',
settings_label_check_updates: 'Verifica aggiornamenti',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: 'Nome Assistente',
settings_label_password: 'Password di Accesso',
settings_saved: 'Impostazioni salvate',
@@ -1861,6 +1892,7 @@ const LOCALES = {
settings_desc_external_sessions: 'Mostra conversazioni da CLI, Telegram, Discord, Slack e altri canali nella lista sessioni. Clicca per importare e continuare.',
settings_desc_sync_insights: 'Rispecchia l\'uso token WebUI su state.db così hermes /insights include i dati delle sessioni browser. Disattivato per impostazione predefinita.',
settings_desc_check_updates: 'Mostra un banner quando sono disponibili versioni più recenti della WebUI o dell\'Agente. Esegue un git fetch in background periodicamente.',
settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.",
settings_desc_bot_name: 'Nome visualizzato per l\'assistente in tutta l\'interfaccia. Predefinito: Hermes.',
settings_desc_password: 'Inserisci una nuova password per impostarla o cambiarla. Lascia vuoto per mantenere l\'impostazione attuale.',
password_placeholder: 'Inserisci nuova password…',
@@ -2334,6 +2366,20 @@ const LOCALES = {
mcp_tools_load_failed: 'MCP ツールの読み込みに失敗しました。',
mcp_tools_schema_empty: 'スキーマパラメータはありません。',
mcp_tools_runtime_note: 'ツール一覧は既知の MCP ランタイム情報のみを使用します。WebUI はサーバーの起動や探索を行いません。',
mcp_tools_summary_no_matches: (query,total) => `No MCP tools matching “${query}” (${total} total MCP tools).`,
mcp_tools_summary_none: 'No MCP tools to show.',
mcp_tools_summary_matching: (query) => ` matching “${query}`,
mcp_tools_summary_total_note: (total) => ` (${total} total MCP tools)`,
mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `Showing ${start}-${end} of ${filtered} MCP tools${searchNote}${totalNote}. Page ${page} of ${pages}.`,
mcp_tools_page_size_prefix: 'Show',
mcp_tools_page_size_suffix: 'per page',
mcp_tools_per_page_aria: 'MCP tools per page',
mcp_tools_inactive_configured_servers: (servers) => `Configured but inactive in this WebUI runtime: ${servers}.`,
mcp_tools_pagination_label: 'MCP tools pagination',
mcp_tools_previous_page: ' Prev',
mcp_tools_previous_page_aria: 'Previous MCP tools page',
mcp_tools_next_page: 'Next ',
mcp_tools_next_page_aria: 'Next MCP tools page',
// PDF preview (#480)
pdf_loading: 'PDF {0} を読み込み中…',
pdf_too_large: 'PDF が大きすぎてインラインプレビューできません',
@@ -2755,6 +2801,7 @@ const LOCALES = {
settings_label_external_sessions: '非WebUIセッションを表示',
settings_label_sync_insights: 'インサイトに同期',
settings_label_check_updates: 'アップデートを確認',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: 'アシスタント名',
settings_label_password: 'アクセスパスワード',
settings_saved: '設定を保存しました',
@@ -2983,6 +3030,7 @@ const LOCALES = {
settings_desc_external_sessions: 'CLI、Telegram、Discord、Slack その他のチャネルからの会話をセッション一覧に表示します。クリックでインポートして続行できます。',
settings_desc_sync_insights: 'WebUI のトークン使用量を state.db にミラーし、hermes /insights にブラウザセッションのデータを含めます。デフォルトはオフ。',
settings_desc_check_updates: 'WebUI または Agent の新しいバージョンが利用可能な時にバナーを表示します。バックグラウンドで定期的に git fetch を実行します。',
settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.",
settings_desc_bot_name: 'UI 全体で表示されるアシスタントの名前。デフォルトは Hermes。',
settings_desc_password: '新しいパスワードを入力すると設定または変更します。空欄なら現在の設定を維持。',
password_placeholder: '新しいパスワードを入力…',
@@ -3453,6 +3501,20 @@ const LOCALES = {
mcp_tools_load_failed: 'Failed to load MCP tools.',
mcp_tools_schema_empty: 'No schema parameters.',
mcp_tools_runtime_note: 'Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.',
mcp_tools_summary_no_matches: (query,total) => `No MCP tools matching “${query}” (${total} total MCP tools).`,
mcp_tools_summary_none: 'No MCP tools to show.',
mcp_tools_summary_matching: (query) => ` matching “${query}`,
mcp_tools_summary_total_note: (total) => ` (${total} total MCP tools)`,
mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `Showing ${start}-${end} of ${filtered} MCP tools${searchNote}${totalNote}. Page ${page} of ${pages}.`,
mcp_tools_page_size_prefix: 'Show',
mcp_tools_page_size_suffix: 'per page',
mcp_tools_per_page_aria: 'MCP tools per page',
mcp_tools_inactive_configured_servers: (servers) => `Configured but inactive in this WebUI runtime: ${servers}.`,
mcp_tools_pagination_label: 'MCP tools pagination',
mcp_tools_previous_page: ' Prev',
mcp_tools_previous_page_aria: 'Previous MCP tools page',
mcp_tools_next_page: 'Next ',
mcp_tools_next_page_aria: 'Next MCP tools page',
thinking: 'Думаю',
expand_all: 'Развернуть всё',
collapse_all: 'Свернуть всё',
@@ -3697,6 +3759,7 @@ const LOCALES = {
settings_label_external_sessions: 'Показывать внешние сеансы',
settings_label_sync_insights: 'Синхронизировать с Insights',
settings_label_check_updates: 'Проверять обновления',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: 'Имя помощника',
settings_label_password: 'Пароль доступа',
settings_saved: 'Настройки сохранены',
@@ -3874,6 +3937,7 @@ const LOCALES = {
settings_desc_external_sessions: 'Показать беседы из CLI, Telegram, Discord, Slack и других каналов в списке сеансов. Нажмите для импорта и продолжения.',
settings_desc_sync_insights: 'Синхронизирует использование токенов WebUI в state.db, чтобы Hermes /insights включал данные браузерных сеансов. Выключено по умолчанию.',
settings_desc_check_updates: 'Показывает баннер, когда доступны более новые версии WebUI или Agent. Периодически выполняет git fetch в фоне.',
settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.",
settings_desc_bot_name: 'Отображаемое имя помощника во всём интерфейсе. По умолчанию Hermes.',
settings_desc_password: 'Введите новый пароль, чтобы задать или изменить его. Оставьте пустым, чтобы сохранить текущую настройку.',
password_placeholder: 'Введите новый пароль…',
@@ -4504,6 +4568,20 @@ const LOCALES = {
mcp_tools_load_failed: 'Failed to load MCP tools.',
mcp_tools_schema_empty: 'No schema parameters.',
mcp_tools_runtime_note: 'Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.',
mcp_tools_summary_no_matches: (query,total) => `No MCP tools matching “${query}” (${total} total MCP tools).`,
mcp_tools_summary_none: 'No MCP tools to show.',
mcp_tools_summary_matching: (query) => ` matching “${query}`,
mcp_tools_summary_total_note: (total) => ` (${total} total MCP tools)`,
mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `Showing ${start}-${end} of ${filtered} MCP tools${searchNote}${totalNote}. Page ${page} of ${pages}.`,
mcp_tools_page_size_prefix: 'Show',
mcp_tools_page_size_suffix: 'per page',
mcp_tools_per_page_aria: 'MCP tools per page',
mcp_tools_inactive_configured_servers: (servers) => `Configured but inactive in this WebUI runtime: ${servers}.`,
mcp_tools_pagination_label: 'MCP tools pagination',
mcp_tools_previous_page: ' Prev',
mcp_tools_previous_page_aria: 'Previous MCP tools page',
mcp_tools_next_page: 'Next ',
mcp_tools_next_page_aria: 'Next MCP tools page',
thinking: 'Pensando',
expand_all: 'Expandir todo',
collapse_all: 'Contraer todo',
@@ -4743,6 +4821,7 @@ const LOCALES = {
settings_label_external_sessions: 'Mostrar sesiones externas',
settings_label_sync_insights: 'Sincronizar con insights',
settings_label_check_updates: 'Buscar actualizaciones',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: 'Nombre del asistente',
settings_label_password: 'Contraseña de acceso',
settings_saved: 'Configuración guardada',
@@ -4931,6 +5010,7 @@ const LOCALES = {
settings_desc_external_sessions: 'Mostrar conversaciones de CLI, Telegram, Discord, Slack y otros canales en la lista de sesiones. Haz clic para importar y continuar.',
settings_desc_sync_insights: 'Refleja el uso de tokens de la WebUI en state.db para que hermes /insights incluya datos de sesiones del navegador. Desactivado por defecto.',
settings_desc_check_updates: 'Muestra un banner cuando haya versiones más nuevas de la WebUI o del Agent. Ejecuta periódicamente un git fetch en segundo plano.',
settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.",
settings_desc_bot_name: 'Nombre visible del asistente en toda la UI. Por defecto es Hermes.',
settings_desc_password: 'Introduce una nueva contraseña para establecerla o cambiarla. Déjalo en blanco para mantener la configuración actual.',
password_placeholder: 'Introduce una contraseña nueva…',
@@ -5558,6 +5638,20 @@ const LOCALES = {
mcp_tools_load_failed: 'Failed to load MCP tools.',
mcp_tools_schema_empty: 'No schema parameters.',
mcp_tools_runtime_note: 'Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.',
mcp_tools_summary_no_matches: (query,total) => `No MCP tools matching “${query}” (${total} total MCP tools).`,
mcp_tools_summary_none: 'No MCP tools to show.',
mcp_tools_summary_matching: (query) => ` matching “${query}`,
mcp_tools_summary_total_note: (total) => ` (${total} total MCP tools)`,
mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `Showing ${start}-${end} of ${filtered} MCP tools${searchNote}${totalNote}. Page ${page} of ${pages}.`,
mcp_tools_page_size_prefix: 'Show',
mcp_tools_page_size_suffix: 'per page',
mcp_tools_per_page_aria: 'MCP tools per page',
mcp_tools_inactive_configured_servers: (servers) => `Configured but inactive in this WebUI runtime: ${servers}.`,
mcp_tools_pagination_label: 'MCP tools pagination',
mcp_tools_previous_page: ' Prev',
mcp_tools_previous_page_aria: 'Previous MCP tools page',
mcp_tools_next_page: 'Next ',
mcp_tools_next_page_aria: 'Next MCP tools page',
thinking: 'Nachdenken',
expand_all: 'Alle ausklappen',
collapse_all: 'Alle einklappen',
@@ -5782,6 +5876,7 @@ const LOCALES = {
settings_label_external_sessions: 'Externe Sitzungen anzeigen',
settings_label_sync_insights: 'Mit Insights synchronisieren',
settings_label_check_updates: 'Nach Updates suchen',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: 'Assistenten-Name',
settings_label_password: 'Zugangspasswort',
settings_saved: 'Einstellungen gespeichert',
@@ -5960,6 +6055,7 @@ const LOCALES = {
settings_desc_external_sessions: 'Zeigt Gespräche von CLI, Telegram, Discord, Slack und anderen Kanälen in der Sitzungsliste an. Klicken zum Importieren und Fortsetzen.',
settings_desc_sync_insights: 'Spiegelt den WebUI-Token-Verbrauch in die state.db, sodass hermes /insights Browser-Sitzungsdaten enthält. Standardmäßig aus.',
settings_desc_check_updates: 'Zeigt ein Banner an, wenn neuere Versionen der WebUI oder des Agenten verfügbar sind. Führt regelmäßig einen Git-Fetch im Hintergrund aus.',
settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.",
settings_desc_bot_name: 'Anzeigename für den Assistenten in der UI. Standardmäßig Hermes.',
settings_desc_password: 'Geben Sie ein neues Passwort ein, um es zu setzen oder zu ändern. Leer lassen, um die aktuelle Einstellung beizubehalten.',
password_placeholder: 'Neues Passwort eingeben…',
@@ -6616,6 +6712,20 @@ const LOCALES = {
mcp_tools_load_failed: '加载 MCP 工具失败。',
mcp_tools_schema_empty: '无参数。',
mcp_tools_runtime_note: '工具清单仅使用已知的活跃 MCP 运行时数据;WebUI 不会启动或探测服务器。',
mcp_tools_summary_no_matches: (query,total) => `No MCP tools matching “${query}” (${total} total MCP tools).`,
mcp_tools_summary_none: 'No MCP tools to show.',
mcp_tools_summary_matching: (query) => ` matching “${query}`,
mcp_tools_summary_total_note: (total) => ` (${total} total MCP tools)`,
mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `Showing ${start}-${end} of ${filtered} MCP tools${searchNote}${totalNote}. Page ${page} of ${pages}.`,
mcp_tools_page_size_prefix: 'Show',
mcp_tools_page_size_suffix: 'per page',
mcp_tools_per_page_aria: 'MCP tools per page',
mcp_tools_inactive_configured_servers: (servers) => `Configured but inactive in this WebUI runtime: ${servers}.`,
mcp_tools_pagination_label: 'MCP tools pagination',
mcp_tools_previous_page: ' Prev',
mcp_tools_previous_page_aria: 'Previous MCP tools page',
mcp_tools_next_page: 'Next ',
mcp_tools_next_page_aria: 'Next MCP tools page',
thinking: '思考过程',
expand_all: '全部展开',
collapse_all: '全部折叠',
@@ -6862,6 +6972,7 @@ const LOCALES = {
settings_label_external_sessions: '显示外部会话',
settings_label_sync_insights: '同步到 insights',
settings_label_check_updates: '检查更新',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: '助手名称',
settings_label_password: '访问密码',
settings_saved: '设置已保存',
@@ -7077,6 +7188,7 @@ const LOCALES = {
settings_desc_external_sessions: '在会话列表中显示来自 CLI、Telegram、Discord、Slack 等渠道的对话。点击可导入并继续对话。',
settings_desc_sync_insights: '将 WebUI token 使用情况同步到 state.db,使 hermes /insights 包含浏览器会话数据。默认关闭。',
settings_desc_check_updates: '当有更新的 WebUI 或助手版本时显示横幅。会在后台定期执行 git fetch。',
settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.",
settings_desc_bot_name: '助手在 UI 中的显示名称。默认为 Hermes。',
settings_desc_password: '输入新密码以设置或更改。留空保持当前设置。',
// onboarding
@@ -7662,6 +7774,20 @@ const LOCALES = {
mcp_tools_load_failed: 'Failed to load MCP tools.',
mcp_tools_schema_empty: 'No schema parameters.',
mcp_tools_runtime_note: 'Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.',
mcp_tools_summary_no_matches: (query,total) => `No MCP tools matching “${query}” (${total} total MCP tools).`,
mcp_tools_summary_none: 'No MCP tools to show.',
mcp_tools_summary_matching: (query) => ` matching “${query}`,
mcp_tools_summary_total_note: (total) => ` (${total} total MCP tools)`,
mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `Showing ${start}-${end} of ${filtered} MCP tools${searchNote}${totalNote}. Page ${page} of ${pages}.`,
mcp_tools_page_size_prefix: 'Show',
mcp_tools_page_size_suffix: 'per page',
mcp_tools_per_page_aria: 'MCP tools per page',
mcp_tools_inactive_configured_servers: (servers) => `Configured but inactive in this WebUI runtime: ${servers}.`,
mcp_tools_pagination_label: 'MCP tools pagination',
mcp_tools_previous_page: ' Prev',
mcp_tools_previous_page_aria: 'Previous MCP tools page',
mcp_tools_next_page: 'Next ',
mcp_tools_next_page_aria: 'Next MCP tools page',
thinking: '\u601d\u8003\u904e\u7a0b',
expand_all: '\u5168\u90e8\u5c55\u958b',
collapse_all: '\u5168\u90e8\u6298\u758a',
@@ -7927,6 +8053,7 @@ const LOCALES = {
settings_label_external_sessions: '顯示外部會話',
settings_label_sync_insights: '\u540c\u6b65\u5230 insights',
settings_label_check_updates: '\u6aa2\u67e5\u66f4\u65b0',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: '\u52a9\u624b\u540d\u7a31',
settings_label_password: '\u8a2a\u554f\u5bc6\u78bc',
settings_saved: '\u8a2d\u5b9a\u5df2\u5132\u5b58',
@@ -8102,6 +8229,7 @@ const LOCALES = {
settings_desc_external_sessions: '在會話列表中顯示來自 CLI、Telegram、Discord、Slack 等管道的對話。點擊可導入並繼續對話。',
settings_desc_sync_insights: '將 WebUI token 使用情況同步到 state.db,使 hermes /insights 包含瀏覽器會話數據。預設未啟用。',
settings_desc_check_updates: '當有更新的 WebUI 或助手版本時顯示標記。將在後台正常執行 Git-Fetch。',
settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.",
settings_desc_bot_name: '助手在 UI 中的顯示名稱。預設未更改。',
settings_desc_password: '\u8a2d\u5b9a WebUI \u767b\u5165\u5bc6\u78bc\u3002\u5047\u5982\u5df2\u8a2d\u7f6e\uff0c\u6bcf\u6b21\u52a0\u8f09\u90fd\u9700\u8981\u767b\u5165\u3002',
onboarding_password_will_enable: '\u5c07\u6703\u555f\u7528',
@@ -9143,6 +9271,7 @@ const LOCALES = {
settings_label_external_sessions: 'Mostrar sessões externas',
settings_label_sync_insights: 'Sincronizar para insights',
settings_label_check_updates: 'Verificar atualizações',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: 'Nome do Assistente',
settings_label_password: 'Senha de Acesso',
settings_saved: 'Configurações salvas',
@@ -9324,6 +9453,7 @@ const LOCALES = {
settings_desc_external_sessions: 'Mostrar conversas de CLI, Telegram, Discord, Slack e outros canais na lista de sessões. Clique para importar e continuar.',
settings_desc_sync_insights: 'Espelha uso de tokens para state.db.',
settings_desc_check_updates: 'Mostrar banner quando versões mais novas estiverem disponíveis.',
settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.",
settings_desc_bot_name: 'Nome de exibição do assistente. Padrão: Hermes.',
settings_desc_password: 'Digite nova senha para definir ou trocar. Deixe em branco para manter.',
password_placeholder: 'Digite nova senha…',
@@ -9764,6 +9894,20 @@ const LOCALES = {
mcp_tools_load_failed: 'Failed to load MCP tools.',
mcp_tools_schema_empty: 'No schema parameters.',
mcp_tools_runtime_note: 'Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.',
mcp_tools_summary_no_matches: (query,total) => `No MCP tools matching “${query}” (${total} total MCP tools).`,
mcp_tools_summary_none: 'No MCP tools to show.',
mcp_tools_summary_matching: (query) => ` matching “${query}`,
mcp_tools_summary_total_note: (total) => ` (${total} total MCP tools)`,
mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `Showing ${start}-${end} of ${filtered} MCP tools${searchNote}${totalNote}. Page ${page} of ${pages}.`,
mcp_tools_page_size_prefix: 'Show',
mcp_tools_page_size_suffix: 'per page',
mcp_tools_per_page_aria: 'MCP tools per page',
mcp_tools_inactive_configured_servers: (servers) => `Configured but inactive in this WebUI runtime: ${servers}.`,
mcp_tools_pagination_label: 'MCP tools pagination',
mcp_tools_previous_page: ' Prev',
mcp_tools_previous_page_aria: 'Previous MCP tools page',
mcp_tools_next_page: 'Next ',
mcp_tools_next_page_aria: 'Next MCP tools page',
thinking: '생각 중',
expand_all: '모두 펼치기',
collapse_all: '모두 접기',
@@ -10164,6 +10308,7 @@ const LOCALES = {
settings_label_external_sessions: '외부 세션 표시',
settings_label_sync_insights: 'Insights에 동기화',
settings_label_check_updates: '업데이트 확인',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: 'Assistant 이름',
settings_label_password: '접근 비밀번호',
settings_saved: '설정 저장됨',
@@ -10344,6 +10489,7 @@ const LOCALES = {
settings_desc_external_sessions: 'CLI, Telegram, Discord, Slack 및 기타 채널의 대화를 세션 목록에 표시합니다. 클릭하여 가져오고 계속하세요.',
settings_desc_sync_insights: 'WebUI 토큰 사용량을 state.db에 반영하여 hermes /insights에 브라우저 세션 데이터가 포함되도록 합니다. 기본값은 꺼짐입니다.',
settings_desc_check_updates: 'WebUI 또는 Agent의 새 버전이 있으면 배너를 표시합니다. 백그라운드에서 주기적으로 git fetch를 실행합니다.',
settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.",
settings_desc_bot_name: 'UI 전체에 표시되는 Assistant 이름입니다. 기본값은 Hermes입니다.',
settings_desc_password: '새 비밀번호를 설정하거나 변경하려면 입력하세요. 현재 설정을 유지하려면 비워 두세요.',
password_placeholder: '새 비밀번호 입력…',
@@ -10878,6 +11024,20 @@ const LOCALES = {
mcp_tools_load_failed: 'Échec du chargement des outils MCP.',
mcp_tools_schema_empty: 'Aucun paramètre de schéma.',
mcp_tools_runtime_note: 'L\'inventaire des outils utilise uniquement les données d\'exécution MCP actives déjà connues ; le WebUI ne démarre pas et ne sonde pas les serveurs.',
mcp_tools_summary_no_matches: (query,total) => `No MCP tools matching “${query}” (${total} total MCP tools).`,
mcp_tools_summary_none: 'No MCP tools to show.',
mcp_tools_summary_matching: (query) => ` matching “${query}`,
mcp_tools_summary_total_note: (total) => ` (${total} total MCP tools)`,
mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `Showing ${start}-${end} of ${filtered} MCP tools${searchNote}${totalNote}. Page ${page} of ${pages}.`,
mcp_tools_page_size_prefix: 'Show',
mcp_tools_page_size_suffix: 'per page',
mcp_tools_per_page_aria: 'MCP tools per page',
mcp_tools_inactive_configured_servers: (servers) => `Configured but inactive in this WebUI runtime: ${servers}.`,
mcp_tools_pagination_label: 'MCP tools pagination',
mcp_tools_previous_page: ' Prev',
mcp_tools_previous_page_aria: 'Previous MCP tools page',
mcp_tools_next_page: 'Next ',
mcp_tools_next_page_aria: 'Next MCP tools page',
pdf_loading: 'Chargement du PDF {0}…',
pdf_too_large: 'PDF trop volumineux pour un aperçu en ligne',
pdf_no_pages: 'Le PDF n\'a pas de pages',
@@ -11201,6 +11361,7 @@ const LOCALES = {
settings_label_external_sessions: 'Afficher les sessions non-WebUI',
settings_label_sync_insights: 'Synchroniser avec les insights',
settings_label_check_updates: 'Vérifier les mises à jour',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: 'Nom de l\'assistant',
settings_label_password: 'Mot de passe d\'accès',
settings_saved: 'Paramètres enregistrés',
@@ -11391,6 +11552,7 @@ const LOCALES = {
settings_desc_external_sessions: 'Affichez les conversations de CLI, Telegram, Discord, Slack et d\'autres chaînes dans la liste des sessions. Cliquez pour importer et continuer.',
settings_desc_sync_insights: 'Met en miroir l\'utilisation du jeton WebUI dans state.db afin que Hermes /insights inclut les données de session du navigateur. Désactivé par défaut.',
settings_desc_check_updates: 'Afficher une bannière lorsque des versions plus récentes de WebUI ou de l\'agent sont disponibles. Exécute périodiquement une récupération git en arrière-plan.',
settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.",
settings_desc_bot_name: 'Nom daffichage de lassistant dans linterface utilisateur. Par défaut, Hermès.',
settings_desc_password: 'Saisissez un nouveau mot de passe pour le définir ou le modifier. Laissez vide pour conserver le paramètre actuel.',
password_placeholder: 'Entrez le nouveau mot de passe…',
+15 -2
View File
@@ -342,7 +342,11 @@
<div class="update-banner" id="updateBanner">
<div style="display:flex;flex-direction:column;flex:1;min-width:0">
<span id="updateMsg"></span>
<a id="updateWhatsNew" href="#" target="_blank" rel="noopener" style="font-size:11px;color:var(--accent);text-decoration:underline;display:none;margin-left:8px;white-space:nowrap">What's new?</a>
<div id="updateWhatsNewLinks" style="display:none;font-size:11px;margin-left:8px;white-space:nowrap"></div>
<div id="updateSummaryPanel" style="display:none;font-size:12px;line-height:1.45;margin-top:6px;padding:8px 10px;border:1px solid var(--border2);border-radius:8px;background:rgba(255,255,255,.04);max-width:720px;white-space:normal">
<div id="updateSummaryText"></div>
<div id="updateSummaryDiffLinks" style="display:none;font-size:11px;margin-top:8px"></div>
</div>
<div id="updateError" style="display:none;font-size:12px;color:var(--error,#e05);margin-top:4px;word-break:break-word"></div>
</div>
<div style="display:flex;gap:8px;flex-shrink:0;flex-wrap:wrap">
@@ -1051,6 +1055,13 @@
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_check_updates">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsWhatsNewSummary" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_whats_new_summary">Summarize What's New with AI</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_whats_new_summary">Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.</div>
</div>
<div class="settings-field">
<label for="settingsBotName" data-i18n="settings_label_bot_name">Assistant Name</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_bot_name">Display name for the assistant throughout the UI. Defaults to Hermes.</div>
@@ -1138,7 +1149,9 @@
<label data-i18n="mcp_tools_title">MCP Tools</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px" data-i18n="mcp_tools_desc">Search known tools across active MCP servers.</div>
<input type="search" id="mcpToolSearch" class="mcp-tool-search" data-i18n-placeholder="mcp_tools_search_placeholder" placeholder="Search tools by name, server, or description…" oninput="filterMcpTools()" autocomplete="off">
<div id="mcpToolList"></div>
<div class="mcp-tool-toolbar" id="mcpToolToolbar" aria-live="polite"></div>
<div id="mcpToolList" class="mcp-tool-list"></div>
<div id="mcpToolPager" class="mcp-tool-pager" aria-label="MCP tools pagination" data-i18n-aria-label="mcp_tools_pagination_label"></div>
<div class="mcp-restart-hint" data-i18n="mcp_tools_runtime_note">Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.</div>
</div>
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
+94 -10
View File
@@ -425,11 +425,13 @@ async function loadCrons(animate) {
item.id = 'cron-' + job.id;
const status = _cronStatusMeta(job);
const isNewRun = _cronNewJobIds.has(String(job.id));
const isAgentMode = !job.no_agent;
const profileLabel = _cronProfileLabel(job.profile);
const profileTitle = _cronProfileTitle(job.profile);
item.innerHTML = `
<div class="cron-header">
${isNewRun ? '<span class="cron-new-dot" title="New run"></span>' : ''}
${isAgentMode ? '<span class="cron-agent-badge" title="Agent mode">🤖</span>' : ''}
<span class="cron-name" title="${esc(job.name)}">${esc(job.name)}</span>
<span class="cron-profile-badge" title="${esc(profileTitle)}">${esc(profileLabel)}</span>
<span class="cron-status ${status.listClass}">${esc(status.label)}</span>
@@ -468,6 +470,11 @@ function _renderCronDetail(job){
const deliver = job.deliver || 'local';
const isNoAgent = !!job.no_agent;
const cronJobMode = isNoAgent ? 'no-agent' : 'agent';
const modelProvider =
job.provider && job.model ? `${esc(job.provider)}/${esc(job.model)}` :
job.model ? esc(job.model) :
job.provider ? esc(job.provider) :
isNoAgent ? '' : 'default';
const script = job.script || '';
const profileLabel = _cronProfileLabel(job.profile);
const profileTitle = _cronProfileTitle(job.profile);
@@ -498,7 +505,7 @@ function _renderCronDetail(job){
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_next'))}</div><div class="detail-row-value">${esc(nextRun)}</div></div>
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_last'))}</div><div class="detail-row-value">${esc(lastRun)}</div></div>
<div class="detail-row"><div class="detail-row-label">Deliver</div><div class="detail-row-value">${esc(deliver)}</div></div>
<div class="detail-row"><div class="detail-row-label">Mode</div><div class="detail-row-value"><span class="detail-badge" id="cronJobMode">${esc(cronJobMode)}</span></div></div>
<div class="detail-row"><div class="detail-row-label">Mode</div><div class="detail-row-value"><span class="detail-badge" id="cronJobMode">${esc(cronJobMode)}</span>${modelProvider ? ` <code>${modelProvider}</code>` : ''}</div></div>
${isNoAgent ? `<div class="detail-row"><div class="detail-row-label">No-agent script</div><div class="detail-row-value"><code>${esc(script || '—')}</code></div></div>` : ''}
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_profile_label') || 'Profile')}</div><div class="detail-row-value"><span class="detail-badge active" title="${esc(profileTitle)}">${esc(profileLabel)}</span></div></div>
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_toast_notifications_label') || 'Completion toasts')}</div><div class="detail-row-value"><span class="detail-badge ${toastNotifications ? 'active' : ''}">${esc(toastNotifications ? (t('cron_toast_notifications_enabled') || 'Enabled') : (t('cron_toast_notifications_disabled') || 'Disabled'))}</span></div></div>
@@ -5070,6 +5077,8 @@ function _preferencesPayloadFromUi(){
if(syncCb) payload.sync_to_insights=syncCb.checked;
const updateCb=$('settingsCheckUpdates');
if(updateCb) payload.check_for_updates=updateCb.checked;
const whatsNewSummaryCb=$('settingsWhatsNewSummary');
if(whatsNewSummaryCb) payload.whats_new_summary_enabled=whatsNewSummaryCb.checked;
const soundCb=$('settingsSoundEnabled');
if(soundCb) payload.sound_enabled=soundCb.checked;
const notifCb=$('settingsNotificationsEnabled');
@@ -5303,6 +5312,8 @@ async function loadSettingsPanel(){
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const updateCb=$('settingsCheckUpdates');
if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const whatsNewSummaryCb=$('settingsWhatsNewSummary');
if(whatsNewSummaryCb){whatsNewSummaryCb.checked=!!settings.whats_new_summary_enabled;whatsNewSummaryCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const soundCb=$('settingsSoundEnabled');
if(soundCb){soundCb.checked=!!settings.sound_enabled;soundCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
// TTS settings (localStorage-only, no server round-trip needed)
@@ -5935,6 +5946,7 @@ function _applySavedSettingsUi(saved, body, opts){
window._showCliSessions=showCliSessions;
window._soundEnabled=body.sound_enabled;
window._notificationsEnabled=body.notifications_enabled;
window._whatsNewSummaryEnabled=!!body.whats_new_summary_enabled;
window._showThinking=body.show_thinking!==false;
window._simplifiedToolCalling=body.simplified_tool_calling!==false;
window._sessionJumpButtonsEnabled=!!body.session_jump_buttons;
@@ -5996,6 +6008,7 @@ async function checkUpdatesNow(){
if(typeof _showUpdateBanner==='function') _showUpdateBanner(data);
} else {
if(status){status.textContent=t('settings_up_to_date');status.style.color='var(--success)';}
if(typeof _showUpdateBanner==='function') _showUpdateBanner(data);
}
}
} catch(e){
@@ -6047,6 +6060,7 @@ async function saveSettings(andClose){
body.show_cli_sessions=showCliSessions;
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
body.whats_new_summary_enabled=!!($('settingsWhatsNewSummary')||{}).checked;
body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked;
body.notifications_enabled=!!($('settingsNotificationsEnabled')||{}).checked;
body.show_thinking=window._showThinking!==false;
@@ -6285,6 +6299,10 @@ function loadMcpServers(){
}).catch(()=>{list.innerHTML=`<div class="mcp-error-state" style="color:#ef4444;font-size:12px;padding:6px 0">${esc(t('mcp_load_failed'))}</div>`});
}
let _mcpToolsCache=[];
let _mcpToolsMeta={};
let _mcpToolsPage=1;
let _mcpToolsPageSize=5;
const MCP_TOOLS_PAGE_SIZE_OPTIONS=[5,10,20,40];
function _filterMcpToolsForSearch(tools, query){
const q=(query||'').trim().toLowerCase();
if(!q) return Array.isArray(tools)?tools:[];
@@ -6301,16 +6319,56 @@ function _mcpToolSchemaText(schemaSummary){
return `${p.name}${req}: ${p.type||'unknown'}${desc}`;
}).join('\n');
}
function _renderMcpTools(tools, query){
const list=$('mcpToolList');
if(!list) return;
const filtered=_filterMcpToolsForSearch(tools, query);
if(!filtered.length){
const key=query?'mcp_tools_no_matches':'mcp_tools_no_tools';
list.innerHTML=`<div class="mcp-tool-empty-state" style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t(key))}</div>`;
function _mcpToolsSummary(total, filtered, page, pages, query){
const trimmedQuery=(query||'').trim();
if(!filtered){
if(trimmedQuery) return t('mcp_tools_summary_no_matches',trimmedQuery,total);
return total?t('mcp_tools_summary_none'):'';
}
const pageSize=_mcpToolsPageSize||5;
const start=(page-1)*pageSize+1;
const end=Math.min(filtered,page*pageSize);
const searchNote=trimmedQuery?t('mcp_tools_summary_matching',trimmedQuery):'';
const totalNote=filtered===total?'':t('mcp_tools_summary_total_note',total);
return t('mcp_tools_summary_showing',start,end,filtered,searchNote,totalNote,page,pages);
}
function _mcpToolPageSizeControl(){
const options=MCP_TOOLS_PAGE_SIZE_OPTIONS.map(size=>`<option value="${size}" ${size===_mcpToolsPageSize?'selected':''}>${size}</option>`).join('');
return `<label class="mcp-tool-page-size">${esc(t('mcp_tools_page_size_prefix'))} <select aria-label="${esc(t('mcp_tools_per_page_aria'))}" onchange="setMcpToolsPageSize(this.value)">${options}</select> ${esc(t('mcp_tools_page_size_suffix'))}</label>`;
}
function _mcpToolsEmptyMessage(query){
const base=esc(t(query?'mcp_tools_no_matches':'mcp_tools_no_tools'));
const unavailable=Array.isArray(_mcpToolsMeta.unavailable_servers)?_mcpToolsMeta.unavailable_servers:[];
if(query||!unavailable.length) return base;
return `${base}<br><span class="mcp-tool-empty-detail">${esc(t('mcp_tools_inactive_configured_servers',unavailable.join(', ')))}</span>`;
}
function _renderMcpToolPager(filteredCount, page, pages){
const pager=$('mcpToolPager');
if(!pager) return;
if(pages<=1){
pager.innerHTML='';
return;
}
list.innerHTML=filtered.map(tool=>{
pager.innerHTML=`<button type="button" class="mcp-tool-page-btn" onclick="setMcpToolsPage(${page-1})" ${page<=1?'disabled':''} aria-label="${esc(t('mcp_tools_previous_page_aria'))}">${esc(t('mcp_tools_previous_page'))}</button>
<span class="mcp-tool-page-label">${page} / ${pages}</span>
<button type="button" class="mcp-tool-page-btn" onclick="setMcpToolsPage(${page+1})" ${page>=pages?'disabled':''} aria-label="${esc(t('mcp_tools_next_page_aria'))}">${esc(t('mcp_tools_next_page'))}</button>`;
}
function _renderMcpTools(tools, query){
const list=$('mcpToolList');
const toolbar=$('mcpToolToolbar');
if(!list) return;
const filtered=_filterMcpToolsForSearch(tools, query);
const total=Array.isArray(tools)?tools.length:0;
const pages=Math.max(1,Math.ceil(filtered.length/_mcpToolsPageSize));
_mcpToolsPage=Math.min(Math.max(1,_mcpToolsPage||1),pages);
if(toolbar) toolbar.innerHTML=`<span class="mcp-tool-summary">${esc(_mcpToolsSummary(total,filtered.length,_mcpToolsPage,pages,query))}</span>${_mcpToolPageSizeControl()}`;
_renderMcpToolPager(filtered.length,_mcpToolsPage,pages);
if(!filtered.length){
list.innerHTML=`<div class="mcp-tool-empty-state" style="color:var(--muted);font-size:12px;padding:6px 0">${_mcpToolsEmptyMessage(query)}</div>`;
return;
}
const visible=filtered.slice((_mcpToolsPage-1)*_mcpToolsPageSize,_mcpToolsPage*_mcpToolsPageSize);
list.innerHTML=visible.map(tool=>{
const status=tool.status||'unknown';
const statusBadge=`<span class="mcp-status-badge mcp-status-${esc(status)}">${esc(_mcpStatusLabel(status))}</span>`;
const schemaText=_mcpToolSchemaText(tool.schema_summary);
@@ -6325,16 +6383,42 @@ function _renderMcpTools(tools, query){
</div>`;
}).join('');
}
function filterMcpTools(){
function setMcpToolsPage(page){
_mcpToolsPage=page;
const input=$('mcpToolSearch');
_renderMcpTools(_mcpToolsCache,input?input.value:'');
const list=$('mcpToolList');
if(list) list.scrollTop=0;
}
function setMcpToolsPageSize(size){
const next=Number(size);
if(!MCP_TOOLS_PAGE_SIZE_OPTIONS.includes(next)) return;
_mcpToolsPageSize=next;
_mcpToolsPage=1;
const input=$('mcpToolSearch');
_renderMcpTools(_mcpToolsCache,input?input.value:'');
const list=$('mcpToolList');
if(list) list.scrollTop=0;
}
function filterMcpTools(){
_mcpToolsPage=1;
const input=$('mcpToolSearch');
_renderMcpTools(_mcpToolsCache,input?input.value:'');
const list=$('mcpToolList');
if(list) list.scrollTop=0;
}
function loadMcpTools(){
const list=$('mcpToolList');
const toolbar=$('mcpToolToolbar');
const pager=$('mcpToolPager');
if(!list) return;
if(toolbar) toolbar.textContent='';
if(pager) pager.innerHTML='';
list.innerHTML=`<div style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t('loading'))}</div>`;
api('/api/mcp/tools').then(r=>{
_mcpToolsCache=(r&&Array.isArray(r.tools))?r.tools:[];
_mcpToolsMeta=r||{};
_mcpToolsPage=1;
filterMcpTools();
}).catch(()=>{list.innerHTML=`<div class="mcp-tool-error-state" style="color:#ef4444;font-size:12px;padding:6px 0">${esc(t('mcp_tools_load_failed'))}</div>`});
}
+17 -5
View File
@@ -1891,12 +1891,12 @@ body.resizing .sidebar{transition:none!important;}
color:var(--muted);
}
@keyframes _tool-shimmer-sweep{
0%{-webkit-mask-position:100% 0;mask-position:100% 0;}
100%{-webkit-mask-position:-200% 0;mask-position:-200% 0;}
0%{-webkit-mask-position:150% 0;mask-position:150% 0;}
100%{-webkit-mask-position:-150% 0;mask-position:-150% 0;}
}
.tool-call-group[data-live-tool-call-group="1"] .tool-call-group-label::after{
--activity-sweep-highlight:linear-gradient(90deg,var(--accent) 0%,var(--accent) 45.2%,color-mix(in srgb,var(--accent) 90%,#000) 46.5%,color-mix(in srgb,var(--accent) 90%,#000) 53.5%,var(--accent) 55%,var(--accent) 100%);
--activity-sweep-mask:linear-gradient(90deg,rgba(0,0,0,0) 0%,rgba(0,0,0,0) 38%,rgba(0,0,0,.18) 40.8%,rgba(0,0,0,.46) 43.6%,rgba(0,0,0,.72) 46.5%,rgba(0,0,0,.9) 53.5%,rgba(0,0,0,.52) 55.8%,rgba(0,0,0,.28) 58.2%,rgba(0,0,0,.1) 60.4%,rgba(0,0,0,0) 62%,rgba(0,0,0,0) 100%);
--activity-sweep-highlight:linear-gradient(90deg,var(--accent) 0%,var(--accent) 40.4%,color-mix(in srgb,var(--accent) 90%,#000) 43%,color-mix(in srgb,var(--accent) 90%,#000) 57%,var(--accent) 60%,var(--accent) 100%);
--activity-sweep-mask:linear-gradient(90deg,rgba(0,0,0,0) 0%,rgba(0,0,0,0) 26%,rgba(0,0,0,.18) 31.6%,rgba(0,0,0,.46) 37.2%,rgba(0,0,0,.72) 43%,rgba(0,0,0,.9) 57%,rgba(0,0,0,.52) 61.6%,rgba(0,0,0,.28) 66.4%,rgba(0,0,0,.1) 70.8%,rgba(0,0,0,0) 74%,rgba(0,0,0,0) 100%);
content:attr(data-sweep-label);
position:absolute;inset:0;
color:var(--accent);
@@ -1911,7 +1911,7 @@ body.resizing .sidebar{transition:none!important;}
mask-size:250% 100%;
-webkit-mask-repeat:no-repeat;
mask-repeat:no-repeat;
animation:_tool-shimmer-sweep 3s cubic-bezier(.45,0,.55,1) infinite;
animation:_tool-shimmer-sweep 3.0s cubic-bezier(.45,0,.55,1) infinite;
}
@media (prefers-reduced-motion: reduce){
.tool-call-group[data-live-tool-call-group="1"] .tool-call-group-label::after{
@@ -2493,10 +2493,21 @@ main.main.showing-logs > #mainLogs{display:flex;}
.mcp-readonly-note,.mcp-restart-hint{margin-top:8px;color:var(--muted);font-size:11px;line-height:1.45;background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;padding:8px 10px;}
.mcp-tool-search{width:100%;margin:0 0 8px 0;padding:8px 10px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:8px;font-size:12px;outline:none;}
.mcp-tool-search:focus{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-bg-soft);}
.mcp-tool-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0 0 8px;color:var(--muted);font-size:11px;line-height:1.35;flex-wrap:wrap;}
.mcp-tool-summary{min-width:0;}
.mcp-tool-page-size{display:inline-flex;align-items:center;gap:5px;white-space:nowrap;color:var(--muted);font-size:11px;}
.mcp-tool-page-size select{appearance:none;border:1px solid var(--border2);background:var(--code-bg);color:var(--text);border-radius:7px;padding:4px 22px 4px 8px;font-size:11px;font-weight:600;line-height:1.2;cursor:pointer;}
.mcp-tool-empty-detail{display:inline-block;margin-top:4px;color:var(--muted);font-size:11px;line-height:1.35;}
.mcp-tool-list{max-height:min(52vh,560px);overflow:auto;padding-right:3px;scrollbar-gutter:stable;}
.mcp-tool-row{display:flex;flex-direction:column;gap:5px;padding:9px 10px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;font-size:12px;background:var(--surface);}
.mcp-tool-name{font-weight:600;color:var(--text);overflow-wrap:anywhere;}
.mcp-tool-server{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);background:var(--code-bg);border:1px solid var(--border2);border-radius:999px;padding:2px 6px;}
.mcp-tool-schema{margin:2px 0 0 0;padding:7px 8px;white-space:pre-wrap;max-height:140px;overflow:auto;background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;color:var(--muted);font-size:11px;line-height:1.45;}
.mcp-tool-pager{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:8px;}
.mcp-tool-page-label{color:var(--muted);font-size:11px;min-width:44px;text-align:center;}
.mcp-tool-page-btn{border:1px solid var(--border2);background:var(--code-bg);color:var(--text);border-radius:7px;padding:5px 9px;font-size:11px;font-weight:600;cursor:pointer;}
.mcp-tool-page-btn:hover:not(:disabled){border-color:var(--accent-bg-strong);color:var(--accent-text);}
.mcp-tool-page-btn:disabled{opacity:.45;cursor:not-allowed;}
/* Picker grids (theme / skin / font-size): make the card chrome use
tokens so all skins flip correctly. */
@@ -2742,6 +2753,7 @@ main.main.showing-logs > #mainLogs{display:flex;}
/* ── Cron alert badge ── */
.cron-badge{position:absolute;top:2px;right:2px;background:#e53e3e;color:#fff;font-size:9px;font-weight:700;min-width:14px;height:14px;line-height:14px;text-align:center;border-radius:7px;padding:0 3px;}
.cron-new-dot{width:7px;height:7px;border-radius:50%;background:var(--success,#22c55e);flex-shrink:0;animation:cron-dot-pulse 2s ease-in-out infinite;}
.cron-agent-badge{flex-shrink:0;font-size:12px;line-height:1;}
@keyframes cron-dot-pulse{0%,100%{opacity:1;}50%{opacity:.4;}}
.has-new-run{border-color:var(--success,#22c55e)!important;box-shadow:0 0 0 1px var(--success,#22c55e);}
+243 -29
View File
@@ -3878,38 +3878,252 @@ function _formatUpdateTargetStatus(label,info){
const branch=info.branch?` (${info.branch})`:'';
return `${label}${branch}: ${info.behind} update${info.behind>1?'s':''}`;
}
function _isSafeUpdateCompareUrl(url){
if(!url||!/^https?:\/\//i.test(url)) return false;
try{
const parsed=new URL(url);
return parsed.protocol==='https:'||parsed.protocol==='http:';
}catch(e){
return false;
}
}
function _updateCompareUrl(info){
if(!info) return null;
const compareUrl=info.compare_url||null;
if(compareUrl) return _isSafeUpdateCompareUrl(compareUrl)?compareUrl:null;
const repo_url=info.repo_url;
const currentSha=info.current_sha;
const latestSha=info.latest_sha;
if(!(repo_url&&currentSha&&latestSha)) return null;
const fallbackUrl=repo_url+'/compare/'+currentSha+'...'+latestSha;
return _isSafeUpdateCompareUrl(fallbackUrl)?fallbackUrl:null;
}
function _updateWhatsNewTargets(data){
const targets=[
{key:'webui',label:'WebUI',info:data&&data.webui},
{key:'agent',label:'Agent',info:data&&data.agent},
];
return targets.map((target)=>({
key:target.key,
label:target.label,
info:target.info,
url:_updateCompareUrl(target.info),
})).filter((target)=>target.info&&target.info.behind>0&&target.url);
}
function _appendUpdateDiffLinks(container,targets,prefix){
if(!container) return;
if(prefix) container.appendChild(document.createTextNode(prefix));
targets.forEach((target,idx)=>{
if(idx>0) container.appendChild(document.createTextNode(' \u00b7 '));
const link=document.createElement('a');
link.href=target.url;
link.target='_blank';
link.rel='noopener';
link.style.color='var(--accent)';
link.style.textDecoration='underline';
link.textContent=target.label;
container.appendChild(link);
});
}
function _hideUpdateSummaryPanel(){
const panel=$('updateSummaryPanel');
const text=$('updateSummaryText');
const links=$('updateSummaryDiffLinks');
if(panel) panel.style.display='none';
if(text) text.textContent='';
if(links){links.replaceChildren();links.style.display='none';}
}
const WHATS_NEW_SUMMARY_STORAGE_KEY='hermes-whats-new-generated-summaries';
function _loadStoredUpdateSummaries(){
window._whatsNewGeneratedSummaries=window._whatsNewGeneratedSummaries||{};
try{
const raw=sessionStorage.getItem(WHATS_NEW_SUMMARY_STORAGE_KEY);
if(!raw) return window._whatsNewGeneratedSummaries;
const stored=JSON.parse(raw);
if(stored&&typeof stored==='object') window._whatsNewGeneratedSummaries=stored;
}catch(_e){
try{sessionStorage.removeItem(WHATS_NEW_SUMMARY_STORAGE_KEY);}catch(_ignore){}
}
return window._whatsNewGeneratedSummaries;
}
function _persistGeneratedSummaries(){
try{sessionStorage.setItem(WHATS_NEW_SUMMARY_STORAGE_KEY,JSON.stringify(window._whatsNewGeneratedSummaries||{}));}catch(_e){}
}
function _pruneGeneratedSummaries(data){
const cache=_loadStoredUpdateSummaries();
const valid=new Set(_updateWhatsNewTargets(data||{}).map((target)=>target.key));
let changed=false;
Object.keys(cache).forEach((key)=>{
if(!valid.has(key)){delete cache[key];changed=true;}
});
if(changed) _persistGeneratedSummaries();
}
function _updateSummarySignature(info){
if(!info) return '';
return [info.current_sha||'',info.latest_sha||'',info.behind||0,info.compare_url||''].join('|');
}
function _updateSummaryButtonLabel(target,data){
const labels=target.key==='webui'
? {generate:'Generate WebUI update summary',view:'View generated WebUI update summary',regenerate:'Re-generate WebUI update summary'}
: {generate:'Generate Agent update summary',view:'View generated Agent update summary',regenerate:'Re-generate Agent update summary'};
const cache=_loadStoredUpdateSummaries()[target.key];
const signature=_updateSummarySignature(data&&data[target.key]);
if(cache&&cache.signature===signature&&cache.payload) return labels.view;
if(cache&&cache.signature!==signature) return labels.regenerate;
return labels.generate;
}
function _rememberGeneratedSummary(target,payload,data){
if(!target) return;
window._whatsNewGeneratedSummaries=window._whatsNewGeneratedSummaries||{};
window._whatsNewGeneratedSummaries[target]={
signature:_updateSummarySignature(data&&data[target]),
payload:payload,
};
_persistGeneratedSummaries();
}
function _renderUpdateSummaryPanel(payload,data,targetKey){
const panel=$('updateSummaryPanel');
const text=$('updateSummaryText');
const links=$('updateSummaryDiffLinks');
if(!panel||!text) return;
panel.style.display='block';
const sections=Array.isArray(payload&&payload.summary_sections)?payload.summary_sections:null;
text.replaceChildren();
if(sections&&sections.length){
const wrap=document.createElement('div');
wrap.id='updateSummarySections';
wrap.style.display='grid';
wrap.style.gap='8px';
sections.forEach((section)=>{
const block=document.createElement('section');
const title=document.createElement('div');
title.style.fontWeight='650';
title.style.marginBottom='3px';
title.textContent=section.title||'Summary';
block.appendChild(title);
const ul=document.createElement('ul');
ul.style.margin='0';
ul.style.paddingLeft='18px';
(Array.isArray(section.items)?section.items:[]).forEach((item)=>{
const li=document.createElement('li');
li.textContent=String(item||'').trim();
if(li.textContent) ul.appendChild(li);
});
if(!ul.children.length){
const li=document.createElement('li');
li.textContent='No summary details available.';
ul.appendChild(li);
}
block.appendChild(ul);
wrap.appendChild(block);
});
text.appendChild(wrap);
}else{
text.textContent=(payload&&payload.summary)||payload||'No summary available.';
}
const targets=_updateWhatsNewTargets(data||window._updateData||{}).filter((target)=>!targetKey||target.key===targetKey);
if(links){
links.replaceChildren();
if(targets.length){
links.style.display='block';
_appendUpdateDiffLinks(links,targets,'Regular diff comparison: ');
}else{
links.style.display='none';
}
}
}
async function showWhatsNewSummary(target){
const data=window._updateData||{};
const scopedUpdates=target?{[target]:data[target]}:data;
const cache=target?_loadStoredUpdateSummaries()[target]:null;
const signature=target?_updateSummarySignature(data[target]):'';
if(cache&&cache.signature===signature&&cache.payload){
_renderUpdateSummaryPanel(cache.payload,data,target);
_renderUpdateWhatsNewLinks(data,{mode:'summary'});
return;
}
_renderUpdateSummaryPanel({summary:'Writing a simple summary…'},data,target);
try{
const res=await api('/api/updates/summary',{method:'POST',body:JSON.stringify({updates:scopedUpdates,target:target||null})});
_rememberGeneratedSummary(target,res,data);
_renderUpdateSummaryPanel(res,data,target);
_renderUpdateWhatsNewLinks(data,{mode:'summary'});
}catch(e){
console.warn('[updates] summary failed',e);
_renderUpdateSummaryPanel({
summary_sections:[
{title:"What you'll notice",items:['Could not generate the summary right now.']},
{title:'Worth knowing',items:['Try again later, or use the comparison links below for the raw update details.']},
],
},data,target);
}
}
function _renderUpdateWhatsNewLinks(data){
const options=arguments.length>1&&arguments[1]?arguments[1]:{};
const container=$('updateWhatsNewLinks');
if(!container) return;
container.replaceChildren();
const targets=_updateWhatsNewTargets(data);
if(!targets.length){
container.style.display='none';
_hideUpdateSummaryPanel();
return;
}
container.style.display='block';
_pruneGeneratedSummaries(data);
const useSummary=(options.mode||'')==='summary'||window._whatsNewSummaryEnabled===true;
if(useSummary){
targets.forEach((target,idx)=>{
if(idx>0) container.appendChild(document.createTextNode(' \u00b7 '));
const btn=document.createElement('button');
btn.type='button';
btn.className='linklike';
btn.style.color='var(--accent)';
btn.style.textDecoration='underline';
btn.style.background='none';
btn.style.border='0';
btn.style.padding='0';
btn.style.cursor='pointer';
btn.textContent=_updateSummaryButtonLabel(target,data);
btn.onclick=()=>showWhatsNewSummary(target.key);
container.appendChild(btn);
});
return;
}
_hideUpdateSummaryPanel();
if(targets.length===1){
const target=targets[0];
const link=document.createElement('a');
link.href=target.url;
link.target='_blank';
link.rel='noopener';
link.style.color='var(--accent)';
link.style.textDecoration='underline';
link.textContent="What's new in "+target.label+'?';
container.appendChild(link);
return;
}
_appendUpdateDiffLinks(container,targets,"What's new: ");
}
function _showUpdateBanner(data){
const parts=[];
const webuiPart=_formatUpdateTargetStatus('WebUI',data.webui);
const agentPart=_formatUpdateTargetStatus('Agent',data.agent);
if(webuiPart) parts.push(webuiPart);
if(agentPart) parts.push(agentPart);
if(!parts.length)return;
window._updateData=data;
if(!parts.length){
_renderUpdateWhatsNewLinks(data);
const staleBanner=$('updateBanner');
if(staleBanner) staleBanner.classList.remove('visible');
return;
}
const msg=$('updateMsg');
if(msg) msg.textContent='\u2B06 '+parts.join(', ')+' available';
const banner=$('updateBanner');
if(banner) banner.classList.add('visible');
window._updateData=data;
// Wire up "What's new?" link.
//
// Reset display:none + clear the href on every render — otherwise a stale
// link from a prior update banner can stay visible after we've moved past
// a state where the new payload no longer carries usable SHAs (#1579 case
// when the local HEAD diverges from upstream and the compare URL would 404).
const link=$('updateWhatsNew');
if(link){
link.style.display='none';
link.removeAttribute('href');
if(data.webui){
const repoUrl=data.webui.repo_url;
const curSha=data.webui.current_sha;
const newSha=data.webui.latest_sha;
if(repoUrl && curSha && newSha){
link.href=repoUrl+'/compare/'+curSha+'...'+newSha;
link.style.display='inline';
}
}
}
const summaryMode=window._whatsNewSummaryEnabled===true?'summary':'diff';
_renderUpdateWhatsNewLinks(data,{mode:summaryMode});
}
function dismissUpdate(){
const b=$('updateBanner');if(b)b.classList.remove('visible');
@@ -4236,7 +4450,7 @@ function _messageHasReasoningPayload(m){
if(!m||m.role!=='assistant') return false;
if(m.reasoning) return true;
if(Array.isArray(m.content)) return m.content.some(p=>p&&(p.type==='thinking'||p.type==='reasoning'));
return /<think>[\s\S]*?<\/think>|<\|channel>thought\n[\s\S]*?<channel\|>|<\|turn\|>thinking\n[\s\S]*?<turn\|>/.test(String(m.content||''));
return /^\s*(?:<think>[\s\S]*?<\/think>|<\|channel\|?>thought\n?[\s\S]*?<channel\|>|<\|turn\|>thinking\n[\s\S]*?<turn\|>)/.test(String(m.content||''));
}
function _formatTurnTps(value){
const n=Number(value);
@@ -5101,25 +5315,25 @@ function renderMessages(options){
}
if(!thinkingText && m.reasoning) thinkingText=m.reasoning;
if(!thinkingText && typeof content==='string'){
const thinkMatch=content.match(/<think>([\s\S]*?)<\/think>/);
const thinkMatch=content.match(/^\s*<think>([\s\S]*?)<\/think>\s*/);
if(thinkMatch){
thinkingText=thinkMatch[1].trim();
content=content.replace(/<think>[\s\S]*?<\/think>\s*/,'').trimStart();
content=content.replace(/^\s*<think>[\s\S]*?<\/think>\s*/,'').trimStart();
}
if(!thinkingText){
// Historical name "gemmaMatch" refers to MiniMax <|channel>thought format.
const gemmaMatch=content.match(/<\|channel>thought\n([\s\S]*?)<channel\|>/);
const gemmaMatch=content.match(/^\s*<\|channel\|?>thought\n?([\s\S]*?)<channel\|>\s*/);
if(gemmaMatch){
thinkingText=gemmaMatch[1].trim();
content=content.replace(/<\|channel>thought\n[\s\S]*?<channel\|>\s*/,'').trimStart();
content=content.replace(/^\s*<\|channel\|?>thought\n?[\s\S]*?<channel\|>\s*/,'').trimStart();
}
}
if(!thinkingText){
// Gemma 4 uses asymmetric <|turn|>thinking\n...<turn|> delimiters.
const gemmaTurnMatch=content.match(/<\|turn\|>thinking\n([\s\S]*?)<turn\|>/);
const gemmaTurnMatch=content.match(/^\s*<\|turn\|>thinking\n([\s\S]*?)<turn\|>\s*/);
if(gemmaTurnMatch){
thinkingText=gemmaTurnMatch[1].trim();
content=content.replace(/<\|turn\|>thinking\n[\s\S]*?<turn\|>\s*/,'').trimStart();
content=content.replace(/^\s*<\|turn\|>thinking\n[\s\S]*?<turn\|>\s*/,'').trimStart();
}
}
}
+83
View File
@@ -86,6 +86,89 @@ def test_claude_code_scan_skips_symlinks_and_oversized_files(tmp_path):
assert models.get_claude_code_sessions(projects_dir=root_link) == []
def test_get_cli_sessions_reuses_short_ttl_cache(monkeypatch, tmp_path):
import api.models as models
import api.profiles as profiles
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: str(hermes_home))
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
monkeypatch.setattr(models, "_CLI_SESSIONS_CACHE_TTL_SECONDS", 60.0, raising=False)
models.clear_cli_sessions_cache()
calls = 0
def fake_claude_code_sessions():
nonlocal calls
calls += 1
return [
{
"session_id": "claude_code_cached",
"title": "Cached Claude Code",
"updated_at": calls,
"message_count": 1,
"source_tag": "claude_code",
"is_cli_session": True,
}
]
monkeypatch.setattr(models, "get_claude_code_sessions", fake_claude_code_sessions)
first = models.get_cli_sessions()
first[0]["title"] = "mutated by caller"
second = models.get_cli_sessions()
assert calls == 1
assert second[0]["title"] == "Cached Claude Code"
assert second[0]["updated_at"] == 1
def test_get_cli_sessions_cache_invalidates_when_sqlite_wal_changes(monkeypatch, tmp_path):
import api.models as models
import api.profiles as profiles
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
db_path = hermes_home / "state.db"
db_path.write_text("initial", encoding="utf-8")
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: str(hermes_home))
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
monkeypatch.setattr(models, "_CLI_SESSIONS_CACHE_TTL_SECONDS", 60.0, raising=False)
monkeypatch.setattr(models, "get_claude_code_sessions", lambda: [])
models.clear_cli_sessions_cache()
calls = 0
def fake_rows(_db_path, **_kwargs):
nonlocal calls
calls += 1
return [
{
"id": "cli_cached_state_db",
"title": "State DB Session",
"model": "test-model",
"source": "cli",
"raw_source": "cli",
"message_count": calls,
"actual_message_count": calls,
"actual_user_message_count": 1,
"last_activity": float(calls),
"started_at": 1.0,
}
]
monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_rows)
first = models.get_cli_sessions()
Path(f"{db_path}-wal").write_text("new wal contents", encoding="utf-8")
second = models.get_cli_sessions()
assert calls == 2
assert first[0]["message_count"] == 1
assert second[0]["message_count"] == 2
def test_session_import_cli_returns_read_only_claude_code_payload(monkeypatch, tmp_path):
import api.routes as routes
+20 -24
View File
@@ -187,39 +187,35 @@ def _read_ui_js():
return (REPO_ROOT / 'static' / 'ui.js').read_text(encoding='utf-8')
def test_whats_new_link_resets_display_and_href_on_every_render():
def test_whats_new_link_resets_display_and_contents_on_every_render():
"""Without reset, a stale link from a prior banner can stay visible after
a re-render where the new payload has current_sha=None.
"""
src = _read_ui_js()
# Find the "What's new" wiring block (~50-line window)
idx = src.find("Wire up \"What's new?\" link")
assert idx != -1, "What's-new link wiring block not found"
block = src[idx:idx + 800]
idx = src.find("function _renderUpdateWhatsNewLinks(data)")
assert idx != -1, "What's-new link renderer not found"
block = src[idx:idx + 1200]
# Reset must happen BEFORE the conditional href set
reset_idx = block.find("style.display='none'")
set_idx = block.find("style.display='inline'")
href_clear_idx = block.find("removeAttribute('href')")
href_set_idx = block.find("link.href=repoUrl")
clear_idx = block.find("container.replaceChildren()")
hide_idx = block.find("container.style.display='none'")
show_idx = block.find("container.style.display='block'")
assert reset_idx != -1, "Missing display='none' reset on every render"
assert href_clear_idx != -1, "Missing removeAttribute('href') reset on every render"
assert reset_idx < set_idx, "display reset must precede inline set"
assert href_clear_idx < href_set_idx, "href clear must precede href assignment"
assert clear_idx != -1, "Missing container contents reset on every render"
assert hide_idx != -1, "Missing display='none' reset when no safe links exist"
assert clear_idx < show_idx, "contents reset must precede link rendering"
assert hide_idx < show_idx, "hidden state must be handled before visible rendering"
def test_whats_new_link_suppressed_when_curSha_falsy():
"""The conditional must guard on all three of repoUrl/curSha/newSha."""
def test_whats_new_link_suppressed_when_current_sha_falsy():
"""The legacy fallback must guard on all three of repo_url/current_sha/latest_sha."""
src = _read_ui_js()
idx = src.find("Wire up \"What's new?\" link")
block = src[idx:idx + 800]
# Match "if(repoUrl && curSha && newSha)" with arbitrary whitespace
pattern = re.compile(r'if\s*\(\s*repoUrl\s*&&\s*curSha\s*&&\s*newSha\s*\)')
assert pattern.search(block), (
"Link must require all three of repoUrl, curSha, newSha to be truthy. "
"If any is null/empty, link stays display:none."
)
idx = src.find("function _updateCompareUrl(info)")
assert idx != -1, "Compare URL helper not found"
block = src[idx:idx + 500]
compact = re.sub(r"\s+", "", block)
assert "if(!(repo_url&&currentSha&&latestSha))returnnull;" in compact
assert "constfallbackUrl=repo_url+'/compare/'+currentSha+'...'+latestSha;" in compact
assert "return_isSafeUpdateCompareUrl(fallbackUrl)?fallbackUrl:null;" in compact
# ── 3. End-to-end: simulate the exact reporter URL shape ──
+30
View File
@@ -64,6 +64,36 @@ class TestGemma4ThinkingTokenStrip:
result = _strip_thinking_markup(raw)
assert result == "Answer"
def test_mid_sentence_think_tags_are_preserved(self):
"""Literal discussion of <think> tags in visible prose must survive (#2152)."""
raw = "The literal tags <think> and </think> describe reasoning markup."
result = _strip_thinking_markup(raw)
assert result == raw
def test_mid_sentence_closed_think_block_is_preserved(self):
"""Only leading provider wrappers are stripped; later tag-looking text stays visible."""
raw = "Use <think>scratchpad</think> as an example in the answer."
result = _strip_thinking_markup(raw)
assert result == raw
def test_mid_sentence_channel_tokens_are_preserved(self):
"""MiniMax-style channel markers later in prose are visible content, not metadata."""
raw = "Use <|channel>thought to start reasoning and <channel|> to finish."
result = _strip_thinking_markup(raw)
assert result == raw
def test_mid_sentence_gemma4_tokens_are_preserved(self):
"""Gemma 4 thinking markers later in prose are visible content, not metadata."""
raw = "Use <|turn|>thinking to start reasoning and <turn|> to finish."
result = _strip_thinking_markup(raw)
assert result == raw
def test_minimax_channel_without_second_pipe_still_stripped_at_start(self):
"""The client-facing MiniMax <|channel>thought shape is stripped when leading."""
raw = "<|channel>thought\nSome reasoning<channel|>Answer"
result = _strip_thinking_markup(raw)
assert result == "Answer"
class TestGemma4TitleLeakDetection:
"""Verify _looks_invalid_generated_title catches Gemma 4 leak."""
+90
View File
@@ -0,0 +1,90 @@
"""Regression coverage for large MCP tool inventories in Settings → System."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
CHANGELOG = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
def test_mcp_tool_list_has_summary_list_and_pager_mounts():
assert 'id="mcpToolToolbar"' in INDEX_HTML
assert 'aria-live="polite"' in INDEX_HTML
assert 'id="mcpToolList" class="mcp-tool-list"' in INDEX_HTML
assert 'id="mcpToolPager"' in INDEX_HTML
assert 'aria-label="MCP tools pagination"' in INDEX_HTML
assert 'data-i18n-aria-label="mcp_tools_pagination_label"' in INDEX_HTML
def test_mcp_tool_rendering_is_paginated_not_full_list_rendered():
assert "let _mcpToolsPageSize=5" in PANELS_JS
assert "const MCP_TOOLS_PAGE_SIZE_OPTIONS=[5,10,20,40]" in PANELS_JS
assert "filtered.slice((_mcpToolsPage-1)*_mcpToolsPageSize,_mcpToolsPage*_mcpToolsPageSize)" in PANELS_JS
assert "list.innerHTML=visible.map(tool=>" in PANELS_JS
assert "list.innerHTML=filtered.map(tool=>" not in PANELS_JS
def test_mcp_tool_page_size_selector_resets_to_first_page():
assert "function setMcpToolsPageSize(size){" in PANELS_JS
assert "if(!MCP_TOOLS_PAGE_SIZE_OPTIONS.includes(next)) return;" in PANELS_JS
assert "_mcpToolsPageSize=next;\n _mcpToolsPage=1;" in PANELS_JS
assert "mcp_tools_per_page_aria" in PANELS_JS
def test_mcp_tool_search_respects_selected_page_size():
assert "const filtered=_filterMcpToolsForSearch(tools, query);" in PANELS_JS
assert "const pages=Math.max(1,Math.ceil(filtered.length/_mcpToolsPageSize));" in PANELS_JS
assert "mcp_tools_summary_showing" in PANELS_JS
assert "t('mcp_tools_summary_showing',start,end,filtered,searchNote,totalNote,page,pages)" in PANELS_JS
assert "mcp_tools_summary_no_matches" in PANELS_JS
def test_mcp_tool_search_resets_to_first_page_and_page_changes_scroll_top():
assert "function setMcpToolsPage(page){" in PANELS_JS
assert "function filterMcpTools(){\n _mcpToolsPage=1;" in PANELS_JS
search_block = PANELS_JS.split("function filterMcpTools(){", 1)[1].split("function loadMcpTools(){", 1)[0]
assert "if(list) list.scrollTop=0;" in search_block
def test_mcp_tool_empty_state_mentions_inactive_configured_servers():
assert "let _mcpToolsMeta={}" in PANELS_JS
assert "mcp_tools_inactive_configured_servers" in PANELS_JS
assert "_mcpToolsMeta=r||{};" in PANELS_JS
def test_mcp_tool_list_is_bounded_scroll_region_with_pager_chrome():
assert ".mcp-tool-list{max-height:min(52vh,560px);overflow:auto" in STYLE_CSS
assert "scrollbar-gutter:stable" in STYLE_CSS
assert ".mcp-tool-pager{display:flex" in STYLE_CSS
assert ".mcp-tool-page-btn" in STYLE_CSS
assert ".mcp-tool-page-size" in STYLE_CSS
def test_mcp_tool_pagination_strings_are_i18n_backed():
for key in [
"mcp_tools_summary_no_matches",
"mcp_tools_summary_none",
"mcp_tools_summary_matching",
"mcp_tools_summary_total_note",
"mcp_tools_summary_showing",
"mcp_tools_page_size_prefix",
"mcp_tools_page_size_suffix",
"mcp_tools_per_page_aria",
"mcp_tools_inactive_configured_servers",
"mcp_tools_pagination_label",
"mcp_tools_previous_page",
"mcp_tools_previous_page_aria",
"mcp_tools_next_page",
"mcp_tools_next_page_aria",
]:
assert f"{key}:" in I18N_JS
def test_changelog_mentions_large_mcp_tool_inventory_fix():
assert "large MCP tool inventories" in CHANGELOG
assert "5-item default pages" in CHANGELOG
assert "per-page selector up to 40 tools" in CHANGELOG
+109
View File
@@ -0,0 +1,109 @@
from urllib.parse import urlparse
def test_webui_session_metadata_load_skips_cli_metadata_scan(monkeypatch):
"""Opening a normal WebUI session should not scan imported CLI sessions."""
import api.routes as routes
from api.models import Session
session = Session(
session_id="webui_normal",
title="Normal WebUI chat",
messages=[{"role": "user", "content": "hello"}],
)
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: session)
monkeypatch.setattr(routes, "_clear_stale_stream_state", lambda _session: None)
monkeypatch.setattr(routes, "redact_session_data", lambda payload: payload)
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
monkeypatch.setattr(
routes,
"_lookup_cli_session_metadata",
lambda _sid: (_ for _ in ()).throw(AssertionError("normal WebUI loads should not scan CLI sessions")),
)
response = routes.handle_get(
object(),
urlparse("/api/session?session_id=webui_normal&messages=0&resolve_model=0"),
)
assert response["session"]["session_id"] == "webui_normal"
assert response["session"]["messages"] == []
def test_read_only_session_metadata_load_preserves_cli_metadata_lookup(monkeypatch):
"""Read-only imported sidecars still need CLI metadata for source identity."""
import api.routes as routes
from api.models import Session
session = Session(
session_id="readonly_sidecar",
title="Imported chat",
messages=[{"role": "user", "content": "hello"}],
read_only=True,
)
looked_up = []
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: session)
monkeypatch.setattr(routes, "_clear_stale_stream_state", lambda _session: None)
monkeypatch.setattr(routes, "get_cli_session_messages", lambda _sid: [])
monkeypatch.setattr(routes, "redact_session_data", lambda payload: payload)
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
def fake_lookup(sid):
looked_up.append(sid)
return {
"session_id": sid,
"read_only": True,
"source_label": "External Agent",
}
monkeypatch.setattr(routes, "_lookup_cli_session_metadata", fake_lookup)
response = routes.handle_get(
object(),
urlparse("/api/session?session_id=readonly_sidecar&messages=0&resolve_model=0"),
)
assert looked_up == ["readonly_sidecar"]
assert response["session"]["read_only"] is True
def test_messaging_session_metadata_load_preserves_cli_metadata_lookup(monkeypatch):
"""Messaging/imported sidecars still need CLI metadata for source identity."""
import api.routes as routes
from api.models import Session
session = Session(
session_id="messaging_sidecar",
title="Telegram chat",
messages=[{"role": "user", "content": "hello"}],
session_source="messaging",
raw_source="telegram",
)
looked_up = []
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: session)
monkeypatch.setattr(routes, "_clear_stale_stream_state", lambda _session: None)
monkeypatch.setattr(routes, "get_cli_session_messages", lambda _sid: [])
monkeypatch.setattr(routes, "redact_session_data", lambda payload: payload)
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
def fake_lookup(sid):
looked_up.append(sid)
return {
"session_id": sid,
"session_source": "messaging",
"raw_source": "telegram",
"source_label": "Telegram",
}
monkeypatch.setattr(routes, "_lookup_cli_session_metadata", fake_lookup)
response = routes.handle_get(
object(),
urlparse("/api/session?session_id=messaging_sidecar&messages=0&resolve_model=0"),
)
assert looked_up == ["messaging_sidecar"]
assert response["session"]["source_label"] == "Telegram"
+47
View File
@@ -123,6 +123,53 @@ def test_all_sessions_backfills_last_message_at_for_legacy_index_rows():
assert persisted[0].get("last_message_at") == 100.0
def test_all_sessions_prune_reuses_in_memory_id_snapshot(monkeypatch):
"""Index pruning should not reacquire the session lock for every row."""
index_file = models.SESSION_INDEX_FILE
entries = [
{
"session_id": "sess_a",
"title": "Alpha",
"updated_at": 200.0,
"last_message_at": 200.0,
"workspace": "/tmp",
"model": "test",
"message_count": 1,
"created_at": 100.0,
"pinned": False,
"archived": False,
},
{
"session_id": "sess_b",
"title": "Bravo",
"updated_at": 150.0,
"last_message_at": 150.0,
"workspace": "/tmp",
"model": "test",
"message_count": 1,
"created_at": 90.0,
"pinned": False,
"archived": False,
},
]
_write_index_file(index_file, entries)
seen = []
def _assert_snapshot_used(session_id, in_memory_ids=None):
assert in_memory_ids is not None, "all_sessions should snapshot SESSIONS once before pruning"
seen.append(session_id)
return True
monkeypatch.setattr(models, "_index_entry_exists", _assert_snapshot_used)
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
rows = models.all_sessions()
assert [row["session_id"] for row in rows] == ["sess_a", "sess_b"]
assert seen == ["sess_a", "sess_b"]
# ── 6. test_incremental_patch_correctness ─────────────────────────────────
def test_incremental_patch_correctness():
+39 -22
View File
@@ -5,7 +5,6 @@ Covers the static render path (ui.js regex logic, verified against the JS source
and the streaming render path (messages.js _streamDisplay logic).
"""
import pathlib
import re
REPO_ROOT = pathlib.Path(__file__).parent.parent
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text()
@@ -14,24 +13,25 @@ MSG_JS = (REPO_ROOT / "static" / "messages.js").read_text()
# ── ui.js: static render path ────────────────────────────────────────────────
def test_think_regex_has_no_anchor():
"""The <think> regex in ui.js must not use a ^ anchor so leading whitespace is allowed."""
def test_think_regex_is_leading_only_after_optional_whitespace():
"""The <think> regex in ui.js must anchor after optional whitespace."""
# Find the thinkMatch line by locating the .match( call on that line
idx = UI_JS.find("const thinkMatch=content.match(")
assert idx >= 0, "thinkMatch line not found in ui.js"
line = UI_JS[idx:idx+100]
# The regex must NOT start with ^ right after the opening /
assert "/^<think>" not in line and "(/^" not in line, \
f"thinkMatch regex must not use ^ anchor — found: {line.strip()}"
assert "/^\\s*<think>" in line, \
f"thinkMatch regex must only match leading <think> blocks after whitespace — found: {line.strip()}"
assert "/^<think>" not in line, \
f"thinkMatch regex must still allow leading whitespace — found: {line.strip()}"
def test_gemma_regex_has_no_anchor():
"""The Gemma channel-token regex in ui.js must not use a ^ anchor."""
match = re.search(r'const gemmaMatch=content\.match\((/[^/]+/)\)', UI_JS)
assert match, "gemmaMatch line not found in ui.js"
pattern = match.group(1)
assert not pattern.startswith('/^'), \
f"gemmaMatch regex must not use ^ anchor — got {pattern}"
def test_gemma_regex_is_leading_only_after_optional_whitespace():
"""The MiniMax channel-token regex in ui.js must anchor after optional whitespace."""
idx = UI_JS.find("const gemmaMatch=content.match(")
assert idx >= 0, "gemmaMatch line not found in ui.js"
line = UI_JS[idx:idx+140]
assert "/^\\s*<\\|channel\\|?>thought\\n?" in line, \
f"gemmaMatch regex must only match leading channel blocks after whitespace — found: {line.strip()}"
def test_think_content_removal_uses_replace_not_slice():
@@ -42,6 +42,8 @@ def test_think_content_removal_uses_replace_not_slice():
block = UI_JS[idx:idx+200]
assert "content.replace(" in block, \
"ui.js must use content.replace() to remove <think> block (not .slice())"
assert "content.replace(/^\\s*<think>" in block, \
"ui.js must remove only leading <think> blocks after optional whitespace"
assert ".trimStart()" in block, \
"ui.js must call .trimStart() on content after removing the <think> block"
@@ -53,6 +55,8 @@ def test_gemma_content_removal_uses_replace_not_slice():
block = UI_JS[idx:idx+200]
assert "content.replace(" in block, \
"ui.js must use content.replace() to remove Gemma channel block (not .slice())"
assert "content.replace(/^\\s*<\\|channel\\|?>thought\\n?" in block, \
"ui.js must remove only leading Gemma channel blocks after optional whitespace"
assert ".trimStart()" in block, \
"ui.js must call .trimStart() on content after removing the Gemma channel block"
@@ -65,11 +69,11 @@ def test_gemma_turn_regex_in_ui_js():
" (note: double-pipe: <|turn|> not <|turn>)"
)
# Extraction block
match = re.search(r'const gemmaTurnMatch=content\.match\((/[^/]+/)\)', UI_JS)
assert match, "gemmaTurnMatch line not found in ui.js"
pattern = match.group(1)
assert not pattern.startswith('/^'), (
f"gemmaTurnMatch regex must not use ^ anchor — got {pattern}"
idx = UI_JS.find("const gemmaTurnMatch=content.match(")
assert idx >= 0, "gemmaTurnMatch line not found in ui.js"
line = UI_JS[idx:idx+140]
assert "/^\\s*<\\|turn\\|>thinking\\n" in line, (
f"gemmaTurnMatch regex must only match leading Gemma 4 blocks after whitespace — found: {line.strip()}"
)
@@ -81,11 +85,24 @@ def test_gemma_turn_content_removal_uses_replace_not_slice():
assert "content.replace(" in block, (
"ui.js must use content.replace() to remove Gemma 4 turn block (not .slice())"
)
assert "content.replace(/^\\s*<\\|turn\\|>thinking\\n" in block, (
"ui.js must remove only leading Gemma 4 turn blocks after optional whitespace"
)
assert ".trimStart()" in block, (
"ui.js must call .trimStart() on content after removing the Gemma 4 turn block"
)
def test_message_reasoning_payload_detection_is_leading_only():
"""Persisted literal tag discussion later in content must not create a thinking card."""
idx = UI_JS.find("function _messageHasReasoningPayload(m)")
assert idx >= 0, "_messageHasReasoningPayload function not found in ui.js"
block = UI_JS[idx:idx+500]
assert "return /^\\s*(?:<think>" in block, (
"_messageHasReasoningPayload must only detect leading provider thinking wrappers"
)
# ── messages.js: streaming render path ───────────────────────────────────────
def test_stream_display_trims_before_startswith():
@@ -128,13 +145,13 @@ def test_stream_display_trims_return_after_close():
# ── Regression: existing anchored patterns must be gone ──────────────────────
def test_no_anchored_think_regex_in_ui_js():
"""The old anchored regex /^<think>/ must not exist in ui.js."""
def test_no_strictly_anchored_think_regex_in_ui_js():
"""The old /^<think>/ shape must not return; leading whitespace remains supported."""
assert "/^<think>" not in UI_JS, \
"Old anchored /^<think>/ regex still present in ui.js — fix not applied"
def test_no_anchored_gemma_regex_in_ui_js():
"""The old anchored Gemma regex must not exist in ui.js."""
def test_no_strictly_anchored_gemma_regex_in_ui_js():
"""The old /^<|channel>/ shape must not return; leading whitespace remains supported."""
assert "/^<|channel>" not in UI_JS, \
"Old anchored /^<|channel>/ regex still present in ui.js — fix not applied"
+267
View File
@@ -29,6 +29,81 @@ def read(rel):
# ── api/updates.py ────────────────────────────────────────────────────────────
class TestUpdateChecker:
def test_build_compare_url_requires_all_pieces(self):
import api.updates as upd
assert upd._build_compare_url(
'https://github.com/nesquena/hermes-webui', 'abc1234', 'def5678'
) == 'https://github.com/nesquena/hermes-webui/compare/abc1234...def5678'
assert upd._build_compare_url(None, 'abc1234', 'def5678') is None
assert upd._build_compare_url('https://github.com/nesquena/hermes-webui', None, 'def5678') is None
assert upd._build_compare_url('https://github.com/nesquena/hermes-webui', 'abc1234', None) is None
def test_build_compare_url_rejects_unsafe_remote_urls(self):
import api.updates as upd
assert upd._build_compare_url('javascript:alert(1)', 'abc1234', 'def5678') is None
assert upd._build_compare_url('file:///tmp/hermes-webui', 'abc1234', 'def5678') is None
assert upd._build_compare_url('https:github.com/nesquena/hermes-webui', 'abc1234', 'def5678') is None
assert upd._build_compare_url('https://github.com/nesquena/hermes-webui', 'abc1234', 'def5678')
def test_check_repo_includes_compare_url_from_normalized_remote_and_merge_base(self, tmp_path, monkeypatch):
import api.updates as upd
(tmp_path / '.git').mkdir()
def fake_run(args, cwd, timeout=10):
if args[0] == 'fetch':
return '', True
if args[:2] == ['rev-parse', '--abbrev-ref']:
return 'origin/master', True
if args[:2] == ['rev-list', '--count']:
return '2', True
if args[0] == 'merge-base':
return 'abcdef1234567890', True
if args[:3] == ['rev-parse', '--short', 'abcdef1234567890']:
return 'abcdef1', True
if args[:3] == ['rev-parse', '--short', 'origin/master']:
return 'def5678', True
if args[:2] == ['remote', 'get-url']:
return 'git@github.com:NousResearch/hermes-agent.git', True
return '', True
monkeypatch.setattr(upd, '_run_git', fake_run)
result = upd._check_repo(tmp_path, 'agent')
assert result['repo_url'] == 'https://github.com/NousResearch/hermes-agent'
assert result['current_sha'] == 'abcdef1'
assert result['latest_sha'] == 'def5678'
assert result['compare_url'] == 'https://github.com/NousResearch/hermes-agent/compare/abcdef1...def5678'
def test_check_repo_omits_compare_url_when_merge_base_missing(self, tmp_path, monkeypatch):
import api.updates as upd
(tmp_path / '.git').mkdir()
def fake_run(args, cwd, timeout=10):
if args[0] == 'fetch':
return '', True
if args[:2] == ['rev-parse', '--abbrev-ref']:
return 'origin/master', True
if args[:2] == ['rev-list', '--count']:
return '2', True
if args[0] == 'merge-base':
return 'fatal: no merge base', False
if args[:3] == ['rev-parse', '--short', 'origin/master']:
return 'def5678', True
if args[:2] == ['remote', 'get-url']:
return 'https://github.com/nesquena/hermes-webui.git', True
return '', True
monkeypatch.setattr(upd, '_run_git', fake_run)
result = upd._check_repo(tmp_path, 'webui')
assert result['current_sha'] is None
assert result['latest_sha'] == 'def5678'
assert result['compare_url'] is None
def test_repo_url_strips_only_dot_git_suffix(self, tmp_path, monkeypatch):
import api.updates as upd
@@ -603,6 +678,198 @@ class TestSequentialUpdateRestartCoordination:
)
class TestUpdateCompareSource:
def test_simulated_update_check_payload_includes_both_safe_compare_urls(self):
src = read('api/routes.py')
assert '"repo_url": "https://github.com/nesquena/hermes-webui"' in src
assert '"compare_url": "https://github.com/nesquena/hermes-webui/compare/abc1234...def5678"' in src
assert '"repo_url": "https://github.com/NousResearch/hermes-agent"' in src
assert '"compare_url": "https://github.com/NousResearch/hermes-agent/compare/aaa0001...bbb0002"' in src
def test_update_banner_html_uses_multi_target_links_container(self):
src = read('static/index.html')
assert 'id="updateWhatsNewLinks"' in src
assert 'id="updateWhatsNew"' not in src
def test_update_banner_frontend_uses_data_driven_compare_helpers(self):
src = read('static/ui.js')
assert 'function _isSafeUpdateCompareUrl(url)' in src
assert 'function _updateCompareUrl(info)' in src
assert 'function _updateWhatsNewTargets(data)' in src
assert 'function _renderUpdateWhatsNewLinks(data)' in src
assert "$('updateWhatsNewLinks')" in src
assert "compare_url" in src
assert "repo_url+'/compare/'+currentSha+'...'+latestSha" in src
assert "_isSafeUpdateCompareUrl(compareUrl)?compareUrl:null" in src
assert "_renderUpdateWhatsNewLinks(data);" in src
assert "data.webui.repo_url" not in src
assert "$('updateWhatsNew')" not in src
def test_update_banner_clears_stale_links_when_no_updates_remain(self):
src = read('static/ui.js')
start = src.find('function _showUpdateBanner(data)')
assert start != -1, "_showUpdateBanner not found"
fn = src[start:src.find('function dismissUpdate()', start)]
empty_idx = fn.find('if(!parts.length)')
assert empty_idx != -1, "_showUpdateBanner must handle empty update payloads"
empty_block = fn[empty_idx:fn.find('return;', empty_idx) + len('return;')]
assert '_renderUpdateWhatsNewLinks(data);' in empty_block
assert "classList.remove('visible')" in empty_block
def test_manual_up_to_date_check_clears_update_banner(self):
src = read('static/panels.js')
up_to_date_idx = src.find("settings_up_to_date")
assert up_to_date_idx != -1, "manual update up-to-date branch not found"
block = src[up_to_date_idx:up_to_date_idx + 300]
assert "_showUpdateBanner(data)" in block
class TestWhatsNewSummaryToggle:
def test_settings_default_and_persistence_allow_whats_new_summary_toggle(self):
src = read('api/config.py')
assert '"whats_new_summary_enabled": False' in src
bool_keys_start = src.find('_SETTINGS_BOOL_KEYS')
assert bool_keys_start != -1
bool_keys = src[bool_keys_start:src.find('}', bool_keys_start)]
assert '"whats_new_summary_enabled"' in bool_keys
def test_settings_panel_places_summary_toggle_next_to_update_check(self):
src = read('static/index.html')
check_idx = src.find('id="settingsCheckUpdates"')
summary_idx = src.find('id="settingsWhatsNewSummary"')
assert check_idx != -1, "settingsCheckUpdates checkbox missing"
assert summary_idx != -1, "settingsWhatsNewSummary checkbox missing"
assert check_idx < summary_idx, "summary toggle should sit after the update-check toggle"
nearby = src[summary_idx:summary_idx + 900]
assert 'settings_label_whats_new_summary' in nearby
assert 'settings_desc_whats_new_summary' in nearby
def test_settings_js_loads_saves_and_boots_summary_toggle(self):
panels = read('static/panels.js')
boot = read('static/boot.js')
assert "$('settingsWhatsNewSummary')" in panels
assert 'payload.whats_new_summary_enabled' in panels
assert 'settings.whats_new_summary_enabled' in panels
assert 'body.whats_new_summary_enabled' in panels
assert 'window._whatsNewSummaryEnabled' in boot
assert 'whats_new_summary_enabled' in boot
def test_update_banner_summary_flow_keeps_diff_links_after_summary(self):
src = read('static/ui.js')
assert 'function _renderUpdateSummaryPanel' in src
assert 'async function showWhatsNewSummary' in src
assert "api('/api/updates/summary'" in src
assert 'updateSummaryDiffLinks' in src
assert 'Regular diff comparison' in src
assert 'updateSummarySections' in src
assert 'Generate WebUI update summary' in src
assert 'Generate Agent update summary' in src
assert 'View generated WebUI update summary' in src
assert 'View generated Agent update summary' in src
assert 'Re-generate WebUI update summary' in src
assert 'Re-generate Agent update summary' in src
assert 'window._whatsNewGeneratedSummaries' in src
assert 'sessionStorage' in src
assert 'hermes-whats-new-generated-summaries' in src
assert 'function _loadStoredUpdateSummaries' in src
assert 'function _persistGeneratedSummaries' in src
assert 'function _pruneGeneratedSummaries' in src
assert 'function _updateSummarySignature' in src
assert 'function _updateSummaryButtonLabel' in src
assert 'showWhatsNewSummary(target.key)' in src
assert 'target?{[target]:data[target]}:data' in src
assert 'target:target||null' in src
assert '_renderUpdateWhatsNewLinks(data,{mode' in src
assert 'window._whatsNewSummaryEnabled' in src
def test_summary_endpoint_and_prompt_are_human_readable_not_technical(self):
routes = read('api/routes.py')
updates = read('api/updates.py')
assert '"/api/updates/summary"' in routes
assert 'summarize_update_payload' in routes
assert 'def summarize_update_payload' in updates
assert 'human-readable' in updates
assert 'avoid technical jargon' in updates
assert 'regular diff comparison' in updates
assert 'Return only bullets' in updates
assert 'def _format_update_summary_sections' in updates
def test_update_summary_formats_llm_text_into_stable_sections(self):
from api.updates import summarize_update_payload
payload = {
'webui': {'behind': 2, 'current_sha': 'abc', 'latest_sha': 'def', 'compare_url': 'https://example.test/webui'},
'agent': {'behind': 1, 'current_sha': 'aaa', 'latest_sha': 'bbb', 'compare_url': 'https://example.test/agent'},
}
result = summarize_update_payload(
payload,
llm_callback=lambda _system, _prompt: 'The settings panel is easier to understand. Update prompts are clearer.',
use_cache=False,
)
assert result['summary_sections'][0]['title'] == "What you'll notice"
assert result['summary_sections'][1]['title'] == 'Worth knowing'
assert result['summary_sections'][0]['items']
assert result['summary_sections'][1]['items']
assert 'regular diff comparison' not in ' '.join(result['summary_sections'][1]['items']).lower()
assert 'What you\'ll notice' in result['summary']
assert 'Worth knowing' in result['summary']
assert '- The settings panel is easier to understand.' in result['summary']
def test_update_summary_cache_reuses_same_update_summary(self):
import api.updates as upd
upd._summary_cache.clear()
calls = []
payload = {
'webui': {'behind': 2, 'current_sha': 'abc', 'latest_sha': 'def', 'compare_url': 'https://example.test/webui'},
}
def fake_llm(_system, _prompt):
calls.append(True)
return f'- Stable cached summary #{len(calls)}'
first = upd.summarize_update_payload(payload, llm_callback=fake_llm)
second = upd.summarize_update_payload(payload, llm_callback=fake_llm)
changed = upd.summarize_update_payload(
{'webui': {'behind': 3, 'current_sha': 'abc', 'latest_sha': 'xyz', 'compare_url': 'https://example.test/webui2'}},
llm_callback=fake_llm,
)
assert len(calls) == 2
assert second['summary'] == first['summary']
assert second['cached'] is True
assert changed['summary'] != first['summary']
def test_update_summary_can_be_generated_per_target_and_cached_separately(self):
import api.updates as upd
upd._summary_cache.clear()
calls = []
payload = {
'webui': {'behind': 2, 'current_sha': 'web-a', 'latest_sha': 'web-b', 'compare_url': 'https://example.test/webui'},
'agent': {'behind': 1, 'current_sha': 'agent-a', 'latest_sha': 'agent-b', 'compare_url': 'https://example.test/agent'},
}
def fake_llm(_system, prompt):
calls.append(prompt)
if 'Agent:' in prompt:
return '- Agent startup is clearer.'
return '- WebUI settings are easier to use.'
webui = upd.summarize_update_payload(payload, target='webui', llm_callback=fake_llm)
agent = upd.summarize_update_payload(payload, target='agent', llm_callback=fake_llm)
webui_again = upd.summarize_update_payload(payload, target='webui', llm_callback=fake_llm)
assert len(calls) == 2
assert webui['target'] == 'webui'
assert agent['target'] == 'agent'
assert [t['name'] for t in webui['targets']] == ['webui']
assert [t['name'] for t in agent['targets']] == ['agent']
assert 'WebUI settings are easier to use.' in webui['summary']
assert 'Agent startup is clearer.' in agent['summary']
assert webui_again['cached'] is True
assert webui_again['summary'] == webui['summary']
# ── Regression: force button reset on retry ──────────────────────────────────
class TestForceButtonResetOnRetry: