mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Merge pull request #2214 from nesquena/stage-351
stage-351: net-positive ready batch — perf CLI scan cache #2149 + thinking-tag leading-only #2213 + MCP tools pagination #2210 + per-target update summaries #2207 + sweep animation tune #2212 + agent-mode cron badge #2206
This commit is contained in:
@@ -4,6 +4,26 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2210** by @Jordan-SkyLF — MCP Tools list in Settings → System no longer renders an unbounded inventory that makes the settings panel scroll-trapping. Added a toolbar (result-count summary, page-size 5/10/20/50/all, search input), bounded scroll area with consistent height, paginated rendering, and focused regression coverage for the large-inventory case. Existing WebUI-only/runtime-only contract preserved (no MCP server probing, no agent-side changes). Visual before/after evidence shipped under `docs/pr-media/2210/`.
|
||||
|
||||
- **PR #2213** by @franksong2702 (fixes #2152) — Literal `<think>`/`</think>` discussions in normal assistant prose are no longer stripped from saved messages and re-renders. The old server cleanup and stored-message render regexes stripped the first closed thinking-looking block anywhere in the content. PR aligns saved/static paths with the existing streaming rule: provider reasoning wrappers (`<think>...</think>`, MiniMax `<|channel>thought...<channel|>`, Gemma 4 `<|turn|>thinking...<turn|>`) are stripped only when they lead the response (i.e. the wrapper is the first non-whitespace content).
|
||||
|
||||
- **PR #2149** by @starship-s — `/api/session` loads no longer pay the cost of full external CLI session discovery when opening an ordinary WebUI-native chat. Caches CLI/external session scans briefly, skips CLI metadata lookup for ordinary WebUI-native session loads, and reuses a single in-memory ID snapshot during session-index pruning. Messaging, read-only, external-agent, and CLI-marked sidecars still take the CLI metadata path; CLI-only sessions still use the existing fallback. Stage-351 maintainer fix renamed the existing `_needs_cli_session_metadata()` gate to the broader `_session_requires_cli_metadata_lookup()` from this PR — strictly more inclusive (now also covers `read_only=True` sidecars, `session_source` markers, and source_tag/raw_source/platform metadata so legacy-imported sidecars still get the slow path when they need it).
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2207** by @Jordan-SkyLF (fixes #1579) — Update banner now shows target-aware "What's new?" links: WebUI updates link to the WebUI comparison, Agent updates link to the Agent comparison. Agent-only and WebUI-only update states no longer show a misleading cross-target comparison action. Opt-in settings toggle enables human-readable LLM-generated update summaries for each target's diff; users can still open the original diff from the summary. Cached/generated-summary button states persist across refreshes. Extended update-banner regression coverage for the diff-link and summary flows. Visual evidence: `docs/images/update-banner-whats-new-{before,after}.png` + summary on/off variants.
|
||||
|
||||
- **PR #2206** by @vcavichini — Cron list now shows a 🤖 emoji badge for jobs running in agent mode (`no_agent=false`). Cron detail view shows the configured provider/model next to the Mode badge, falling back to "default" when neither is explicitly set for agent-mode crons. UI-only change.
|
||||
|
||||
- **PR #2212** by @dobby-d-elf — Tunes the Activity sweep animation introduced in PR #2203 (stage-350) — softer color stops, less aggressive contrast, smoother fade. CSS-only follow-up.
|
||||
|
||||
## [v0.51.57] — 2026-05-13 — Release AG (stage-350 — 7-PR medium-risk batch — auth trilogy + cancel-status with conflict resolution + Ollama label guard + provider precedence + Activity animation + Opus dedup tightening)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Issue #2152** — Literal discussions of reasoning tags such as `<think>` and `</think>` no longer disappear from saved or re-rendered assistant messages. WebUI now treats `<think>...</think>`, MiniMax `<|channel>thought...<channel|>`, and Gemma 4 `<|turn|>thinking...<turn|>` blocks as hidden reasoning metadata only when the wrapper is the first non-whitespace content in the response; provider wrappers with leading whitespace still strip as before.
|
||||
|
||||
- **PR #2191** by @lucasrc (auth refactor 1/3) — Thread-safe login rate limiter (new `_LOGIN_ATTEMPTS_LOCK`) + PBKDF2 key separation (new `_pbkdf2_key()` reading `.pbkdf2_key` separately from `_signing_key()` reading `.signing_key` — previously both shared `.signing_key`, a key-reuse anti-pattern across HMAC and PBKDF2 primitives) + transparent migration in `verify_password()` that re-salts legacy hashes with the new key on next successful login. 241-line regression suite covering the lock + migration paths. Split from earlier #2167 per maintainer review request.
|
||||
|
||||
- **PR #2192** by @lucasrc (auth refactor 2/3, depends on #2191) — Invalidate password-hash cache when password changes via the Settings panel. The PR #2191 cache lives for the process lifetime, but `save_settings({'_set_password': ...})` could mutate `settings.json.password_hash` without telling the auth module — leaving the cache stale and verifying against the old password until restart. Now `save_settings()` calls `_invalidate_password_hash_cache()` on both `_set_password` and `_clear_password` paths. 52-line regression suite + `verify_password()` simplified to rely on the new hook instead of doing the invalidation itself.
|
||||
@@ -36,6 +56,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- MCP Tools in Settings → System now uses a bounded scroll region with 5-item default pages, a per-page selector up to 40 tools, and a visible result summary, so large MCP tool inventories no longer make the settings panel balloon indefinitely.
|
||||
|
||||
- **PR #2201** by @MrFant — Multi-turn conversations with thinking-mode providers (MiMo/Xiaomi, DeepSeek, Kimi/Moonshot) no longer 400 with `Param Incorrect: reasoning_content must be passed back`. WebUI's `_sanitize_messages_for_api()` strips fields not in `_API_SAFE_MSG_KEYS` before sending conversation history to the LLM; `reasoning_content` was missing from the whitelist, so when history was replayed on the second turn, the assistant message with `tool_calls` arrived without `reasoning_content` and providers enforcing thinking-mode echo-back rejected it. One-line fix: adds `'reasoning_content'` to `_API_SAFE_MSG_KEYS`. CLI was unaffected because `run_agent.py` has its own `_copy_reasoning_content_for_api()` that doesn't go through this filter.
|
||||
|
||||
- **PR #2198** by @Michaelyklam — Fork-from-here keep-count was off-by-one (or larger) for truncated sessions where the visible-message index didn't match the absolute transcript index. JS now sends `_oldestIdx + msgIdx` (the absolute message index in the full transcript) as `keep_count` instead of the visible-window-relative index — captured *before* `_ensureAllMessagesLoaded()` resets `_oldestIdx`, so the index remains stable. Backend `source_messages[:keep_count]` then forks from the correct point even when the user has only loaded a tail window. When the full transcript is loaded (`_oldestIdx==0`), behavior is unchanged. 186-line regression suite in `tests/test_issue2184_fork_from_here_absolute_index.py` explicitly pins `keep_count: absoluteKeepCount` (and forbids the old `keep_count: msgIdx` form).
|
||||
|
||||
@@ -3909,6 +3909,7 @@ _SETTINGS_DEFAULTS = {
|
||||
"show_cli_sessions": False, # merge CLI sessions from state.db into the sidebar
|
||||
"sync_to_insights": False, # mirror WebUI token usage to state.db for /insights
|
||||
"check_for_updates": True, # check if webui/agent repos are behind upstream
|
||||
"whats_new_summary_enabled": False, # show an LLM-written What's New summary before diff links
|
||||
"theme": "dark", # light | dark | system
|
||||
"skin": "default", # accent color skin: default | ares | mono | slate | poseidon | sisyphus | charizard
|
||||
"font_size": "default", # small | default | large
|
||||
@@ -4037,6 +4038,7 @@ _SETTINGS_BOOL_KEYS = {
|
||||
"show_cli_sessions",
|
||||
"sync_to_insights",
|
||||
"check_for_updates",
|
||||
"whats_new_summary_enabled",
|
||||
"sound_enabled",
|
||||
"notifications_enabled",
|
||||
"show_thinking",
|
||||
|
||||
+194
-104
@@ -1,5 +1,6 @@
|
||||
"""Hermes Web UI -- Session model and in-memory session store."""
|
||||
import collections
|
||||
import copy
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
@@ -22,6 +23,9 @@ from api.agent_sessions import read_importable_agent_session_rows, read_session_
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
CLI_VISIBLE_SESSION_LIMIT = 20
|
||||
_CLI_SESSIONS_CACHE_TTL_SECONDS = 5.0
|
||||
_CLI_SESSIONS_CACHE_LOCK = threading.Lock()
|
||||
_CLI_SESSIONS_CACHE = {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stale temp-file cleanup
|
||||
@@ -383,6 +387,7 @@ class Session:
|
||||
self.raw_source = kwargs.get('raw_source')
|
||||
self.session_source = kwargs.get('session_source')
|
||||
self.source_label = kwargs.get('source_label')
|
||||
self.read_only = bool(kwargs.get('read_only', False))
|
||||
self.enabled_toolsets = enabled_toolsets # List[str] or None — per-session toolset override
|
||||
self.composer_draft = composer_draft if isinstance(composer_draft, dict) else {}
|
||||
self._metadata_message_count = None
|
||||
@@ -426,7 +431,7 @@ class Session:
|
||||
'gateway_routing', 'gateway_routing_history', 'llm_title_generated',
|
||||
'parent_session_id',
|
||||
'worktree_path', 'worktree_branch', 'worktree_repo_root', 'worktree_created_at',
|
||||
'is_cli_session', 'source_tag', 'raw_source', 'session_source', 'source_label',
|
||||
'is_cli_session', 'source_tag', 'raw_source', 'session_source', 'source_label', 'read_only',
|
||||
'enabled_toolsets', 'composer_draft',
|
||||
]
|
||||
meta = {k: getattr(self, k, None) for k in METADATA_FIELDS}
|
||||
@@ -610,6 +615,7 @@ class Session:
|
||||
'raw_source': self.raw_source,
|
||||
'session_source': self.session_source,
|
||||
'source_label': self.source_label,
|
||||
'read_only': self.read_only,
|
||||
'enabled_toolsets': self.enabled_toolsets,
|
||||
'composer_draft': self.composer_draft if isinstance(self.composer_draft, dict) else {},
|
||||
'is_streaming': _is_streaming_session(
|
||||
@@ -1016,9 +1022,11 @@ def all_sessions(diag=None):
|
||||
_diag_stage(diag, "all_sessions.read_index")
|
||||
index = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
|
||||
_diag_stage(diag, "all_sessions.prune_index")
|
||||
with LOCK:
|
||||
in_memory_ids = set(SESSIONS.keys())
|
||||
index = [
|
||||
s for s in index
|
||||
if _index_entry_exists(s.get('session_id'))
|
||||
if _index_entry_exists(s.get('session_id'), in_memory_ids=in_memory_ids)
|
||||
]
|
||||
backfilled = []
|
||||
for i, s in enumerate(index):
|
||||
@@ -1536,20 +1544,51 @@ def get_claude_code_session_messages(sid, projects_dir: Path | str | None = None
|
||||
return []
|
||||
|
||||
|
||||
def get_cli_sessions() -> list:
|
||||
"""Read CLI sessions from the agent's SQLite store and return them as
|
||||
dicts in a format the WebUI sidebar can render alongside local sessions.
|
||||
def clear_cli_sessions_cache() -> None:
|
||||
with _CLI_SESSIONS_CACHE_LOCK:
|
||||
_CLI_SESSIONS_CACHE.clear()
|
||||
|
||||
Returns empty list if the SQLite DB is missing or any error occurs -- the
|
||||
bridge is purely additive and never crashes the WebUI.
|
||||
"""
|
||||
import os
|
||||
cli_sessions = []
|
||||
|
||||
def _copy_cli_sessions(sessions: list) -> list:
|
||||
return copy.deepcopy(sessions)
|
||||
|
||||
|
||||
def _cli_sessions_cache_ttl_seconds() -> float:
|
||||
try:
|
||||
cli_sessions.extend(get_claude_code_sessions())
|
||||
except Exception:
|
||||
logger.debug("Claude Code session scan failed", exc_info=True)
|
||||
return max(0.0, float(_CLI_SESSIONS_CACHE_TTL_SECONDS))
|
||||
except (TypeError, ValueError):
|
||||
return 5.0
|
||||
|
||||
|
||||
def _path_cache_key(path) -> str | None:
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
return str(Path(path).expanduser().resolve(strict=False))
|
||||
except Exception:
|
||||
return str(path)
|
||||
|
||||
|
||||
def _path_stat_cache_key(path):
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
st = Path(path).stat()
|
||||
return (st.st_mtime_ns, st.st_size)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _sqlite_file_stat_cache_key(db_path: Path):
|
||||
"""Return a cheap invalidation key for a SQLite DB and WAL sidecars."""
|
||||
return (
|
||||
_path_stat_cache_key(db_path),
|
||||
_path_stat_cache_key(Path(f"{db_path}-wal")),
|
||||
_path_stat_cache_key(Path(f"{db_path}-shm")),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_cli_sessions_context():
|
||||
# Use the active WebUI profile's HERMES_HOME to find state.db.
|
||||
# The active profile is determined by what the user has selected in the UI
|
||||
# (stored in the server's runtime config). This means:
|
||||
@@ -1564,17 +1603,35 @@ def get_cli_sessions() -> list:
|
||||
except Exception:
|
||||
hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser().resolve()
|
||||
|
||||
db_path = hermes_home / 'state.db'
|
||||
if not db_path.exists():
|
||||
return cli_sessions
|
||||
|
||||
# Try to resolve the active CLI profile so imported sessions integrate
|
||||
# with the WebUI profile filter (available since Sprint 22).
|
||||
try:
|
||||
from api.profiles import get_active_profile_name
|
||||
_cli_profile = get_active_profile_name()
|
||||
except ImportError:
|
||||
_cli_profile = None # older agent -- fall back to no profile
|
||||
cli_profile = get_active_profile_name()
|
||||
except Exception:
|
||||
cli_profile = None
|
||||
|
||||
db_path = hermes_home / 'state.db'
|
||||
projects_dir = _default_claude_code_projects_dir()
|
||||
cache_key = (
|
||||
str(hermes_home),
|
||||
str(cli_profile or ''),
|
||||
str(db_path),
|
||||
_sqlite_file_stat_cache_key(db_path),
|
||||
_path_cache_key(projects_dir),
|
||||
_path_stat_cache_key(projects_dir),
|
||||
_path_stat_cache_key(SESSION_INDEX_FILE),
|
||||
)
|
||||
return hermes_home, db_path, cli_profile, cache_key
|
||||
|
||||
|
||||
def _load_cli_sessions_uncached(hermes_home: Path, db_path: Path, _cli_profile) -> list:
|
||||
cli_sessions = []
|
||||
try:
|
||||
cli_sessions.extend(get_claude_code_sessions())
|
||||
except Exception:
|
||||
logger.debug("Claude Code session scan failed", exc_info=True)
|
||||
|
||||
if not db_path.exists():
|
||||
return cli_sessions
|
||||
|
||||
# Memoize the cron project ID for this scan so we don't pay a lock-acquire +
|
||||
# disk-read of projects.json per cron session in the loop below.
|
||||
@@ -1585,96 +1642,129 @@ def get_cli_sessions() -> list:
|
||||
_cron_pid_cache[0] = ensure_cron_project()
|
||||
return _cron_pid_cache[0]
|
||||
|
||||
try:
|
||||
for row in read_importable_agent_session_rows(
|
||||
db_path,
|
||||
limit=CLI_VISIBLE_SESSION_LIMIT,
|
||||
log=logger,
|
||||
exclude_sources=None,
|
||||
):
|
||||
sid = row['id']
|
||||
raw_ts = row['last_activity'] or row['started_at']
|
||||
# Prefer the CLI session's own profile from the DB; fall back to
|
||||
# the active CLI profile so sidebar filtering works either way.
|
||||
profile = _cli_profile # CLI DB has no profile column; use active profile
|
||||
for row in read_importable_agent_session_rows(
|
||||
db_path,
|
||||
limit=CLI_VISIBLE_SESSION_LIMIT,
|
||||
log=logger,
|
||||
exclude_sources=None,
|
||||
):
|
||||
sid = row['id']
|
||||
raw_ts = row['last_activity'] or row['started_at']
|
||||
# Prefer the CLI session's own profile from the DB; fall back to
|
||||
# the active CLI profile so sidebar filtering works either way.
|
||||
profile = _cli_profile # CLI DB has no profile column; use active profile
|
||||
|
||||
_source = row['source'] or 'cli'
|
||||
_title = row['title']
|
||||
if not _title and _source == 'cron' and sid.startswith('cron_'):
|
||||
# Extract job_id from session ID (cron_{job_id}_{timestamp})
|
||||
# and look up the human-friendly job name from jobs.json
|
||||
parts = sid.split('_')
|
||||
if len(parts) >= 3:
|
||||
_job_id = parts[1]
|
||||
try:
|
||||
_jobs_path = hermes_home / 'cron' / 'jobs.json'
|
||||
if _jobs_path.exists():
|
||||
import json as _json
|
||||
_jobs_data = _json.loads(_jobs_path.read_text())
|
||||
for _j in _jobs_data.get('jobs', []):
|
||||
if _j.get('id') == _job_id:
|
||||
_title = _j.get('name') or _title
|
||||
break
|
||||
except Exception:
|
||||
pass # degrade gracefully
|
||||
# If a WebUI JSON file exists for this session (e.g. previously
|
||||
# imported or renamed in the sidebar), prefer its title over the
|
||||
# state.db title. This fixes rename-not-persisting for CLI sessions
|
||||
# after compression chain extension (#1486).
|
||||
_source = row['source'] or 'cli'
|
||||
_title = row['title']
|
||||
if not _title and _source == 'cron' and sid.startswith('cron_'):
|
||||
# Extract job_id from session ID (cron_{job_id}_{timestamp})
|
||||
# and look up the human-friendly job name from jobs.json
|
||||
parts = sid.split('_')
|
||||
if len(parts) >= 3:
|
||||
_job_id = parts[1]
|
||||
try:
|
||||
_jobs_path = hermes_home / 'cron' / 'jobs.json'
|
||||
if _jobs_path.exists():
|
||||
import json as _json
|
||||
_jobs_data = _json.loads(_jobs_path.read_text())
|
||||
for _j in _jobs_data.get('jobs', []):
|
||||
if _j.get('id') == _job_id:
|
||||
_title = _j.get('name') or _title
|
||||
break
|
||||
except Exception:
|
||||
pass # degrade gracefully
|
||||
# If a WebUI JSON file exists for this session (e.g. previously
|
||||
# imported or renamed in the sidebar), prefer its title over the
|
||||
# state.db title. This fixes rename-not-persisting for CLI sessions
|
||||
# after compression chain extension (#1486).
|
||||
try:
|
||||
_webui_meta = Session.load_metadata_only(sid)
|
||||
if _webui_meta and getattr(_webui_meta, 'title', None):
|
||||
_title = _webui_meta.title
|
||||
except Exception:
|
||||
pass
|
||||
_display_title = _title or f'{_source.title()} Session'
|
||||
cli_sessions.append({
|
||||
'session_id': sid,
|
||||
'title': _display_title,
|
||||
'workspace': str(get_last_workspace()),
|
||||
'model': row['model'] or None,
|
||||
'message_count': row['message_count'] or row['actual_message_count'] or 0,
|
||||
'created_at': row['started_at'],
|
||||
'updated_at': raw_ts,
|
||||
'pinned': False,
|
||||
'archived': False,
|
||||
'project_id': _cron_pid() if is_cron_session(sid, _source) else None,
|
||||
'profile': profile,
|
||||
'source_tag': _source,
|
||||
'raw_source': row.get('raw_source'),
|
||||
'user_id': row.get('user_id'),
|
||||
'chat_id': row.get('chat_id') or row.get('origin_chat_id'),
|
||||
'chat_type': row.get('chat_type'),
|
||||
'thread_id': row.get('thread_id'),
|
||||
'session_key': row.get('session_key'),
|
||||
'platform': row.get('platform'),
|
||||
'session_source': row.get('session_source'),
|
||||
'source_label': row.get('source_label'),
|
||||
'parent_session_id': row.get('parent_session_id'),
|
||||
'parent_title': row.get('parent_title'),
|
||||
'parent_source': row.get('parent_source'),
|
||||
'relationship_type': row.get('relationship_type'),
|
||||
'_parent_lineage_root_id': row.get('_parent_lineage_root_id'),
|
||||
'end_reason': row.get('end_reason'),
|
||||
'actual_message_count': row.get('actual_message_count'),
|
||||
'user_message_count': row.get('actual_user_message_count'),
|
||||
'_lineage_root_id': row.get('_lineage_root_id'),
|
||||
'_lineage_tip_id': row.get('_lineage_tip_id'),
|
||||
'_compression_segment_count': row.get('_compression_segment_count'),
|
||||
'is_cli_session': True,
|
||||
})
|
||||
|
||||
return cli_sessions
|
||||
|
||||
|
||||
def get_cli_sessions() -> list:
|
||||
"""Read CLI sessions from the agent's SQLite store and return them as
|
||||
dicts in a format the WebUI sidebar can render alongside local sessions.
|
||||
|
||||
Returns empty list if the SQLite DB is missing or any error occurs -- the
|
||||
bridge is purely additive and never crashes the WebUI.
|
||||
"""
|
||||
hermes_home, db_path, cli_profile, cache_key = _resolve_cli_sessions_context()
|
||||
ttl = _cli_sessions_cache_ttl_seconds()
|
||||
now = time.monotonic()
|
||||
|
||||
if ttl > 0:
|
||||
with _CLI_SESSIONS_CACHE_LOCK:
|
||||
cached = _CLI_SESSIONS_CACHE.get(cache_key)
|
||||
if cached:
|
||||
expires_at, cached_sessions = cached
|
||||
if expires_at > now:
|
||||
return _copy_cli_sessions(cached_sessions)
|
||||
_CLI_SESSIONS_CACHE.pop(cache_key, None)
|
||||
try:
|
||||
_webui_meta = Session.load_metadata_only(sid)
|
||||
if _webui_meta and getattr(_webui_meta, 'title', None):
|
||||
_title = _webui_meta.title
|
||||
except Exception:
|
||||
pass
|
||||
_display_title = _title or f'{_source.title()} Session'
|
||||
cli_sessions.append({
|
||||
'session_id': sid,
|
||||
'title': _display_title,
|
||||
'workspace': str(get_last_workspace()),
|
||||
'model': row['model'] or None,
|
||||
'message_count': row['message_count'] or row['actual_message_count'] or 0,
|
||||
'created_at': row['started_at'],
|
||||
'updated_at': raw_ts,
|
||||
'pinned': False,
|
||||
'archived': False,
|
||||
'project_id': _cron_pid() if is_cron_session(sid, _source) else None,
|
||||
'profile': profile,
|
||||
'source_tag': _source,
|
||||
'raw_source': row.get('raw_source'),
|
||||
'user_id': row.get('user_id'),
|
||||
'chat_id': row.get('chat_id') or row.get('origin_chat_id'),
|
||||
'chat_type': row.get('chat_type'),
|
||||
'thread_id': row.get('thread_id'),
|
||||
'session_key': row.get('session_key'),
|
||||
'platform': row.get('platform'),
|
||||
'session_source': row.get('session_source'),
|
||||
'source_label': row.get('source_label'),
|
||||
'parent_session_id': row.get('parent_session_id'),
|
||||
'parent_title': row.get('parent_title'),
|
||||
'parent_source': row.get('parent_source'),
|
||||
'relationship_type': row.get('relationship_type'),
|
||||
'_parent_lineage_root_id': row.get('_parent_lineage_root_id'),
|
||||
'end_reason': row.get('end_reason'),
|
||||
'actual_message_count': row.get('actual_message_count'),
|
||||
'user_message_count': row.get('actual_user_message_count'),
|
||||
'_lineage_root_id': row.get('_lineage_root_id'),
|
||||
'_lineage_tip_id': row.get('_lineage_tip_id'),
|
||||
'_compression_segment_count': row.get('_compression_segment_count'),
|
||||
'is_cli_session': True,
|
||||
})
|
||||
sessions = _load_cli_sessions_uncached(hermes_home, db_path, cli_profile)
|
||||
except Exception as _cli_err:
|
||||
logger.warning(
|
||||
"get_cli_sessions() failed — check state.db schema or path (%s): %s",
|
||||
db_path, _cli_err,
|
||||
)
|
||||
return []
|
||||
_CLI_SESSIONS_CACHE[cache_key] = (
|
||||
time.monotonic() + ttl,
|
||||
_copy_cli_sessions(sessions),
|
||||
)
|
||||
return _copy_cli_sessions(sessions)
|
||||
|
||||
try:
|
||||
return _load_cli_sessions_uncached(hermes_home, db_path, cli_profile)
|
||||
except Exception as _cli_err:
|
||||
# DB schema changed, locked, or corrupted -- log warning so admins can diagnose.
|
||||
# Still degrade gracefully (don't crash the WebUI).
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
logger.warning(
|
||||
"get_cli_sessions() failed — check state.db schema or path (%s): %s",
|
||||
db_path, _cli_err,
|
||||
)
|
||||
return []
|
||||
|
||||
return cli_sessions
|
||||
|
||||
|
||||
def _json_loads_if_string(value):
|
||||
if not isinstance(value, str):
|
||||
|
||||
+99
-13
@@ -1525,18 +1525,6 @@ def _lookup_cli_session_metadata(session_id: str) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _needs_cli_session_metadata(session) -> bool:
|
||||
"""Return true when /api/session should pay for Agent/CLI metadata lookup."""
|
||||
if not session:
|
||||
return False
|
||||
is_cli = (
|
||||
bool(session.get("is_cli_session"))
|
||||
if isinstance(session, dict)
|
||||
else bool(getattr(session, "is_cli_session", False))
|
||||
)
|
||||
return is_cli or _is_messaging_session_record(session)
|
||||
|
||||
|
||||
def _messaging_session_identity(session: dict, raw_source: str) -> str:
|
||||
metadata = _lookup_gateway_session_identity(session.get("session_id"))
|
||||
session_key = _safe_first(
|
||||
@@ -1684,6 +1672,43 @@ def _messages_include_tool_metadata(messages) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _session_requires_cli_metadata_lookup(session) -> bool:
|
||||
"""Return True when a sidecar/session row still needs CLI metadata.
|
||||
|
||||
Legacy imported sidecars may predate the ``read_only`` field and therefore
|
||||
load with ``read_only=False``. They still persist ``is_cli_session`` and/or
|
||||
source metadata from import time, so those markers intentionally keep them
|
||||
on the CLI lookup path while ordinary WebUI-native sessions take the fast
|
||||
path.
|
||||
|
||||
Supersedes the simpler is-cli-or-messaging gate from PR #1822 — the new
|
||||
gate is strictly more inclusive (also covers ``read_only=True`` sidecars,
|
||||
``session_source`` markers, and source_tag/raw_source/platform metadata)
|
||||
so all sessions that previously took the slow path still do, plus a few
|
||||
more legacy shapes.
|
||||
"""
|
||||
if not session:
|
||||
return False
|
||||
|
||||
def _field(name):
|
||||
return session.get(name) if isinstance(session, dict) else getattr(session, name, None)
|
||||
|
||||
if _is_messaging_session_record(session):
|
||||
return True
|
||||
if bool(_field("is_cli_session")) or bool(_field("read_only")):
|
||||
return True
|
||||
session_source = _normalize_messaging_source(_safe_first(_field("session_source")))
|
||||
if session_source in {"messaging", "external_agent", "external-agent"}:
|
||||
return True
|
||||
return bool(_safe_first(
|
||||
_field("source_tag"),
|
||||
_field("raw_source"),
|
||||
_field("source"),
|
||||
_field("source_label"),
|
||||
_field("platform"),
|
||||
))
|
||||
|
||||
|
||||
def _is_messaging_session_id(sid: str) -> bool:
|
||||
"""Detect messaging-backed sessions from WebUI metadata or Agent rows."""
|
||||
try:
|
||||
@@ -3252,7 +3277,7 @@ def handle_get(handler, parsed) -> bool:
|
||||
_t1 = _time.monotonic()
|
||||
s = get_session(sid, metadata_only=(not load_messages))
|
||||
_clear_stale_stream_state(s)
|
||||
cli_meta = _lookup_cli_session_metadata(sid) if _needs_cli_session_metadata(s) else {}
|
||||
cli_meta = _lookup_cli_session_metadata(sid) if _session_requires_cli_metadata_lookup(s) else {}
|
||||
is_messaging_session = _is_messaging_session_record(s) or _is_messaging_session_record(cli_meta)
|
||||
cli_messages = []
|
||||
if is_messaging_session:
|
||||
@@ -3718,6 +3743,8 @@ def handle_get(handler, parsed) -> bool:
|
||||
"current_sha": "abc1234",
|
||||
"latest_sha": "def5678",
|
||||
"branch": "master",
|
||||
"repo_url": "https://github.com/nesquena/hermes-webui",
|
||||
"compare_url": "https://github.com/nesquena/hermes-webui/compare/abc1234...def5678",
|
||||
},
|
||||
"agent": {
|
||||
"name": "agent",
|
||||
@@ -3725,6 +3752,8 @@ def handle_get(handler, parsed) -> bool:
|
||||
"current_sha": "aaa0001",
|
||||
"latest_sha": "bbb0002",
|
||||
"branch": "master",
|
||||
"repo_url": "https://github.com/NousResearch/hermes-agent",
|
||||
"compare_url": "https://github.com/NousResearch/hermes-agent/compare/aaa0001...bbb0002",
|
||||
},
|
||||
"checked_at": 0,
|
||||
},
|
||||
@@ -5272,6 +5301,63 @@ def handle_post(handler, parsed) -> bool:
|
||||
|
||||
return j(handler, apply_force_update(target))
|
||||
|
||||
if parsed.path == "/api/updates/summary":
|
||||
from api.updates import summarize_update_payload
|
||||
|
||||
updates = body.get("updates") if isinstance(body, dict) else {}
|
||||
target = body.get("target") if isinstance(body, dict) else None
|
||||
|
||||
def _llm_update_summary(system_prompt: str, user_prompt: str) -> str:
|
||||
from run_agent import AIAgent
|
||||
from api.config import (
|
||||
get_effective_default_model,
|
||||
resolve_model_provider,
|
||||
resolve_custom_provider_connection,
|
||||
)
|
||||
|
||||
_model, _provider, _base_url = resolve_model_provider(get_effective_default_model())
|
||||
_api_key = None
|
||||
try:
|
||||
from api.oauth import resolve_runtime_provider_with_anthropic_env_lock
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
_rt = resolve_runtime_provider_with_anthropic_env_lock(
|
||||
resolve_runtime_provider,
|
||||
requested=_provider,
|
||||
)
|
||||
_api_key = _rt.get("api_key")
|
||||
if not _provider:
|
||||
_provider = _rt.get("provider")
|
||||
if not _base_url:
|
||||
_base_url = _rt.get("base_url")
|
||||
except Exception as _e:
|
||||
logger.debug("update summary runtime provider resolution failed: %s", _e)
|
||||
if isinstance(_provider, str) and _provider.startswith("custom:"):
|
||||
_cp_key, _cp_base = resolve_custom_provider_connection(_provider)
|
||||
if not _api_key and _cp_key:
|
||||
_api_key = _cp_key
|
||||
if not _base_url and _cp_base:
|
||||
_base_url = _cp_base
|
||||
agent = AIAgent(
|
||||
model=_model,
|
||||
provider=_provider,
|
||||
base_url=_base_url,
|
||||
api_key=_api_key,
|
||||
platform="webui",
|
||||
quiet_mode=True,
|
||||
enabled_toolsets=[],
|
||||
session_id=f"updates-summary-{uuid.uuid4().hex[:8]}",
|
||||
)
|
||||
result = agent.run_conversation(
|
||||
user_message=user_prompt,
|
||||
system_message=system_prompt,
|
||||
conversation_history=[],
|
||||
task_id=f"updates-summary-{uuid.uuid4().hex[:8]}",
|
||||
)
|
||||
return str(result.get("final_response") or "").strip()
|
||||
|
||||
return j(handler, summarize_update_payload(updates, llm_callback=_llm_update_summary, target=target))
|
||||
|
||||
# ── CLI session import (POST) ──
|
||||
if parsed.path == "/api/session/import_cli":
|
||||
return _handle_session_import_cli(handler, body)
|
||||
|
||||
+6
-3
@@ -689,9 +689,12 @@ def _strip_thinking_markup(text: str) -> str:
|
||||
if not text:
|
||||
return ''
|
||||
s = str(text)
|
||||
s = re.sub(r'<think>.*?</think>', ' ', s, flags=re.IGNORECASE | re.DOTALL)
|
||||
s = re.sub(r'<\|channel\|>thought.*?<channel\|>', ' ', s, flags=re.IGNORECASE | re.DOTALL)
|
||||
s = re.sub(r'<\|turn\|>thinking\n.*?<turn\|>', ' ', s, flags=re.IGNORECASE | re.DOTALL) # Gemma 4
|
||||
# Treat provider thinking wrappers as metadata only when they lead the
|
||||
# response. Literal discussion of these tags later in normal prose should
|
||||
# stay visible (#2152).
|
||||
s = re.sub(r'^\s*<think>.*?</think>\s*', ' ', s, flags=re.IGNORECASE | re.DOTALL)
|
||||
s = re.sub(r'^\s*<\|channel\|?>thought\n?.*?<channel\|>\s*', ' ', s, flags=re.IGNORECASE | re.DOTALL)
|
||||
s = re.sub(r'^\s*<\|turn\|>thinking\n.*?<turn\|>\s*', ' ', s, flags=re.IGNORECASE | re.DOTALL) # Gemma 4
|
||||
s = re.sub(r'^\s*(the|ther)\s+user\s+is\s+asking.*$', ' ', s, flags=re.IGNORECASE | re.MULTILINE)
|
||||
# Strip plain-text thinking preambles from models that don't use <think> tags (e.g. Qwen3).
|
||||
# These appear as the very first sentence of the assistant response and are not useful as titles.
|
||||
|
||||
+244
@@ -8,10 +8,14 @@ at most twice per hour regardless of client count.
|
||||
Skips repos that are not git checkouts (e.g. Docker baked images where
|
||||
.git does not exist).
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from api.config import REPO_ROOT, STREAMS, STREAMS_LOCK
|
||||
|
||||
@@ -22,6 +26,7 @@ except ImportError:
|
||||
_AGENT_DIR = None
|
||||
|
||||
_update_cache = {'webui': None, 'agent': None, 'checked_at': 0}
|
||||
_summary_cache = {}
|
||||
_cache_lock = threading.Lock()
|
||||
_check_in_progress = False
|
||||
_apply_lock = threading.Lock() # prevents concurrent stash/pull/pop on same repo
|
||||
@@ -169,6 +174,16 @@ def _normalize_remote_url(remote_url):
|
||||
return remote_url.rstrip('/')
|
||||
|
||||
|
||||
def _build_compare_url(repo_url, current_sha, latest_sha):
|
||||
"""Return a safe browser compare URL, or None when any piece is missing."""
|
||||
if not (repo_url and current_sha and latest_sha):
|
||||
return None
|
||||
parsed = urlparse(repo_url)
|
||||
if parsed.scheme not in ('http', 'https') or not parsed.netloc:
|
||||
return None
|
||||
return f"{repo_url}/compare/{current_sha}...{latest_sha}"
|
||||
|
||||
|
||||
def _split_remote_ref(ref):
|
||||
"""Split 'origin/branch-name' into ('origin', 'branch-name').
|
||||
|
||||
@@ -262,6 +277,7 @@ def _check_repo(path, name):
|
||||
'latest_sha': latest,
|
||||
'branch': compare_ref,
|
||||
'repo_url': remote_url,
|
||||
'compare_url': _build_compare_url(remote_url, current, latest),
|
||||
}
|
||||
|
||||
|
||||
@@ -289,6 +305,234 @@ def check_for_updates(force=False):
|
||||
_check_in_progress = False
|
||||
|
||||
|
||||
def _repo_path_for_update_target(target: str):
|
||||
if target == 'webui':
|
||||
return REPO_ROOT
|
||||
if target == 'agent':
|
||||
return _AGENT_DIR
|
||||
return None
|
||||
|
||||
|
||||
def _commit_subjects_for_update(info: dict, *, limit: int = 24) -> list[str]:
|
||||
"""Return commit subjects for an update range, if the local git refs exist."""
|
||||
if not isinstance(info, dict):
|
||||
return []
|
||||
target = info.get('name')
|
||||
if target not in ('webui', 'agent'):
|
||||
target = 'webui' if info.get('repo_url', '').endswith('hermes-webui') else target
|
||||
path = _repo_path_for_update_target(target)
|
||||
if path is None or not (Path(path) / '.git').exists():
|
||||
return []
|
||||
current = str(info.get('current_sha') or '').strip()
|
||||
latest = str(info.get('latest_sha') or '').strip()
|
||||
if not (current and latest):
|
||||
return []
|
||||
out, ok = _run_git(['log', '--format=%s', f'{current}..{latest}', f'-n{limit}'], path, timeout=5)
|
||||
if not ok or not out:
|
||||
return []
|
||||
return [line.strip() for line in out.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _summary_cache_key(updates: dict, details: list[dict]) -> str:
|
||||
"""Stable key for the exact update range being summarized."""
|
||||
payload = []
|
||||
for item in details:
|
||||
payload.append({
|
||||
'name': item.get('name'),
|
||||
'behind': item.get('behind'),
|
||||
'current_sha': item.get('current_sha'),
|
||||
'latest_sha': item.get('latest_sha'),
|
||||
'compare_url': item.get('compare_url'),
|
||||
})
|
||||
blob = json.dumps(payload, sort_keys=True, separators=(',', ':'))
|
||||
return hashlib.sha256(blob.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _clean_summary_bullet(line: str) -> str:
|
||||
line = re.sub(r'^\s*(?:[-*•]+|\d+[.)])\s*', '', str(line or '')).strip()
|
||||
line = re.sub(r'\s+', ' ', line)
|
||||
if not line:
|
||||
return ''
|
||||
if line[-1] not in '.!?':
|
||||
line += '.'
|
||||
return line[:240]
|
||||
|
||||
|
||||
def _summary_bullets_from_text(text: str, *, fallback_items: list[str]) -> list[str]:
|
||||
raw = str(text or '').strip()
|
||||
candidates = []
|
||||
for line in raw.splitlines():
|
||||
cleaned = _clean_summary_bullet(line)
|
||||
if cleaned:
|
||||
candidates.append(cleaned)
|
||||
if len(candidates) <= 1 and raw:
|
||||
candidates = [_clean_summary_bullet(part) for part in re.split(r'(?<=[.!?])\s+', raw)]
|
||||
candidates = [item for item in candidates if item]
|
||||
if not candidates:
|
||||
candidates = [_clean_summary_bullet(item) for item in fallback_items]
|
||||
seen = set()
|
||||
bullets = []
|
||||
for item in candidates:
|
||||
key = item.lower()
|
||||
if item and key not in seen:
|
||||
bullets.append(item)
|
||||
seen.add(key)
|
||||
if len(bullets) >= 5:
|
||||
break
|
||||
return bullets or ['Updates are available.']
|
||||
|
||||
|
||||
def _fallback_update_bullets(details: list[dict]) -> list[str]:
|
||||
bullets = []
|
||||
for item in details:
|
||||
label = item.get('label') or item.get('name') or 'Hermes'
|
||||
behind = item.get('behind') or 0
|
||||
commits = item.get('commits') or []
|
||||
if commits:
|
||||
highlights = '; '.join(commits[:3])
|
||||
bullets.append(f"{label} has {behind} update(s), including: {highlights}.")
|
||||
else:
|
||||
bullets.append(f"{label} has {behind} update(s) available.")
|
||||
return bullets or ['Updates are available.']
|
||||
|
||||
|
||||
def _worth_knowing_bullets(details: list[dict]) -> list[str]:
|
||||
targets = [
|
||||
f"{item.get('label') or item.get('name') or 'Hermes'} ({item.get('behind') or 0} update{'s' if (item.get('behind') or 0) != 1 else ''})"
|
||||
for item in details
|
||||
if item.get('behind')
|
||||
]
|
||||
if len(targets) > 1:
|
||||
return ['This summary combines updates from ' + ' and '.join(targets) + '.']
|
||||
if targets:
|
||||
return ['This summary covers ' + targets[0] + '.']
|
||||
return ['No update details were available to summarize.']
|
||||
|
||||
|
||||
def _format_update_summary_sections(summary_text: str, details: list[dict]) -> tuple[list[dict], str]:
|
||||
bullets = _summary_bullets_from_text(summary_text, fallback_items=_fallback_update_bullets(details))
|
||||
if len(bullets) > 1:
|
||||
notice_items = bullets[:3]
|
||||
worth_items = bullets[3:] or bullets[1:]
|
||||
else:
|
||||
notice_items = bullets
|
||||
worth_items = []
|
||||
worth_items = worth_items[:2] or _worth_knowing_bullets(details)
|
||||
sections = [
|
||||
{
|
||||
'title': "What you'll notice",
|
||||
'items': notice_items,
|
||||
},
|
||||
{
|
||||
'title': 'Worth knowing',
|
||||
'items': worth_items,
|
||||
},
|
||||
]
|
||||
lines = []
|
||||
for section in sections:
|
||||
lines.append(section['title'])
|
||||
lines.extend(f"- {item}" for item in section['items'])
|
||||
lines.append('')
|
||||
return sections, '\n'.join(lines).strip()
|
||||
|
||||
|
||||
def _fallback_update_summary(updates: dict, details: list[dict]) -> str:
|
||||
_sections, summary = _format_update_summary_sections('', details)
|
||||
return summary
|
||||
|
||||
|
||||
def _update_summary_prompt(details: list[dict]) -> tuple[str, str]:
|
||||
system = (
|
||||
"You write human-readable release summaries for Hermes users. "
|
||||
"Focus on what the user will notice in the product. Keep it simple, specific, and short. "
|
||||
"avoid technical jargon, implementation details, SHA names, branch names, and file paths unless necessary. "
|
||||
"Return only bullets. Do not include headings, markdown tables, intro paragraphs, or closing notes."
|
||||
)
|
||||
user_lines = [
|
||||
"Summarize these available updates as 3-5 concise bullets.",
|
||||
"Use everyday language and explain visible behavior changes, not code mechanics.",
|
||||
"Return only bullets; the WebUI will add the fixed section headings separately.",
|
||||
"",
|
||||
]
|
||||
for item in details:
|
||||
user_lines.append(f"{item['label']}: {item['behind']} commit(s) behind")
|
||||
commits = item.get('commits') or []
|
||||
if commits:
|
||||
user_lines.extend(f"- {subject}" for subject in commits)
|
||||
else:
|
||||
user_lines.append("- No local commit subjects available; summarize only the update count.")
|
||||
user_lines.append("")
|
||||
return system, '\n'.join(user_lines)
|
||||
|
||||
|
||||
def summarize_update_payload(updates: dict, llm_callback=None, *, target: str | None = None, use_cache: bool = True) -> dict:
|
||||
"""Build a human-readable What's New summary and keep regular diff comparison links.
|
||||
|
||||
``llm_callback`` receives ``(system_prompt, user_prompt)`` and returns text.
|
||||
The caller may wire that to AIAgent; this module keeps a deterministic
|
||||
fallback so the banner remains useful when no LLM provider is configured.
|
||||
Summaries are cached per exact update range so refreshes do not generate
|
||||
slightly different wording for the same available updates.
|
||||
"""
|
||||
if not isinstance(updates, dict):
|
||||
updates = {}
|
||||
requested_target = target if target in ('webui', 'agent') else None
|
||||
details = []
|
||||
for key, label in (('webui', 'WebUI'), ('agent', 'Agent')):
|
||||
if requested_target and key != requested_target:
|
||||
continue
|
||||
info = updates.get(key)
|
||||
if not isinstance(info, dict) or int(info.get('behind') or 0) <= 0:
|
||||
continue
|
||||
item = {
|
||||
'name': key,
|
||||
'label': label,
|
||||
'behind': int(info.get('behind') or 0),
|
||||
'current_sha': info.get('current_sha'),
|
||||
'latest_sha': info.get('latest_sha'),
|
||||
'compare_url': info.get('compare_url'),
|
||||
'commits': _commit_subjects_for_update({'name': key, **info}),
|
||||
}
|
||||
details.append(item)
|
||||
cache_key = _summary_cache_key(updates, details)
|
||||
if use_cache:
|
||||
with _cache_lock:
|
||||
cached = _summary_cache.get(cache_key)
|
||||
if cached:
|
||||
result = dict(cached)
|
||||
result['cached'] = True
|
||||
return result
|
||||
|
||||
generated_by = 'fallback'
|
||||
candidate = ''
|
||||
if details and callable(llm_callback):
|
||||
system, prompt = _update_summary_prompt(details)
|
||||
try:
|
||||
candidate = (llm_callback(system, prompt) or '').strip()
|
||||
if candidate:
|
||||
generated_by = 'llm'
|
||||
except Exception:
|
||||
candidate = ''
|
||||
sections, summary = _format_update_summary_sections(candidate, details)
|
||||
result = {
|
||||
'ok': True,
|
||||
'summary': summary,
|
||||
'summary_sections': sections,
|
||||
'generated_by': generated_by,
|
||||
'cached': False,
|
||||
'cache_key': cache_key,
|
||||
'target': requested_target,
|
||||
'targets': details,
|
||||
}
|
||||
if use_cache:
|
||||
with _cache_lock:
|
||||
_summary_cache[cache_key] = dict(result)
|
||||
return result
|
||||
|
||||
|
||||
# ── Self-update application ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _schedule_restart(delay: float = 2.0) -> None:
|
||||
"""Re-exec this process after *delay* seconds.
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
+5
-3
@@ -1379,6 +1379,10 @@ function applyBotName(){
|
||||
window._showCliSessions=!!s.show_cli_sessions;
|
||||
window._soundEnabled=!!s.sound_enabled;
|
||||
window._notificationsEnabled=!!s.notifications_enabled;
|
||||
// Persist default workspace so the blank new-chat page can show it
|
||||
// and workspace actions (New file/folder) work before the first session (#804).
|
||||
if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
|
||||
window._whatsNewSummaryEnabled=!!s.whats_new_summary_enabled;
|
||||
window._showThinking=s.show_thinking!==false;
|
||||
window._simplifiedToolCalling=s.simplified_tool_calling!==false;
|
||||
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
|
||||
@@ -1386,9 +1390,6 @@ function applyBotName(){
|
||||
window._sessionEndlessScrollEnabled=!!s.session_endless_scroll;
|
||||
window._botName=s.bot_name||'Hermes';
|
||||
if(s.default_model) window._defaultModel=s.default_model;
|
||||
// Persist default workspace so the blank new-chat page can show it
|
||||
// and workspace actions (New file/folder) work before the first session (#804).
|
||||
if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
|
||||
window._sessionJumpButtonsEnabled=!!s.session_jump_buttons;
|
||||
const appearance=_normalizeAppearance(s.theme,s.skin);
|
||||
localStorage.setItem('hermes-theme',appearance.theme);
|
||||
@@ -1415,6 +1416,7 @@ function applyBotName(){
|
||||
window._showCliSessions=false;
|
||||
window._soundEnabled=false;
|
||||
window._notificationsEnabled=false;
|
||||
window._whatsNewSummaryEnabled=false;
|
||||
window._showThinking=true;
|
||||
window._simplifiedToolCalling=true;
|
||||
window._sessionJumpButtonsEnabled=false;
|
||||
|
||||
+162
@@ -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…',
|
||||
|
||||
+15
-2
@@ -342,7 +342,11 @@
|
||||
<div class="update-banner" id="updateBanner">
|
||||
<div style="display:flex;flex-direction:column;flex:1;min-width:0">
|
||||
<span id="updateMsg"></span>
|
||||
<a id="updateWhatsNew" href="#" target="_blank" rel="noopener" style="font-size:11px;color:var(--accent);text-decoration:underline;display:none;margin-left:8px;white-space:nowrap">What's new?</a>
|
||||
<div id="updateWhatsNewLinks" style="display:none;font-size:11px;margin-left:8px;white-space:nowrap"></div>
|
||||
<div id="updateSummaryPanel" style="display:none;font-size:12px;line-height:1.45;margin-top:6px;padding:8px 10px;border:1px solid var(--border2);border-radius:8px;background:rgba(255,255,255,.04);max-width:720px;white-space:normal">
|
||||
<div id="updateSummaryText"></div>
|
||||
<div id="updateSummaryDiffLinks" style="display:none;font-size:11px;margin-top:8px"></div>
|
||||
</div>
|
||||
<div id="updateError" style="display:none;font-size:12px;color:var(--error,#e05);margin-top:4px;word-break:break-word"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0;flex-wrap:wrap">
|
||||
@@ -1051,6 +1055,13 @@
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_check_updates">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsWhatsNewSummary" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_whats_new_summary">Summarize What's New with AI</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_whats_new_summary">Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsBotName" data-i18n="settings_label_bot_name">Assistant Name</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_bot_name">Display name for the assistant throughout the UI. Defaults to Hermes.</div>
|
||||
@@ -1138,7 +1149,9 @@
|
||||
<label data-i18n="mcp_tools_title">MCP Tools</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:8px" data-i18n="mcp_tools_desc">Search known tools across active MCP servers.</div>
|
||||
<input type="search" id="mcpToolSearch" class="mcp-tool-search" data-i18n-placeholder="mcp_tools_search_placeholder" placeholder="Search tools by name, server, or description…" oninput="filterMcpTools()" autocomplete="off">
|
||||
<div id="mcpToolList"></div>
|
||||
<div class="mcp-tool-toolbar" id="mcpToolToolbar" aria-live="polite"></div>
|
||||
<div id="mcpToolList" class="mcp-tool-list"></div>
|
||||
<div id="mcpToolPager" class="mcp-tool-pager" aria-label="MCP tools pagination" data-i18n-aria-label="mcp_tools_pagination_label"></div>
|
||||
<div class="mcp-restart-hint" data-i18n="mcp_tools_runtime_note">Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.</div>
|
||||
</div>
|
||||
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
|
||||
|
||||
+94
-10
@@ -425,11 +425,13 @@ async function loadCrons(animate) {
|
||||
item.id = 'cron-' + job.id;
|
||||
const status = _cronStatusMeta(job);
|
||||
const isNewRun = _cronNewJobIds.has(String(job.id));
|
||||
const isAgentMode = !job.no_agent;
|
||||
const profileLabel = _cronProfileLabel(job.profile);
|
||||
const profileTitle = _cronProfileTitle(job.profile);
|
||||
item.innerHTML = `
|
||||
<div class="cron-header">
|
||||
${isNewRun ? '<span class="cron-new-dot" title="New run"></span>' : ''}
|
||||
${isAgentMode ? '<span class="cron-agent-badge" title="Agent mode">🤖</span>' : ''}
|
||||
<span class="cron-name" title="${esc(job.name)}">${esc(job.name)}</span>
|
||||
<span class="cron-profile-badge" title="${esc(profileTitle)}">${esc(profileLabel)}</span>
|
||||
<span class="cron-status ${status.listClass}">${esc(status.label)}</span>
|
||||
@@ -468,6 +470,11 @@ function _renderCronDetail(job){
|
||||
const deliver = job.deliver || 'local';
|
||||
const isNoAgent = !!job.no_agent;
|
||||
const cronJobMode = isNoAgent ? 'no-agent' : 'agent';
|
||||
const modelProvider =
|
||||
job.provider && job.model ? `${esc(job.provider)}/${esc(job.model)}` :
|
||||
job.model ? esc(job.model) :
|
||||
job.provider ? esc(job.provider) :
|
||||
isNoAgent ? '' : 'default';
|
||||
const script = job.script || '';
|
||||
const profileLabel = _cronProfileLabel(job.profile);
|
||||
const profileTitle = _cronProfileTitle(job.profile);
|
||||
@@ -498,7 +505,7 @@ function _renderCronDetail(job){
|
||||
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_next'))}</div><div class="detail-row-value">${esc(nextRun)}</div></div>
|
||||
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_last'))}</div><div class="detail-row-value">${esc(lastRun)}</div></div>
|
||||
<div class="detail-row"><div class="detail-row-label">Deliver</div><div class="detail-row-value">${esc(deliver)}</div></div>
|
||||
<div class="detail-row"><div class="detail-row-label">Mode</div><div class="detail-row-value"><span class="detail-badge" id="cronJobMode">${esc(cronJobMode)}</span></div></div>
|
||||
<div class="detail-row"><div class="detail-row-label">Mode</div><div class="detail-row-value"><span class="detail-badge" id="cronJobMode">${esc(cronJobMode)}</span>${modelProvider ? ` <code>${modelProvider}</code>` : ''}</div></div>
|
||||
${isNoAgent ? `<div class="detail-row"><div class="detail-row-label">No-agent script</div><div class="detail-row-value"><code>${esc(script || '—')}</code></div></div>` : ''}
|
||||
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_profile_label') || 'Profile')}</div><div class="detail-row-value"><span class="detail-badge active" title="${esc(profileTitle)}">${esc(profileLabel)}</span></div></div>
|
||||
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_toast_notifications_label') || 'Completion toasts')}</div><div class="detail-row-value"><span class="detail-badge ${toastNotifications ? 'active' : ''}">${esc(toastNotifications ? (t('cron_toast_notifications_enabled') || 'Enabled') : (t('cron_toast_notifications_disabled') || 'Disabled'))}</span></div></div>
|
||||
@@ -5070,6 +5077,8 @@ function _preferencesPayloadFromUi(){
|
||||
if(syncCb) payload.sync_to_insights=syncCb.checked;
|
||||
const updateCb=$('settingsCheckUpdates');
|
||||
if(updateCb) payload.check_for_updates=updateCb.checked;
|
||||
const whatsNewSummaryCb=$('settingsWhatsNewSummary');
|
||||
if(whatsNewSummaryCb) payload.whats_new_summary_enabled=whatsNewSummaryCb.checked;
|
||||
const soundCb=$('settingsSoundEnabled');
|
||||
if(soundCb) payload.sound_enabled=soundCb.checked;
|
||||
const notifCb=$('settingsNotificationsEnabled');
|
||||
@@ -5303,6 +5312,8 @@ async function loadSettingsPanel(){
|
||||
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const updateCb=$('settingsCheckUpdates');
|
||||
if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const whatsNewSummaryCb=$('settingsWhatsNewSummary');
|
||||
if(whatsNewSummaryCb){whatsNewSummaryCb.checked=!!settings.whats_new_summary_enabled;whatsNewSummaryCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const soundCb=$('settingsSoundEnabled');
|
||||
if(soundCb){soundCb.checked=!!settings.sound_enabled;soundCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
// TTS settings (localStorage-only, no server round-trip needed)
|
||||
@@ -5935,6 +5946,7 @@ function _applySavedSettingsUi(saved, body, opts){
|
||||
window._showCliSessions=showCliSessions;
|
||||
window._soundEnabled=body.sound_enabled;
|
||||
window._notificationsEnabled=body.notifications_enabled;
|
||||
window._whatsNewSummaryEnabled=!!body.whats_new_summary_enabled;
|
||||
window._showThinking=body.show_thinking!==false;
|
||||
window._simplifiedToolCalling=body.simplified_tool_calling!==false;
|
||||
window._sessionJumpButtonsEnabled=!!body.session_jump_buttons;
|
||||
@@ -5996,6 +6008,7 @@ async function checkUpdatesNow(){
|
||||
if(typeof _showUpdateBanner==='function') _showUpdateBanner(data);
|
||||
} else {
|
||||
if(status){status.textContent=t('settings_up_to_date');status.style.color='var(--success)';}
|
||||
if(typeof _showUpdateBanner==='function') _showUpdateBanner(data);
|
||||
}
|
||||
}
|
||||
} catch(e){
|
||||
@@ -6047,6 +6060,7 @@ async function saveSettings(andClose){
|
||||
body.show_cli_sessions=showCliSessions;
|
||||
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
|
||||
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
|
||||
body.whats_new_summary_enabled=!!($('settingsWhatsNewSummary')||{}).checked;
|
||||
body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked;
|
||||
body.notifications_enabled=!!($('settingsNotificationsEnabled')||{}).checked;
|
||||
body.show_thinking=window._showThinking!==false;
|
||||
@@ -6285,6 +6299,10 @@ function loadMcpServers(){
|
||||
}).catch(()=>{list.innerHTML=`<div class="mcp-error-state" style="color:#ef4444;font-size:12px;padding:6px 0">${esc(t('mcp_load_failed'))}</div>`});
|
||||
}
|
||||
let _mcpToolsCache=[];
|
||||
let _mcpToolsMeta={};
|
||||
let _mcpToolsPage=1;
|
||||
let _mcpToolsPageSize=5;
|
||||
const MCP_TOOLS_PAGE_SIZE_OPTIONS=[5,10,20,40];
|
||||
function _filterMcpToolsForSearch(tools, query){
|
||||
const q=(query||'').trim().toLowerCase();
|
||||
if(!q) return Array.isArray(tools)?tools:[];
|
||||
@@ -6301,16 +6319,56 @@ function _mcpToolSchemaText(schemaSummary){
|
||||
return `${p.name}${req}: ${p.type||'unknown'}${desc}`;
|
||||
}).join('\n');
|
||||
}
|
||||
function _renderMcpTools(tools, query){
|
||||
const list=$('mcpToolList');
|
||||
if(!list) return;
|
||||
const filtered=_filterMcpToolsForSearch(tools, query);
|
||||
if(!filtered.length){
|
||||
const key=query?'mcp_tools_no_matches':'mcp_tools_no_tools';
|
||||
list.innerHTML=`<div class="mcp-tool-empty-state" style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t(key))}</div>`;
|
||||
function _mcpToolsSummary(total, filtered, page, pages, query){
|
||||
const trimmedQuery=(query||'').trim();
|
||||
if(!filtered){
|
||||
if(trimmedQuery) return t('mcp_tools_summary_no_matches',trimmedQuery,total);
|
||||
return total?t('mcp_tools_summary_none'):'';
|
||||
}
|
||||
const pageSize=_mcpToolsPageSize||5;
|
||||
const start=(page-1)*pageSize+1;
|
||||
const end=Math.min(filtered,page*pageSize);
|
||||
const searchNote=trimmedQuery?t('mcp_tools_summary_matching',trimmedQuery):'';
|
||||
const totalNote=filtered===total?'':t('mcp_tools_summary_total_note',total);
|
||||
return t('mcp_tools_summary_showing',start,end,filtered,searchNote,totalNote,page,pages);
|
||||
}
|
||||
function _mcpToolPageSizeControl(){
|
||||
const options=MCP_TOOLS_PAGE_SIZE_OPTIONS.map(size=>`<option value="${size}" ${size===_mcpToolsPageSize?'selected':''}>${size}</option>`).join('');
|
||||
return `<label class="mcp-tool-page-size">${esc(t('mcp_tools_page_size_prefix'))} <select aria-label="${esc(t('mcp_tools_per_page_aria'))}" onchange="setMcpToolsPageSize(this.value)">${options}</select> ${esc(t('mcp_tools_page_size_suffix'))}</label>`;
|
||||
}
|
||||
function _mcpToolsEmptyMessage(query){
|
||||
const base=esc(t(query?'mcp_tools_no_matches':'mcp_tools_no_tools'));
|
||||
const unavailable=Array.isArray(_mcpToolsMeta.unavailable_servers)?_mcpToolsMeta.unavailable_servers:[];
|
||||
if(query||!unavailable.length) return base;
|
||||
return `${base}<br><span class="mcp-tool-empty-detail">${esc(t('mcp_tools_inactive_configured_servers',unavailable.join(', ')))}</span>`;
|
||||
}
|
||||
function _renderMcpToolPager(filteredCount, page, pages){
|
||||
const pager=$('mcpToolPager');
|
||||
if(!pager) return;
|
||||
if(pages<=1){
|
||||
pager.innerHTML='';
|
||||
return;
|
||||
}
|
||||
list.innerHTML=filtered.map(tool=>{
|
||||
pager.innerHTML=`<button type="button" class="mcp-tool-page-btn" onclick="setMcpToolsPage(${page-1})" ${page<=1?'disabled':''} aria-label="${esc(t('mcp_tools_previous_page_aria'))}">${esc(t('mcp_tools_previous_page'))}</button>
|
||||
<span class="mcp-tool-page-label">${page} / ${pages}</span>
|
||||
<button type="button" class="mcp-tool-page-btn" onclick="setMcpToolsPage(${page+1})" ${page>=pages?'disabled':''} aria-label="${esc(t('mcp_tools_next_page_aria'))}">${esc(t('mcp_tools_next_page'))}</button>`;
|
||||
}
|
||||
function _renderMcpTools(tools, query){
|
||||
const list=$('mcpToolList');
|
||||
const toolbar=$('mcpToolToolbar');
|
||||
if(!list) return;
|
||||
const filtered=_filterMcpToolsForSearch(tools, query);
|
||||
const total=Array.isArray(tools)?tools.length:0;
|
||||
const pages=Math.max(1,Math.ceil(filtered.length/_mcpToolsPageSize));
|
||||
_mcpToolsPage=Math.min(Math.max(1,_mcpToolsPage||1),pages);
|
||||
if(toolbar) toolbar.innerHTML=`<span class="mcp-tool-summary">${esc(_mcpToolsSummary(total,filtered.length,_mcpToolsPage,pages,query))}</span>${_mcpToolPageSizeControl()}`;
|
||||
_renderMcpToolPager(filtered.length,_mcpToolsPage,pages);
|
||||
if(!filtered.length){
|
||||
list.innerHTML=`<div class="mcp-tool-empty-state" style="color:var(--muted);font-size:12px;padding:6px 0">${_mcpToolsEmptyMessage(query)}</div>`;
|
||||
return;
|
||||
}
|
||||
const visible=filtered.slice((_mcpToolsPage-1)*_mcpToolsPageSize,_mcpToolsPage*_mcpToolsPageSize);
|
||||
list.innerHTML=visible.map(tool=>{
|
||||
const status=tool.status||'unknown';
|
||||
const statusBadge=`<span class="mcp-status-badge mcp-status-${esc(status)}">${esc(_mcpStatusLabel(status))}</span>`;
|
||||
const schemaText=_mcpToolSchemaText(tool.schema_summary);
|
||||
@@ -6325,16 +6383,42 @@ function _renderMcpTools(tools, query){
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
function filterMcpTools(){
|
||||
function setMcpToolsPage(page){
|
||||
_mcpToolsPage=page;
|
||||
const input=$('mcpToolSearch');
|
||||
_renderMcpTools(_mcpToolsCache,input?input.value:'');
|
||||
const list=$('mcpToolList');
|
||||
if(list) list.scrollTop=0;
|
||||
}
|
||||
function setMcpToolsPageSize(size){
|
||||
const next=Number(size);
|
||||
if(!MCP_TOOLS_PAGE_SIZE_OPTIONS.includes(next)) return;
|
||||
_mcpToolsPageSize=next;
|
||||
_mcpToolsPage=1;
|
||||
const input=$('mcpToolSearch');
|
||||
_renderMcpTools(_mcpToolsCache,input?input.value:'');
|
||||
const list=$('mcpToolList');
|
||||
if(list) list.scrollTop=0;
|
||||
}
|
||||
function filterMcpTools(){
|
||||
_mcpToolsPage=1;
|
||||
const input=$('mcpToolSearch');
|
||||
_renderMcpTools(_mcpToolsCache,input?input.value:'');
|
||||
const list=$('mcpToolList');
|
||||
if(list) list.scrollTop=0;
|
||||
}
|
||||
function loadMcpTools(){
|
||||
const list=$('mcpToolList');
|
||||
const toolbar=$('mcpToolToolbar');
|
||||
const pager=$('mcpToolPager');
|
||||
if(!list) return;
|
||||
if(toolbar) toolbar.textContent='';
|
||||
if(pager) pager.innerHTML='';
|
||||
list.innerHTML=`<div style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t('loading'))}</div>`;
|
||||
api('/api/mcp/tools').then(r=>{
|
||||
_mcpToolsCache=(r&&Array.isArray(r.tools))?r.tools:[];
|
||||
_mcpToolsMeta=r||{};
|
||||
_mcpToolsPage=1;
|
||||
filterMcpTools();
|
||||
}).catch(()=>{list.innerHTML=`<div class="mcp-tool-error-state" style="color:#ef4444;font-size:12px;padding:6px 0">${esc(t('mcp_tools_load_failed'))}</div>`});
|
||||
}
|
||||
|
||||
+17
-5
@@ -1891,12 +1891,12 @@ body.resizing .sidebar{transition:none!important;}
|
||||
color:var(--muted);
|
||||
}
|
||||
@keyframes _tool-shimmer-sweep{
|
||||
0%{-webkit-mask-position:100% 0;mask-position:100% 0;}
|
||||
100%{-webkit-mask-position:-200% 0;mask-position:-200% 0;}
|
||||
0%{-webkit-mask-position:150% 0;mask-position:150% 0;}
|
||||
100%{-webkit-mask-position:-150% 0;mask-position:-150% 0;}
|
||||
}
|
||||
.tool-call-group[data-live-tool-call-group="1"] .tool-call-group-label::after{
|
||||
--activity-sweep-highlight:linear-gradient(90deg,var(--accent) 0%,var(--accent) 45.2%,color-mix(in srgb,var(--accent) 90%,#000) 46.5%,color-mix(in srgb,var(--accent) 90%,#000) 53.5%,var(--accent) 55%,var(--accent) 100%);
|
||||
--activity-sweep-mask:linear-gradient(90deg,rgba(0,0,0,0) 0%,rgba(0,0,0,0) 38%,rgba(0,0,0,.18) 40.8%,rgba(0,0,0,.46) 43.6%,rgba(0,0,0,.72) 46.5%,rgba(0,0,0,.9) 53.5%,rgba(0,0,0,.52) 55.8%,rgba(0,0,0,.28) 58.2%,rgba(0,0,0,.1) 60.4%,rgba(0,0,0,0) 62%,rgba(0,0,0,0) 100%);
|
||||
--activity-sweep-highlight:linear-gradient(90deg,var(--accent) 0%,var(--accent) 40.4%,color-mix(in srgb,var(--accent) 90%,#000) 43%,color-mix(in srgb,var(--accent) 90%,#000) 57%,var(--accent) 60%,var(--accent) 100%);
|
||||
--activity-sweep-mask:linear-gradient(90deg,rgba(0,0,0,0) 0%,rgba(0,0,0,0) 26%,rgba(0,0,0,.18) 31.6%,rgba(0,0,0,.46) 37.2%,rgba(0,0,0,.72) 43%,rgba(0,0,0,.9) 57%,rgba(0,0,0,.52) 61.6%,rgba(0,0,0,.28) 66.4%,rgba(0,0,0,.1) 70.8%,rgba(0,0,0,0) 74%,rgba(0,0,0,0) 100%);
|
||||
content:attr(data-sweep-label);
|
||||
position:absolute;inset:0;
|
||||
color:var(--accent);
|
||||
@@ -1911,7 +1911,7 @@ body.resizing .sidebar{transition:none!important;}
|
||||
mask-size:250% 100%;
|
||||
-webkit-mask-repeat:no-repeat;
|
||||
mask-repeat:no-repeat;
|
||||
animation:_tool-shimmer-sweep 3s cubic-bezier(.45,0,.55,1) infinite;
|
||||
animation:_tool-shimmer-sweep 3.0s cubic-bezier(.45,0,.55,1) infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
.tool-call-group[data-live-tool-call-group="1"] .tool-call-group-label::after{
|
||||
@@ -2493,10 +2493,21 @@ main.main.showing-logs > #mainLogs{display:flex;}
|
||||
.mcp-readonly-note,.mcp-restart-hint{margin-top:8px;color:var(--muted);font-size:11px;line-height:1.45;background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;padding:8px 10px;}
|
||||
.mcp-tool-search{width:100%;margin:0 0 8px 0;padding:8px 10px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:8px;font-size:12px;outline:none;}
|
||||
.mcp-tool-search:focus{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-bg-soft);}
|
||||
.mcp-tool-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0 0 8px;color:var(--muted);font-size:11px;line-height:1.35;flex-wrap:wrap;}
|
||||
.mcp-tool-summary{min-width:0;}
|
||||
.mcp-tool-page-size{display:inline-flex;align-items:center;gap:5px;white-space:nowrap;color:var(--muted);font-size:11px;}
|
||||
.mcp-tool-page-size select{appearance:none;border:1px solid var(--border2);background:var(--code-bg);color:var(--text);border-radius:7px;padding:4px 22px 4px 8px;font-size:11px;font-weight:600;line-height:1.2;cursor:pointer;}
|
||||
.mcp-tool-empty-detail{display:inline-block;margin-top:4px;color:var(--muted);font-size:11px;line-height:1.35;}
|
||||
.mcp-tool-list{max-height:min(52vh,560px);overflow:auto;padding-right:3px;scrollbar-gutter:stable;}
|
||||
.mcp-tool-row{display:flex;flex-direction:column;gap:5px;padding:9px 10px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;font-size:12px;background:var(--surface);}
|
||||
.mcp-tool-name{font-weight:600;color:var(--text);overflow-wrap:anywhere;}
|
||||
.mcp-tool-server{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);background:var(--code-bg);border:1px solid var(--border2);border-radius:999px;padding:2px 6px;}
|
||||
.mcp-tool-schema{margin:2px 0 0 0;padding:7px 8px;white-space:pre-wrap;max-height:140px;overflow:auto;background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;color:var(--muted);font-size:11px;line-height:1.45;}
|
||||
.mcp-tool-pager{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:8px;}
|
||||
.mcp-tool-page-label{color:var(--muted);font-size:11px;min-width:44px;text-align:center;}
|
||||
.mcp-tool-page-btn{border:1px solid var(--border2);background:var(--code-bg);color:var(--text);border-radius:7px;padding:5px 9px;font-size:11px;font-weight:600;cursor:pointer;}
|
||||
.mcp-tool-page-btn:hover:not(:disabled){border-color:var(--accent-bg-strong);color:var(--accent-text);}
|
||||
.mcp-tool-page-btn:disabled{opacity:.45;cursor:not-allowed;}
|
||||
|
||||
/* Picker grids (theme / skin / font-size): make the card chrome use
|
||||
tokens so all skins flip correctly. */
|
||||
@@ -2742,6 +2753,7 @@ main.main.showing-logs > #mainLogs{display:flex;}
|
||||
/* ── Cron alert badge ── */
|
||||
.cron-badge{position:absolute;top:2px;right:2px;background:#e53e3e;color:#fff;font-size:9px;font-weight:700;min-width:14px;height:14px;line-height:14px;text-align:center;border-radius:7px;padding:0 3px;}
|
||||
.cron-new-dot{width:7px;height:7px;border-radius:50%;background:var(--success,#22c55e);flex-shrink:0;animation:cron-dot-pulse 2s ease-in-out infinite;}
|
||||
.cron-agent-badge{flex-shrink:0;font-size:12px;line-height:1;}
|
||||
@keyframes cron-dot-pulse{0%,100%{opacity:1;}50%{opacity:.4;}}
|
||||
.has-new-run{border-color:var(--success,#22c55e)!important;box-shadow:0 0 0 1px var(--success,#22c55e);}
|
||||
|
||||
|
||||
+243
-29
@@ -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 /<think>[\s\S]*?<\/think>|<\|channel>thought\n[\s\S]*?<channel\|>|<\|turn\|>thinking\n[\s\S]*?<turn\|>/.test(String(m.content||''));
|
||||
return /^\s*(?:<think>[\s\S]*?<\/think>|<\|channel\|?>thought\n?[\s\S]*?<channel\|>|<\|turn\|>thinking\n[\s\S]*?<turn\|>)/.test(String(m.content||''));
|
||||
}
|
||||
function _formatTurnTps(value){
|
||||
const n=Number(value);
|
||||
@@ -5101,25 +5315,25 @@ function renderMessages(options){
|
||||
}
|
||||
if(!thinkingText && m.reasoning) thinkingText=m.reasoning;
|
||||
if(!thinkingText && typeof content==='string'){
|
||||
const thinkMatch=content.match(/<think>([\s\S]*?)<\/think>/);
|
||||
const thinkMatch=content.match(/^\s*<think>([\s\S]*?)<\/think>\s*/);
|
||||
if(thinkMatch){
|
||||
thinkingText=thinkMatch[1].trim();
|
||||
content=content.replace(/<think>[\s\S]*?<\/think>\s*/,'').trimStart();
|
||||
content=content.replace(/^\s*<think>[\s\S]*?<\/think>\s*/,'').trimStart();
|
||||
}
|
||||
if(!thinkingText){
|
||||
// Historical name "gemmaMatch" refers to MiniMax <|channel>thought format.
|
||||
const gemmaMatch=content.match(/<\|channel>thought\n([\s\S]*?)<channel\|>/);
|
||||
const gemmaMatch=content.match(/^\s*<\|channel\|?>thought\n?([\s\S]*?)<channel\|>\s*/);
|
||||
if(gemmaMatch){
|
||||
thinkingText=gemmaMatch[1].trim();
|
||||
content=content.replace(/<\|channel>thought\n[\s\S]*?<channel\|>\s*/,'').trimStart();
|
||||
content=content.replace(/^\s*<\|channel\|?>thought\n?[\s\S]*?<channel\|>\s*/,'').trimStart();
|
||||
}
|
||||
}
|
||||
if(!thinkingText){
|
||||
// Gemma 4 uses asymmetric <|turn|>thinking\n...<turn|> delimiters.
|
||||
const gemmaTurnMatch=content.match(/<\|turn\|>thinking\n([\s\S]*?)<turn\|>/);
|
||||
const gemmaTurnMatch=content.match(/^\s*<\|turn\|>thinking\n([\s\S]*?)<turn\|>\s*/);
|
||||
if(gemmaTurnMatch){
|
||||
thinkingText=gemmaTurnMatch[1].trim();
|
||||
content=content.replace(/<\|turn\|>thinking\n[\s\S]*?<turn\|>\s*/,'').trimStart();
|
||||
content=content.replace(/^\s*<\|turn\|>thinking\n[\s\S]*?<turn\|>\s*/,'').trimStart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -64,6 +64,36 @@ class TestGemma4ThinkingTokenStrip:
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == "Answer"
|
||||
|
||||
def test_mid_sentence_think_tags_are_preserved(self):
|
||||
"""Literal discussion of <think> tags in visible prose must survive (#2152)."""
|
||||
raw = "The literal tags <think> and </think> describe reasoning markup."
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == raw
|
||||
|
||||
def test_mid_sentence_closed_think_block_is_preserved(self):
|
||||
"""Only leading provider wrappers are stripped; later tag-looking text stays visible."""
|
||||
raw = "Use <think>scratchpad</think> as an example in the answer."
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == raw
|
||||
|
||||
def test_mid_sentence_channel_tokens_are_preserved(self):
|
||||
"""MiniMax-style channel markers later in prose are visible content, not metadata."""
|
||||
raw = "Use <|channel>thought to start reasoning and <channel|> to finish."
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == raw
|
||||
|
||||
def test_mid_sentence_gemma4_tokens_are_preserved(self):
|
||||
"""Gemma 4 thinking markers later in prose are visible content, not metadata."""
|
||||
raw = "Use <|turn|>thinking to start reasoning and <turn|> to finish."
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == raw
|
||||
|
||||
def test_minimax_channel_without_second_pipe_still_stripped_at_start(self):
|
||||
"""The client-facing MiniMax <|channel>thought shape is stripped when leading."""
|
||||
raw = "<|channel>thought\nSome reasoning<channel|>Answer"
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == "Answer"
|
||||
|
||||
|
||||
class TestGemma4TitleLeakDetection:
|
||||
"""Verify _looks_invalid_generated_title catches Gemma 4 leak."""
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -123,6 +123,53 @@ def test_all_sessions_backfills_last_message_at_for_legacy_index_rows():
|
||||
assert persisted[0].get("last_message_at") == 100.0
|
||||
|
||||
|
||||
def test_all_sessions_prune_reuses_in_memory_id_snapshot(monkeypatch):
|
||||
"""Index pruning should not reacquire the session lock for every row."""
|
||||
index_file = models.SESSION_INDEX_FILE
|
||||
entries = [
|
||||
{
|
||||
"session_id": "sess_a",
|
||||
"title": "Alpha",
|
||||
"updated_at": 200.0,
|
||||
"last_message_at": 200.0,
|
||||
"workspace": "/tmp",
|
||||
"model": "test",
|
||||
"message_count": 1,
|
||||
"created_at": 100.0,
|
||||
"pinned": False,
|
||||
"archived": False,
|
||||
},
|
||||
{
|
||||
"session_id": "sess_b",
|
||||
"title": "Bravo",
|
||||
"updated_at": 150.0,
|
||||
"last_message_at": 150.0,
|
||||
"workspace": "/tmp",
|
||||
"model": "test",
|
||||
"message_count": 1,
|
||||
"created_at": 90.0,
|
||||
"pinned": False,
|
||||
"archived": False,
|
||||
},
|
||||
]
|
||||
_write_index_file(index_file, entries)
|
||||
|
||||
seen = []
|
||||
|
||||
def _assert_snapshot_used(session_id, in_memory_ids=None):
|
||||
assert in_memory_ids is not None, "all_sessions should snapshot SESSIONS once before pruning"
|
||||
seen.append(session_id)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(models, "_index_entry_exists", _assert_snapshot_used)
|
||||
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
|
||||
|
||||
rows = models.all_sessions()
|
||||
|
||||
assert [row["session_id"] for row in rows] == ["sess_a", "sess_b"]
|
||||
assert seen == ["sess_a", "sess_b"]
|
||||
|
||||
|
||||
# ── 6. test_incremental_patch_correctness ─────────────────────────────────
|
||||
|
||||
def test_incremental_patch_correctness():
|
||||
|
||||
+39
-22
@@ -5,7 +5,6 @@ Covers the static render path (ui.js regex logic, verified against the JS source
|
||||
and the streaming render path (messages.js _streamDisplay logic).
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text()
|
||||
@@ -14,24 +13,25 @@ MSG_JS = (REPO_ROOT / "static" / "messages.js").read_text()
|
||||
|
||||
# ── ui.js: static render path ────────────────────────────────────────────────
|
||||
|
||||
def test_think_regex_has_no_anchor():
|
||||
"""The <think> regex in ui.js must not use a ^ anchor so leading whitespace is allowed."""
|
||||
def test_think_regex_is_leading_only_after_optional_whitespace():
|
||||
"""The <think> regex in ui.js must anchor after optional whitespace."""
|
||||
# Find the thinkMatch line by locating the .match( call on that line
|
||||
idx = UI_JS.find("const thinkMatch=content.match(")
|
||||
assert idx >= 0, "thinkMatch line not found in ui.js"
|
||||
line = UI_JS[idx:idx+100]
|
||||
# The regex must NOT start with ^ right after the opening /
|
||||
assert "/^<think>" not in line and "(/^" not in line, \
|
||||
f"thinkMatch regex must not use ^ anchor — found: {line.strip()}"
|
||||
assert "/^\\s*<think>" in line, \
|
||||
f"thinkMatch regex must only match leading <think> blocks after whitespace — found: {line.strip()}"
|
||||
assert "/^<think>" not in line, \
|
||||
f"thinkMatch regex must still allow leading whitespace — found: {line.strip()}"
|
||||
|
||||
|
||||
def test_gemma_regex_has_no_anchor():
|
||||
"""The Gemma channel-token regex in ui.js must not use a ^ anchor."""
|
||||
match = re.search(r'const gemmaMatch=content\.match\((/[^/]+/)\)', UI_JS)
|
||||
assert match, "gemmaMatch line not found in ui.js"
|
||||
pattern = match.group(1)
|
||||
assert not pattern.startswith('/^'), \
|
||||
f"gemmaMatch regex must not use ^ anchor — got {pattern}"
|
||||
def test_gemma_regex_is_leading_only_after_optional_whitespace():
|
||||
"""The MiniMax channel-token regex in ui.js must anchor after optional whitespace."""
|
||||
idx = UI_JS.find("const gemmaMatch=content.match(")
|
||||
assert idx >= 0, "gemmaMatch line not found in ui.js"
|
||||
line = UI_JS[idx:idx+140]
|
||||
assert "/^\\s*<\\|channel\\|?>thought\\n?" in line, \
|
||||
f"gemmaMatch regex must only match leading channel blocks after whitespace — found: {line.strip()}"
|
||||
|
||||
|
||||
def test_think_content_removal_uses_replace_not_slice():
|
||||
@@ -42,6 +42,8 @@ def test_think_content_removal_uses_replace_not_slice():
|
||||
block = UI_JS[idx:idx+200]
|
||||
assert "content.replace(" in block, \
|
||||
"ui.js must use content.replace() to remove <think> block (not .slice())"
|
||||
assert "content.replace(/^\\s*<think>" in block, \
|
||||
"ui.js must remove only leading <think> blocks after optional whitespace"
|
||||
assert ".trimStart()" in block, \
|
||||
"ui.js must call .trimStart() on content after removing the <think> block"
|
||||
|
||||
@@ -53,6 +55,8 @@ def test_gemma_content_removal_uses_replace_not_slice():
|
||||
block = UI_JS[idx:idx+200]
|
||||
assert "content.replace(" in block, \
|
||||
"ui.js must use content.replace() to remove Gemma channel block (not .slice())"
|
||||
assert "content.replace(/^\\s*<\\|channel\\|?>thought\\n?" in block, \
|
||||
"ui.js must remove only leading Gemma channel blocks after optional whitespace"
|
||||
assert ".trimStart()" in block, \
|
||||
"ui.js must call .trimStart() on content after removing the Gemma channel block"
|
||||
|
||||
@@ -65,11 +69,11 @@ def test_gemma_turn_regex_in_ui_js():
|
||||
" (note: double-pipe: <|turn|> not <|turn>)"
|
||||
)
|
||||
# Extraction block
|
||||
match = re.search(r'const gemmaTurnMatch=content\.match\((/[^/]+/)\)', UI_JS)
|
||||
assert match, "gemmaTurnMatch line not found in ui.js"
|
||||
pattern = match.group(1)
|
||||
assert not pattern.startswith('/^'), (
|
||||
f"gemmaTurnMatch regex must not use ^ anchor — got {pattern}"
|
||||
idx = UI_JS.find("const gemmaTurnMatch=content.match(")
|
||||
assert idx >= 0, "gemmaTurnMatch line not found in ui.js"
|
||||
line = UI_JS[idx:idx+140]
|
||||
assert "/^\\s*<\\|turn\\|>thinking\\n" in line, (
|
||||
f"gemmaTurnMatch regex must only match leading Gemma 4 blocks after whitespace — found: {line.strip()}"
|
||||
)
|
||||
|
||||
|
||||
@@ -81,11 +85,24 @@ def test_gemma_turn_content_removal_uses_replace_not_slice():
|
||||
assert "content.replace(" in block, (
|
||||
"ui.js must use content.replace() to remove Gemma 4 turn block (not .slice())"
|
||||
)
|
||||
assert "content.replace(/^\\s*<\\|turn\\|>thinking\\n" in block, (
|
||||
"ui.js must remove only leading Gemma 4 turn blocks after optional whitespace"
|
||||
)
|
||||
assert ".trimStart()" in block, (
|
||||
"ui.js must call .trimStart() on content after removing the Gemma 4 turn block"
|
||||
)
|
||||
|
||||
|
||||
def test_message_reasoning_payload_detection_is_leading_only():
|
||||
"""Persisted literal tag discussion later in content must not create a thinking card."""
|
||||
idx = UI_JS.find("function _messageHasReasoningPayload(m)")
|
||||
assert idx >= 0, "_messageHasReasoningPayload function not found in ui.js"
|
||||
block = UI_JS[idx:idx+500]
|
||||
assert "return /^\\s*(?:<think>" in block, (
|
||||
"_messageHasReasoningPayload must only detect leading provider thinking wrappers"
|
||||
)
|
||||
|
||||
|
||||
# ── messages.js: streaming render path ───────────────────────────────────────
|
||||
|
||||
def test_stream_display_trims_before_startswith():
|
||||
@@ -128,13 +145,13 @@ def test_stream_display_trims_return_after_close():
|
||||
|
||||
# ── Regression: existing anchored patterns must be gone ──────────────────────
|
||||
|
||||
def test_no_anchored_think_regex_in_ui_js():
|
||||
"""The old anchored regex /^<think>/ must not exist in ui.js."""
|
||||
def test_no_strictly_anchored_think_regex_in_ui_js():
|
||||
"""The old /^<think>/ shape must not return; leading whitespace remains supported."""
|
||||
assert "/^<think>" not in UI_JS, \
|
||||
"Old anchored /^<think>/ regex still present in ui.js — fix not applied"
|
||||
|
||||
|
||||
def test_no_anchored_gemma_regex_in_ui_js():
|
||||
"""The old anchored Gemma regex must not exist in ui.js."""
|
||||
def test_no_strictly_anchored_gemma_regex_in_ui_js():
|
||||
"""The old /^<|channel>/ shape must not return; leading whitespace remains supported."""
|
||||
assert "/^<|channel>" not in UI_JS, \
|
||||
"Old anchored /^<|channel>/ regex still present in ui.js — fix not applied"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user