diff --git a/CHANGELOG.md b/CHANGELOG.md index 48e58a6e..1a68ebc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ``/`` 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 (`...`, MiniMax `<|channel>thought...`, Gemma 4 `<|turn|>thinking...`) 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 `` and `` no longer disappear from saved or re-rendered assistant messages. WebUI now treats `...`, MiniMax `<|channel>thought...`, and Gemma 4 `<|turn|>thinking...` 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). diff --git a/api/config.py b/api/config.py index a6f7daaf..223df061 100644 --- a/api/config.py +++ b/api/config.py @@ -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", diff --git a/api/models.py b/api/models.py index b15d5531..589cc38a 100644 --- a/api/models.py +++ b/api/models.py @@ -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): diff --git a/api/routes.py b/api/routes.py index 765857ca..3d3c5a15 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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) diff --git a/api/streaming.py b/api/streaming.py index 91dbc738..bf473d90 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -689,9 +689,12 @@ def _strip_thinking_markup(text: str) -> str: if not text: return '' s = str(text) - s = re.sub(r'.*?', ' ', s, flags=re.IGNORECASE | re.DOTALL) - s = re.sub(r'<\|channel\|>thought.*?', ' ', s, flags=re.IGNORECASE | re.DOTALL) - s = re.sub(r'<\|turn\|>thinking\n.*?', ' ', 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*.*?\s*', ' ', s, flags=re.IGNORECASE | re.DOTALL) + s = re.sub(r'^\s*<\|channel\|?>thought\n?.*?\s*', ' ', s, flags=re.IGNORECASE | re.DOTALL) + s = re.sub(r'^\s*<\|turn\|>thinking\n.*?\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 tags (e.g. Qwen3). # These appear as the very first sentence of the assistant response and are not useful as titles. diff --git a/api/updates.py b/api/updates.py index e3e025c2..0bbca829 100644 --- a/api/updates.py +++ b/api/updates.py @@ -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. diff --git a/docs/images/update-banner-whats-new-after-summary-off.png b/docs/images/update-banner-whats-new-after-summary-off.png new file mode 100644 index 00000000..fbc1a861 Binary files /dev/null and b/docs/images/update-banner-whats-new-after-summary-off.png differ diff --git a/docs/images/update-banner-whats-new-after-summary-on.png b/docs/images/update-banner-whats-new-after-summary-on.png new file mode 100644 index 00000000..7ff4a81c Binary files /dev/null and b/docs/images/update-banner-whats-new-after-summary-on.png differ diff --git a/docs/images/update-banner-whats-new-after.png b/docs/images/update-banner-whats-new-after.png new file mode 100644 index 00000000..3fbed52d Binary files /dev/null and b/docs/images/update-banner-whats-new-after.png differ diff --git a/docs/images/update-banner-whats-new-before.png b/docs/images/update-banner-whats-new-before.png new file mode 100644 index 00000000..e5af9b39 Binary files /dev/null and b/docs/images/update-banner-whats-new-before.png differ diff --git a/docs/pr-media/2210/after-mcp-tools-paginated.png b/docs/pr-media/2210/after-mcp-tools-paginated.png new file mode 100644 index 00000000..e2793510 Binary files /dev/null and b/docs/pr-media/2210/after-mcp-tools-paginated.png differ diff --git a/docs/pr-media/2210/before-mcp-tools-unbounded.png b/docs/pr-media/2210/before-mcp-tools-unbounded.png new file mode 100644 index 00000000..9123a563 Binary files /dev/null and b/docs/pr-media/2210/before-mcp-tools-unbounded.png differ diff --git a/static/boot.js b/static/boot.js index e08ad6e9..27dfa638 100644 --- a/static/boot.js +++ b/static/boot.js @@ -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; diff --git a/static/i18n.js b/static/i18n.js index b0a28ade..fb0b0944 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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 d’affichage de l’assistant dans l’interface 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…', diff --git a/static/index.html b/static/index.html index 8a530d16..424e9c44 100644 --- a/static/index.html +++ b/static/index.html @@ -342,7 +342,11 @@
- + +
@@ -1051,6 +1055,13 @@
Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.
+
+ +
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.
+
Display name for the assistant throughout the UI. Defaults to Hermes.
@@ -1138,7 +1149,9 @@
Search known tools across active MCP servers.
-
+
+
+
Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.
diff --git a/static/panels.js b/static/panels.js index d942fac0..a81a597c 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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 = `
${isNewRun ? '' : ''} + ${isAgentMode ? '🤖' : ''} ${esc(job.name)} ${esc(profileLabel)} ${esc(status.label)} @@ -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){
${esc(t('cron_next'))}
${esc(nextRun)}
${esc(t('cron_last'))}
${esc(lastRun)}
Deliver
${esc(deliver)}
-
Mode
${esc(cronJobMode)}
+
Mode
${esc(cronJobMode)}${modelProvider ? ` ${modelProvider}` : ''}
${isNoAgent ? `
No-agent script
${esc(script || '—')}
` : ''}
${esc(t('cron_profile_label') || 'Profile')}
${esc(profileLabel)}
${esc(t('cron_toast_notifications_label') || 'Completion toasts')}
${esc(toastNotifications ? (t('cron_toast_notifications_enabled') || 'Enabled') : (t('cron_toast_notifications_disabled') || 'Disabled'))}
@@ -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=`
${esc(t('mcp_load_failed'))}
`}); } 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=`
${esc(t(key))}
`; +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=>``).join(''); + return ``; +} +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}
${esc(t('mcp_tools_inactive_configured_servers',unavailable.join(', ')))}`; +} +function _renderMcpToolPager(filteredCount, page, pages){ + const pager=$('mcpToolPager'); + if(!pager) return; + if(pages<=1){ + pager.innerHTML=''; return; } - list.innerHTML=filtered.map(tool=>{ + pager.innerHTML=` + ${page} / ${pages} + `; +} +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=`${esc(_mcpToolsSummary(total,filtered.length,_mcpToolsPage,pages,query))}${_mcpToolPageSizeControl()}`; + _renderMcpToolPager(filtered.length,_mcpToolsPage,pages); + if(!filtered.length){ + list.innerHTML=`
${_mcpToolsEmptyMessage(query)}
`; + return; + } + const visible=filtered.slice((_mcpToolsPage-1)*_mcpToolsPageSize,_mcpToolsPage*_mcpToolsPageSize); + list.innerHTML=visible.map(tool=>{ const status=tool.status||'unknown'; const statusBadge=`${esc(_mcpStatusLabel(status))}`; const schemaText=_mcpToolSchemaText(tool.schema_summary); @@ -6325,16 +6383,42 @@ function _renderMcpTools(tools, query){
`; }).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=`
${esc(t('loading'))}
`; api('/api/mcp/tools').then(r=>{ _mcpToolsCache=(r&&Array.isArray(r.tools))?r.tools:[]; + _mcpToolsMeta=r||{}; + _mcpToolsPage=1; filterMcpTools(); }).catch(()=>{list.innerHTML=`
${esc(t('mcp_tools_load_failed'))}
`}); } diff --git a/static/style.css b/static/style.css index 67b9b6f5..9f955d3f 100644 --- a/static/style.css +++ b/static/style.css @@ -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);} diff --git a/static/ui.js b/static/ui.js index d0f27aa8..0c716cb8 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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&¤tSha&&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&§ions.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 /[\s\S]*?<\/think>|<\|channel>thought\n[\s\S]*?|<\|turn\|>thinking\n[\s\S]*?/.test(String(m.content||'')); + return /^\s*(?:[\s\S]*?<\/think>|<\|channel\|?>thought\n?[\s\S]*?|<\|turn\|>thinking\n[\s\S]*?)/.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(/([\s\S]*?)<\/think>/); + const thinkMatch=content.match(/^\s*([\s\S]*?)<\/think>\s*/); if(thinkMatch){ thinkingText=thinkMatch[1].trim(); - content=content.replace(/[\s\S]*?<\/think>\s*/,'').trimStart(); + content=content.replace(/^\s*[\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]*?)/); + const gemmaMatch=content.match(/^\s*<\|channel\|?>thought\n?([\s\S]*?)\s*/); if(gemmaMatch){ thinkingText=gemmaMatch[1].trim(); - content=content.replace(/<\|channel>thought\n[\s\S]*?\s*/,'').trimStart(); + content=content.replace(/^\s*<\|channel\|?>thought\n?[\s\S]*?\s*/,'').trimStart(); } } if(!thinkingText){ // Gemma 4 uses asymmetric <|turn|>thinking\n... delimiters. - const gemmaTurnMatch=content.match(/<\|turn\|>thinking\n([\s\S]*?)/); + const gemmaTurnMatch=content.match(/^\s*<\|turn\|>thinking\n([\s\S]*?)\s*/); if(gemmaTurnMatch){ thinkingText=gemmaTurnMatch[1].trim(); - content=content.replace(/<\|turn\|>thinking\n[\s\S]*?\s*/,'').trimStart(); + content=content.replace(/^\s*<\|turn\|>thinking\n[\s\S]*?\s*/,'').trimStart(); } } } diff --git a/tests/test_claude_code_session_import.py b/tests/test_claude_code_session_import.py index 54e0859f..fb36d799 100644 --- a/tests/test_claude_code_session_import.py +++ b/tests/test_claude_code_session_import.py @@ -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 diff --git a/tests/test_issue1579_whats_new_link_404.py b/tests/test_issue1579_whats_new_link_404.py index 6f8bb7db..9b851ac2 100644 --- a/tests/test_issue1579_whats_new_link_404.py +++ b/tests/test_issue1579_whats_new_link_404.py @@ -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&¤tSha&&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 ── diff --git a/tests/test_issue607.py b/tests/test_issue607.py index 15d0c4e4..0e950c3d 100644 --- a/tests/test_issue607.py +++ b/tests/test_issue607.py @@ -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 tags in visible prose must survive (#2152).""" + raw = "The literal tags and 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 scratchpad 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 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 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 reasoningAnswer" + result = _strip_thinking_markup(raw) + assert result == "Answer" + class TestGemma4TitleLeakDetection: """Verify _looks_invalid_generated_title catches Gemma 4 leak.""" diff --git a/tests/test_mcp_tools_list_overflow.py b/tests/test_mcp_tools_list_overflow.py new file mode 100644 index 00000000..9e418cd9 --- /dev/null +++ b/tests/test_mcp_tools_list_overflow.py @@ -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 diff --git a/tests/test_session_cli_scan_fast_path.py b/tests/test_session_cli_scan_fast_path.py new file mode 100644 index 00000000..a28b0318 --- /dev/null +++ b/tests/test_session_cli_scan_fast_path.py @@ -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" diff --git a/tests/test_session_index.py b/tests/test_session_index.py index eacda6d5..3f487f97 100644 --- a/tests/test_session_index.py +++ b/tests/test_session_index.py @@ -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(): diff --git a/tests/test_sprint38.py b/tests/test_sprint38.py index 020574f3..880a812e 100644 --- a/tests/test_sprint38.py +++ b/tests/test_sprint38.py @@ -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 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 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 "/^" not in line and "(/^" not in line, \ - f"thinkMatch regex must not use ^ anchor — found: {line.strip()}" + assert "/^\\s*" in line, \ + f"thinkMatch regex must only match leading blocks after whitespace — found: {line.strip()}" + assert "/^" 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 block (not .slice())" + assert "content.replace(/^\\s*" in block, \ + "ui.js must remove only leading blocks after optional whitespace" assert ".trimStart()" in block, \ "ui.js must call .trimStart() on content after removing the 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*(?:" 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 /^/ must not exist in ui.js.""" +def test_no_strictly_anchored_think_regex_in_ui_js(): + """The old /^/ shape must not return; leading whitespace remains supported.""" assert "/^" not in UI_JS, \ "Old anchored /^/ 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" diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index 5eaee61e..de46a892 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -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: