mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Merge branch 'nesquena:master' into fix/provider-qualified-session-models
This commit is contained in:
@@ -2,6 +2,22 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.50.252] — 2026-05-01
|
||||
|
||||
### Fixed
|
||||
- **CLI session import no longer crashes when metadata row is missing** — `_handle_session_import_cli` only assigned `model` inside the `for cs in get_cli_sessions(): if cs["session_id"] == sid` loop. Sessions that existed in the messages store but were missing from the metadata index (post-pruning, race during cron job export, etc.) reached the downstream `import_cli_session(sid, title, msgs, model, ...)` call with `model` unbound and crashed with `UnboundLocalError`. The fix initializes `model = "unknown"` before the loop so the import proceeds with a sensible default. Added a regression test that asserts the init lives before the loop. (`api/routes.py`, `tests/test_session_import_cli_fallback_model.py`) @trucuit — PR #1386
|
||||
- **Streaming scroll no longer yanks the viewport when tool/queue cards insert** (#1360) — three independent paths could re-pin a user mid-read while the agent streamed: (a) browser scroll-anchoring on `#messages` shifted the scroller when card heights changed, (b) the queue-card render `setTimeout` called unconditional `scrollToBottom()` regardless of stream state, and (c) the queue-pill click handler did the same. Now `#messages` has `overflow-anchor:none`, the near-bottom re-pin dead zone widens from 150px to 250px (small macOS-app windows + trackpad momentum no longer re-pin too eagerly), and both queue-card paths respect `S.activeStreamId` — using `scrollIfPinned()` mid-stream and falling back to `scrollToBottom()` only after the stream ends. 4 regression tests pin all four invariants. (`static/style.css`, `static/ui.js`, `tests/test_issue1360_streaming_scroll_hardening.py`) @NocGeek — PR #1377, fixes #1360
|
||||
- **API credential redaction no longer regresses for `ghp_*` / `sk-*` / `hf_*` / `AKIA*` tokens** — `_build_redact_fn()` previously returned the agent's `redact_sensitive_text` directly whenever `agent.redact` imported. The agent redactor missed several common credential prefixes that the WebUI's local fallback already knew how to mask, so session/search/memory API responses could leak plaintext credentials. Now both run in series — agent first (handles broader patterns when `HERMES_REDACT_SECRETS` is enabled), local fallback second (always-on, catches the common token shapes). The chained order is safe: agent masking shortens tokens to a `prefix...suffix` form that the fallback regex's character class no longer matches, so no double-redaction. The agent-broader patterns (Stripe `sk_live_`, Google `AIza…`, JWT `eyJ…`) still depend on the env var; opening a follow-up to switch the WebUI call to `force=True`. (`api/helpers.py`) @NocGeek — PR #1379
|
||||
- **`/status` slash command shows the resolved Hermes home directory** (refs #463) — the WebUI `/status` card already showed model, profile, workspace, timestamps, and token counts but was missing the profile-aware Hermes home path that the CLI's `hermes status` displays. `session_status()` now returns `profile` and `hermes_home` keys (resolved via `get_hermes_home_for_profile()` so named profiles resolve to their dedicated dirs), and `commands.js cmdStatus` renders the new `Hermes home:` line. New `status_hermes_home` i18n key added across all 8 locales (en/ru/es/de/zh/zh-Hant/pt/ko). (`api/session_ops.py`, `static/commands.js`, `static/i18n.js`, `tests/test_session_ops.py`) @NocGeek — PR #1380, refs #463
|
||||
|
||||
### Added
|
||||
- **`/api/models/live` now caches results for 60 seconds** — repeated model-list refreshes (every panel open, every workspace switch) hit upstream provider APIs every time. The new in-memory TTL cache keyed by `(active_profile, provider)` returns deep copies so callers can't mutate the cache, expires after 60s, and is guarded by `threading.RLock` for thread-safety. The cache lives next to `_handle_live_models` and is cleared via `_clear_live_models_cache()` in tests. 4 regression tests cover hit-within-TTL, expiry, profile-scoping (default vs research stay separate), and mutation isolation. (`api/routes.py`, `tests/test_live_models_ttl_cache.py`) @NocGeek — PR #1378
|
||||
- **WebUI explains CLI-only slash commands instead of forwarding them to the model** — typing `/browser connect` or any other Hermes CLI-only command in the WebUI used to fall through as plain text, so the model would explain the command instead of the app. The frontend now lazy-fetches `/api/commands` metadata, matches by name and aliases, and intercepts any command flagged `cli_only` with a local assistant message that explains the command is CLI-only. Special note for `/browser` about how WebUI's browser tools must be configured server-side (CLI-only `/browser` itself does not work in the WebUI). Built on the existing `cli_only` field that `/api/commands` already exposed; no agent-side changes. (`static/commands.js`, `static/messages.js`, `tests/test_cli_only_slash_commands.py`) @NocGeek — PR #1382
|
||||
|
||||
### Changed
|
||||
- **API credential redaction now uses `force=True`** — `_combined_redact` (introduced by #1379) now passes `force=True` to `redact_sensitive_text` so the agent's broader patterns (Stripe `sk_live_`, Google `AIza…`, JWT `eyJ…`, DB connection strings, Telegram bot tokens) run regardless of the user's `HERMES_REDACT_SECRETS` opt-in. The local fallback then handles the short-prefix shapes the agent omits (`ghp_`, `sk-`, `hf_`, `AKIA`). WebUI API responses are a hard safety boundary — no opt-in should be required. (`api/helpers.py`) — Opus pre-release follow-up
|
||||
- **`_active_profile_for_live_models_cache` logs the fallback path** — when `get_active_profile_name()` raises (transient state, mid-switch, etc.) the live-models cache (#1378) falls back to `"default"`, mis-scoping the cache for up to 60s. Now logs at debug so we can detect this in production logs without changing the blast radius (TTL still caps the bad-cache window). (`api/routes.py`) — Opus pre-release follow-up
|
||||
|
||||
## [v0.50.251] — 2026-04-30
|
||||
|
||||
### Fixed
|
||||
|
||||
+20
-9
@@ -110,14 +110,10 @@ MAX_BODY_BYTES = 20 * 1024 * 1024 # 20MB limit for non-upload POST bodies
|
||||
# ── Credential redaction ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_redact_fn():
|
||||
"""Return redact_sensitive_text from hermes-agent if available, else a fallback."""
|
||||
try:
|
||||
from agent.redact import redact_sensitive_text
|
||||
return redact_sensitive_text
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Minimal fallback covering the most common credential prefixes
|
||||
"""Return a redactor backed by hermes-agent plus local fallback patterns."""
|
||||
# Minimal fallback covering the most common credential prefixes.
|
||||
# Keep this active even when hermes-agent is importable so API responses do
|
||||
# not regress if the agent redactor misses a token shape.
|
||||
_CRED_RE = _re.compile(
|
||||
r"(?<![A-Za-z0-9_-])("
|
||||
r"sk-[A-Za-z0-9_-]{10,}" # OpenAI / Anthropic / OpenRouter
|
||||
@@ -156,7 +152,22 @@ def _build_redact_fn():
|
||||
text = _PRIVKEY_RE.sub("[REDACTED PRIVATE KEY]", text)
|
||||
return text
|
||||
|
||||
return _fallback_redact
|
||||
try:
|
||||
from agent.redact import redact_sensitive_text
|
||||
except ImportError:
|
||||
return _fallback_redact
|
||||
|
||||
def _combined_redact(text: str) -> str:
|
||||
if not isinstance(text, str) or not text:
|
||||
return text
|
||||
# WebUI API responses are a hard safety boundary — pass force=True so the
|
||||
# agent's broader patterns (Stripe sk_live_, Google AIza…, JWT eyJ…, DB
|
||||
# connection strings, Telegram bot tokens) run regardless of the user's
|
||||
# HERMES_REDACT_SECRETS opt-in. The local fallback then handles the
|
||||
# common short-prefix shapes the agent omits (ghp_, sk-, hf_, AKIA).
|
||||
return _fallback_redact(redact_sensitive_text(text, force=True))
|
||||
|
||||
return _combined_redact
|
||||
|
||||
|
||||
_redact_text = _build_redact_fn()
|
||||
|
||||
+57
-6
@@ -4,6 +4,7 @@ Extracted from server.py (Sprint 11) so server.py is a thin shell.
|
||||
"""
|
||||
|
||||
import html as _html
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -143,9 +144,49 @@ _OPENAI_COMPAT_ENDPOINTS = {
|
||||
# the openai provider is already wired through provider_model_ids(); codex-
|
||||
# specific model filtering happens downstream in hermes_cli.
|
||||
#
|
||||
# TODO: Add TTL-based caching (e.g. 60s) so repeated model-list requests
|
||||
# don't hit provider APIs. The frontend already caches via _liveModelCache
|
||||
# but the backend re-fetches on every /api/models/live call.
|
||||
_LIVE_MODELS_CACHE_TTL = 60.0
|
||||
_LIVE_MODELS_CACHE: dict[tuple[str, str], tuple[float, dict]] = {}
|
||||
_LIVE_MODELS_CACHE_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def _active_profile_for_live_models_cache() -> str:
|
||||
try:
|
||||
from api.profiles import get_active_profile_name
|
||||
|
||||
return get_active_profile_name() or "default"
|
||||
except Exception as _e:
|
||||
# A transient profile-resolution error mis-scopes the cache for up to
|
||||
# 60s ("default" gets the wrong payload). Log so we can detect it; the
|
||||
# blast radius stays small because the TTL caps the bad-cache window.
|
||||
logger.debug("_active_profile_for_live_models_cache fell back to 'default': %s", _e)
|
||||
return "default"
|
||||
|
||||
|
||||
def _live_models_cache_key(provider: str) -> tuple[str, str]:
|
||||
return (_active_profile_for_live_models_cache(), provider)
|
||||
|
||||
|
||||
def _get_cached_live_models(key: tuple[str, str]) -> dict | None:
|
||||
now = time.monotonic()
|
||||
with _LIVE_MODELS_CACHE_LOCK:
|
||||
cached = _LIVE_MODELS_CACHE.get(key)
|
||||
if not cached:
|
||||
return None
|
||||
ts, payload = cached
|
||||
if now - ts >= _LIVE_MODELS_CACHE_TTL:
|
||||
_LIVE_MODELS_CACHE.pop(key, None)
|
||||
return None
|
||||
return copy.deepcopy(payload)
|
||||
|
||||
|
||||
def _set_cached_live_models(key: tuple[str, str], payload: dict) -> None:
|
||||
with _LIVE_MODELS_CACHE_LOCK:
|
||||
_LIVE_MODELS_CACHE[key] = (time.monotonic(), copy.deepcopy(payload))
|
||||
|
||||
|
||||
def _clear_live_models_cache() -> None:
|
||||
with _LIVE_MODELS_CACHE_LOCK:
|
||||
_LIVE_MODELS_CACHE.clear()
|
||||
|
||||
from api.config import (
|
||||
STATE_DIR,
|
||||
@@ -3193,6 +3234,15 @@ def _handle_live_models(handler, parsed):
|
||||
from api.config import _resolve_provider_alias
|
||||
provider = _resolve_provider_alias(provider)
|
||||
|
||||
cache_key = _live_models_cache_key(provider)
|
||||
cached = _get_cached_live_models(cache_key)
|
||||
if cached is not None:
|
||||
return j(handler, cached)
|
||||
|
||||
def _finish(payload: dict):
|
||||
_set_cached_live_models(cache_key, payload)
|
||||
return j(handler, payload)
|
||||
|
||||
# Delegate to the agent's live-fetch + fallback resolver.
|
||||
# provider_model_ids() tries live endpoints first and falls back to
|
||||
# the static _PROVIDER_MODELS list — it never raises.
|
||||
@@ -3310,7 +3360,7 @@ def _handle_live_models(handler, parsed):
|
||||
from api.config import _PROVIDER_MODELS as _pm
|
||||
ids = [m["id"] for m in _pm.get(provider, [])]
|
||||
if not ids:
|
||||
return j(handler, {"provider": provider, "models": [], "count": 0})
|
||||
return _finish({"provider": provider, "models": [], "count": 0})
|
||||
|
||||
# Normalise to {id, label} — provider_model_ids() returns plain string IDs.
|
||||
# For ollama-cloud use the shared Ollama formatter (handles `:variant` suffix).
|
||||
@@ -3343,8 +3393,8 @@ def _handle_live_models(handler, parsed):
|
||||
return label
|
||||
|
||||
models_out = [{"id": mid, "label": _make_label(mid)} for mid in ids if mid]
|
||||
return j(handler, {"provider": provider, "models": models_out,
|
||||
"count": len(models_out)})
|
||||
return _finish({"provider": provider, "models": models_out,
|
||||
"count": len(models_out)})
|
||||
|
||||
except Exception as _e:
|
||||
logger.debug("_handle_live_models failed for %s: %s", provider, _e)
|
||||
@@ -4635,6 +4685,7 @@ def _handle_session_import_cli(handler, body):
|
||||
updated_at = None
|
||||
cli_title = None
|
||||
cli_source_tag = None
|
||||
model = "unknown"
|
||||
for cs in get_cli_sessions():
|
||||
if cs["session_id"] == sid:
|
||||
profile = cs.get("profile")
|
||||
|
||||
@@ -137,10 +137,18 @@ def session_status(session_id: str) -> dict[str, Any]:
|
||||
s = get_session(session_id)
|
||||
inp = int(s.input_tokens or 0)
|
||||
out = int(s.output_tokens or 0)
|
||||
profile = getattr(s, 'profile', None) or 'default'
|
||||
try:
|
||||
from api.profiles import get_hermes_home_for_profile
|
||||
hermes_home = str(get_hermes_home_for_profile(profile))
|
||||
except Exception:
|
||||
hermes_home = ''
|
||||
return {
|
||||
'session_id': s.session_id,
|
||||
'title': s.title,
|
||||
'model': s.model,
|
||||
'profile': profile,
|
||||
'hermes_home': hermes_home,
|
||||
'workspace': s.workspace,
|
||||
'personality': s.personality,
|
||||
'message_count': len(s.messages or []),
|
||||
|
||||
+42
-1
@@ -84,6 +84,8 @@ let _slashModelCache=null;
|
||||
let _slashModelCachePromise=null;
|
||||
let _slashPersonalityCache=null;
|
||||
let _slashPersonalityCachePromise=null;
|
||||
let _agentCommandCache=null;
|
||||
let _agentCommandCachePromise=null;
|
||||
|
||||
function _normalizeSlashSubArg(value){
|
||||
return String(value||'').trim();
|
||||
@@ -162,6 +164,44 @@ function _getSlashSubArgOptions(spec){
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
async function loadAgentCommandMetadata(force=false){
|
||||
if(_agentCommandCache&&!force) return _agentCommandCache;
|
||||
if(_agentCommandCachePromise&&!force) return _agentCommandCachePromise;
|
||||
_agentCommandCachePromise=(async()=>{
|
||||
try{
|
||||
const data=await api('/api/commands');
|
||||
_agentCommandCache=Array.isArray(data&&data.commands)?data.commands:[];
|
||||
}catch(_){
|
||||
_agentCommandCache=[];
|
||||
}finally{
|
||||
_agentCommandCachePromise=null;
|
||||
}
|
||||
return _agentCommandCache;
|
||||
})();
|
||||
return _agentCommandCachePromise;
|
||||
}
|
||||
|
||||
async function getAgentCommandMetadata(name){
|
||||
const needle=String(name||'').trim().toLowerCase();
|
||||
if(!needle) return null;
|
||||
const commands=await loadAgentCommandMetadata();
|
||||
return commands.find(cmd=>{
|
||||
if(String(cmd&&cmd.name||'').toLowerCase()===needle) return true;
|
||||
return Array.isArray(cmd&&cmd.aliases)&&cmd.aliases.some(a=>String(a||'').toLowerCase()===needle);
|
||||
})||null;
|
||||
}
|
||||
|
||||
function cliOnlyCommandResponse(cmdName, meta){
|
||||
const name=String((meta&&meta.name)||cmdName||'').trim();
|
||||
const desc=String((meta&&meta.description)||'').trim();
|
||||
const detail=desc?`\n\n${desc}`:'';
|
||||
let extra='';
|
||||
if(name==='browser'){
|
||||
extra='\n\nBrowser tools in WebUI must be configured server-side with the agent/browser environment. Once configured, ask the model to use browser tools directly; `/browser` itself only works in `hermes chat`.';
|
||||
}
|
||||
return `\`/${name}\` is a Hermes CLI-only command and cannot run inside the WebUI.${detail}${extra}`;
|
||||
}
|
||||
|
||||
function _parseSlashAutocomplete(text){
|
||||
if(!text.startsWith('/')||text.indexOf('\n')!==-1) return null;
|
||||
const raw=text.slice(1);
|
||||
@@ -759,7 +799,7 @@ async function cmdStatus(){
|
||||
if(r&&r.error){showToast(r.error);return;}
|
||||
// Build status card lines matching CLI /status output
|
||||
const provider=window._activeProvider||'';
|
||||
const profile=S.activeProfile||'default';
|
||||
const profile=r.profile||S.activeProfile||'default';
|
||||
const started=r.created_at?new Date(r.created_at).toLocaleString():t('status_unknown');
|
||||
const fmtNum=n=>typeof n==='number'?n.toLocaleString():'0';
|
||||
const tokens=r.total_tokens?`${fmtNum(r.input_tokens)} in / ${fmtNum(r.output_tokens)} out`:t('status_no_tokens');
|
||||
@@ -770,6 +810,7 @@ async function cmdStatus(){
|
||||
`**${t('status_title')}:** ${r.title||t('untitled')}`,
|
||||
`**${t('status_model')}:** ${r.model||t('usage_default_model')}${provider?' ('+provider+')':''}`,
|
||||
`**${t('status_profile')}:** ${profile}`,
|
||||
`**${t('status_hermes_home')}:** ${r.hermes_home||t('status_unknown')}`,
|
||||
`**${t('status_workspace')}:** ${r.workspace}`,
|
||||
`**${t('status_personality')}:** ${r.personality||t('usage_personality_none')}`,
|
||||
`**${t('status_started')}:** ${started}`,
|
||||
|
||||
@@ -209,6 +209,7 @@ const LOCALES = {
|
||||
status_messages:'Messages',
|
||||
status_agent_running:'Agent running',
|
||||
status_profile: 'Profile',
|
||||
status_hermes_home: 'Hermes home',
|
||||
status_started: 'Started',
|
||||
status_tokens: 'Tokens',
|
||||
status_no_tokens: 'No tokens used',
|
||||
@@ -1479,6 +1480,7 @@ const LOCALES = {
|
||||
settings_tab_system: 'System',
|
||||
status_no_tokens: 'No token data',
|
||||
status_profile: 'Profile',
|
||||
status_hermes_home: 'Hermes home',
|
||||
status_started: 'Started',
|
||||
status_tokens: 'Tokens',
|
||||
status_unknown: 'Unknown',
|
||||
@@ -2212,6 +2214,7 @@ const LOCALES = {
|
||||
settings_tab_system: 'System',
|
||||
status_no_tokens: 'No token data',
|
||||
status_profile: 'Profile',
|
||||
status_hermes_home: 'Hermes home',
|
||||
status_started: 'Started',
|
||||
status_tokens: 'Tokens',
|
||||
status_unknown: 'Unknown',
|
||||
@@ -2718,6 +2721,7 @@ const LOCALES = {
|
||||
settings_tab_system: 'System',
|
||||
status_no_tokens: 'No token data',
|
||||
status_profile: 'Profile',
|
||||
status_hermes_home: 'Hermes home',
|
||||
status_started: 'Started',
|
||||
status_tokens: 'Tokens',
|
||||
status_unknown: 'Unknown',
|
||||
@@ -3679,6 +3683,7 @@ const LOCALES = {
|
||||
settings_tab_system: 'System',
|
||||
status_no_tokens: 'No token data',
|
||||
status_profile: 'Profile',
|
||||
status_hermes_home: 'Hermes home',
|
||||
status_started: 'Started',
|
||||
status_tokens: 'Tokens',
|
||||
status_unknown: 'Unknown',
|
||||
@@ -4438,6 +4443,7 @@ const LOCALES = {
|
||||
providers_tab_title: '供應商',
|
||||
status_agent_running: 'Agent 執行中',
|
||||
status_profile: '個人資料',
|
||||
status_hermes_home: 'Hermes 主目錄',
|
||||
status_started: '開始時間',
|
||||
status_tokens: 'Token',
|
||||
status_no_tokens: '未使用 Token',
|
||||
@@ -4739,6 +4745,7 @@ const LOCALES = {
|
||||
status_messages: 'Mensagens',
|
||||
status_agent_running: 'Agente rodando',
|
||||
status_profile: 'Perfil',
|
||||
status_hermes_home: 'Diretório Hermes',
|
||||
status_started: 'Iniciado',
|
||||
status_tokens: 'Tokens',
|
||||
status_no_tokens: 'Nenhum token usado',
|
||||
@@ -5424,6 +5431,7 @@ const LOCALES = {
|
||||
status_messages: '메시지',
|
||||
status_agent_running: '에이전트 실행 중',
|
||||
status_profile: '프로필',
|
||||
status_hermes_home: 'Hermes 홈',
|
||||
status_started: '시작 시간',
|
||||
status_tokens: '토큰',
|
||||
status_no_tokens: '사용된 토큰 없음',
|
||||
|
||||
@@ -132,6 +132,18 @@ async function send(){
|
||||
$('msg').value='';autoResize();hideCmdDropdown();return;
|
||||
}
|
||||
}
|
||||
if(_parsedCmd&&!_cmd){
|
||||
const _agentCmd=typeof getAgentCommandMetadata==='function'
|
||||
? await getAgentCommandMetadata(_parsedCmd.name)
|
||||
: null;
|
||||
if(_agentCmd&&_agentCmd.cli_only){
|
||||
if(!S.session){await newSession();await renderSessionList();}
|
||||
S.messages.push({role:'user',content:text,_ts:Date.now()/1000});
|
||||
S.messages.push({role:'assistant',content:cliOnlyCommandResponse(_parsedCmd.name,_agentCmd),_ts:Date.now()/1000});
|
||||
renderMessages();
|
||||
$('msg').value='';autoResize();hideCmdDropdown();return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!S.session){await newSession();await renderSessionList();}
|
||||
|
||||
|
||||
+1
-1
@@ -681,7 +681,7 @@
|
||||
.workspace-toggle-btn.active{color:var(--accent-text);border-color:var(--accent-bg);background:var(--accent-bg);}
|
||||
.workspace-toggle-btn:disabled{opacity:.38;cursor:not-allowed;}
|
||||
.chip.model{color:var(--accent-text);border-color:var(--accent-bg-strong);background:var(--accent-bg);}
|
||||
.messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;}
|
||||
.messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;overflow-anchor:none;}
|
||||
/* sticky-first-child: button is first child of .messages so its natural position is above viewport; sticky+bottom:16px pins it there when visible */
|
||||
.scroll-to-bottom-btn{position:sticky;bottom:16px;align-self:flex-end;margin-right:20px;width:32px;height:32px;border-radius:50%;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:10;transition:color .12s,border-color .12s,background .12s;}
|
||||
.scroll-to-bottom-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);}
|
||||
|
||||
+6
-4
@@ -885,13 +885,13 @@ document.addEventListener('click',function(e){
|
||||
|
||||
// ── Scroll pinning ──────────────────────────────────────────────────────────
|
||||
// When streaming, auto-scroll only if the user hasn't manually scrolled up.
|
||||
// Once the user scrolls back to within 150px of the bottom, re-pin.
|
||||
// Once the user scrolls back to within 250px of the bottom, re-pin.
|
||||
let _scrollPinned=true;
|
||||
(function(){
|
||||
const el=document.getElementById('messages');
|
||||
if(!el) return;
|
||||
el.addEventListener('scroll',()=>{
|
||||
const nearBottom=el.scrollHeight-el.scrollTop-el.clientHeight<150;
|
||||
const nearBottom=el.scrollHeight-el.scrollTop-el.clientHeight<250;
|
||||
_scrollPinned=nearBottom;
|
||||
const btn=$('scrollToBottomBtn');
|
||||
if(btn) btn.style.display=_scrollPinned?'none':'flex';
|
||||
@@ -1818,7 +1818,8 @@ function _renderQueueChips(sid){
|
||||
if(!card.classList.contains('visible')) return;
|
||||
const h=card.getBoundingClientRect().height;
|
||||
if(h>0) _msgs.style.setProperty('--queue-card-height', h+'px');
|
||||
if(typeof scrollToBottom==='function') scrollToBottom();
|
||||
if(S.activeStreamId&&typeof scrollIfPinned==='function') scrollIfPinned();
|
||||
else if(!S.activeStreamId&&typeof scrollToBottom==='function') scrollToBottom();
|
||||
}, 360);
|
||||
}
|
||||
|
||||
@@ -2008,7 +2009,8 @@ function _updateQueuePill(sid,count){
|
||||
}, 360);
|
||||
}
|
||||
if(pillOuter) pillOuter.classList.remove('show');
|
||||
if(typeof scrollToBottom==='function') scrollToBottom();
|
||||
if(S.activeStreamId&&typeof scrollIfPinned==='function') scrollIfPinned();
|
||||
else if(!S.activeStreamId&&typeof scrollToBottom==='function') scrollToBottom();
|
||||
};
|
||||
} else {
|
||||
if(pillOuter) pillOuter.classList.remove('show');
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Regression tests for WebUI handling of Hermes CLI-only slash commands."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import textwrap
|
||||
from types import SimpleNamespace
|
||||
|
||||
from api.commands import list_commands
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
COMMANDS_JS = (REPO_ROOT / "static" / "commands.js").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_api_commands_exposes_cli_only_metadata_for_webui_intercept():
|
||||
"""CLI-only commands must remain visible so the frontend can explain them."""
|
||||
registry = [
|
||||
SimpleNamespace(
|
||||
name="browser",
|
||||
description="Attach browser tools",
|
||||
category="tools",
|
||||
aliases=["browse"],
|
||||
args_hint="connect",
|
||||
subcommands=["connect"],
|
||||
cli_only=True,
|
||||
gateway_only=False,
|
||||
)
|
||||
]
|
||||
|
||||
body = list_commands(registry)
|
||||
|
||||
assert body == [
|
||||
{
|
||||
"name": "browser",
|
||||
"description": "Attach browser tools",
|
||||
"category": "tools",
|
||||
"aliases": ["browse"],
|
||||
"args_hint": "connect",
|
||||
"subcommands": ["connect"],
|
||||
"cli_only": True,
|
||||
"gateway_only": False,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_frontend_fetches_agent_command_metadata_lazily():
|
||||
assert "async function loadAgentCommandMetadata" in COMMANDS_JS
|
||||
assert "api('/api/commands')" in COMMANDS_JS
|
||||
assert "_agentCommandCache" in COMMANDS_JS
|
||||
|
||||
|
||||
def test_frontend_matches_agent_command_aliases():
|
||||
helper_idx = COMMANDS_JS.find("async function getAgentCommandMetadata")
|
||||
assert helper_idx != -1
|
||||
helper = COMMANDS_JS[helper_idx : helper_idx + 700]
|
||||
assert "cmd.aliases" in helper
|
||||
assert "some(a=>String(a||'').toLowerCase()===needle)" in helper
|
||||
|
||||
|
||||
def test_cli_only_response_mentions_webui_and_cli_scope():
|
||||
assert "function cliOnlyCommandResponse" in COMMANDS_JS
|
||||
assert "Hermes CLI-only command" in COMMANDS_JS
|
||||
assert "cannot run inside the WebUI" in COMMANDS_JS
|
||||
|
||||
|
||||
def test_browser_cli_only_response_explains_server_side_browser_tools():
|
||||
response_idx = COMMANDS_JS.find("function cliOnlyCommandResponse")
|
||||
response = COMMANDS_JS[response_idx : response_idx + 900]
|
||||
assert "if(name==='browser')" in response
|
||||
assert "configured server-side" in response
|
||||
assert "`/browser` itself only works in `hermes chat`" in response
|
||||
|
||||
|
||||
def _run_commands_js(script_body: str) -> dict:
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
const vm = require('vm');
|
||||
const ctx = {{
|
||||
console,
|
||||
localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }},
|
||||
t: (key) => key,
|
||||
api: async (path) => {{
|
||||
if (path !== '/api/commands') throw new Error('unexpected api path: ' + path);
|
||||
return {{
|
||||
commands: [
|
||||
{{
|
||||
name: 'browser',
|
||||
description: 'Attach browser tools',
|
||||
aliases: ['browse'],
|
||||
cli_only: true,
|
||||
gateway_only: false
|
||||
}},
|
||||
{{
|
||||
name: 'model',
|
||||
description: 'Change model',
|
||||
aliases: [],
|
||||
cli_only: false,
|
||||
gateway_only: false
|
||||
}}
|
||||
]
|
||||
}};
|
||||
}}
|
||||
}};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext({json.dumps(COMMANDS_JS)}, ctx);
|
||||
(async () => {{
|
||||
const result = await vm.runInContext(`(async () => {{ {script_body} }})()`, ctx);
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
}})().catch(err => {{
|
||||
console.error(err && err.stack || err);
|
||||
process.exit(1);
|
||||
}});
|
||||
"""
|
||||
)
|
||||
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_agent_command_metadata_helper_resolves_name_and_alias():
|
||||
result = _run_commands_js(
|
||||
"""
|
||||
const byName = await getAgentCommandMetadata('browser');
|
||||
const byAlias = await getAgentCommandMetadata('browse');
|
||||
const unknown = await getAgentCommandMetadata('does-not-exist');
|
||||
return {
|
||||
by_name: byName && byName.name,
|
||||
by_alias: byAlias && byAlias.name,
|
||||
cli_only: byAlias && byAlias.cli_only === true,
|
||||
unknown: unknown === null
|
||||
};
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"by_name": "browser",
|
||||
"by_alias": "browser",
|
||||
"cli_only": True,
|
||||
"unknown": True,
|
||||
}
|
||||
|
||||
|
||||
def test_cli_only_response_helper_uses_canonical_command_name():
|
||||
result = _run_commands_js(
|
||||
"""
|
||||
const meta = await getAgentCommandMetadata('browse');
|
||||
return {
|
||||
response: cliOnlyCommandResponse('browse', meta)
|
||||
};
|
||||
"""
|
||||
)
|
||||
|
||||
assert "`/browser` is a Hermes CLI-only command" in result["response"]
|
||||
assert "Attach browser tools" in result["response"]
|
||||
assert "configured server-side" in result["response"]
|
||||
|
||||
|
||||
def test_send_intercepts_cli_only_commands_before_agent_round_trip():
|
||||
intercept_idx = MESSAGES_JS.find("Slash command intercept")
|
||||
assert intercept_idx != -1
|
||||
normal_send_idx = MESSAGES_JS.find("const activeSid=S.session.session_id", intercept_idx)
|
||||
assert normal_send_idx != -1
|
||||
intercept = MESSAGES_JS[intercept_idx:normal_send_idx]
|
||||
|
||||
assert "await getAgentCommandMetadata(_parsedCmd.name)" in intercept
|
||||
assert "if(_agentCmd&&_agentCmd.cli_only)" in intercept
|
||||
assert "cliOnlyCommandResponse(_parsedCmd.name,_agentCmd)" in intercept
|
||||
assert "return;" in intercept
|
||||
|
||||
|
||||
def test_unknown_slash_commands_still_fall_through_to_agent():
|
||||
"""Only known cli_only commands should be intercepted."""
|
||||
intercept_idx = MESSAGES_JS.find("Slash command intercept")
|
||||
normal_send_idx = MESSAGES_JS.find("const activeSid=S.session.session_id", intercept_idx)
|
||||
intercept = MESSAGES_JS[intercept_idx:normal_send_idx]
|
||||
|
||||
assert "if(_agentCmd&&_agentCmd.cli_only)" in intercept
|
||||
assert "if(_parsedCmd&&!_cmd)" in intercept
|
||||
assert "if(!_agentCmd" not in intercept
|
||||
assert "else" not in intercept[intercept.find("if(_agentCmd&&_agentCmd.cli_only)") :]
|
||||
|
||||
|
||||
def test_builtin_command_opt_outs_do_not_hit_agent_metadata_lookup():
|
||||
"""Built-in fall-through commands like /reasoning high keep their old path."""
|
||||
intercept_idx = MESSAGES_JS.find("Slash command intercept")
|
||||
normal_send_idx = MESSAGES_JS.find("const activeSid=S.session.session_id", intercept_idx)
|
||||
intercept = MESSAGES_JS[intercept_idx:normal_send_idx]
|
||||
optout_idx = intercept.find("if(_cmd.fn(_parsedCmd.args)===false)")
|
||||
metadata_idx = intercept.find("await getAgentCommandMetadata(_parsedCmd.name)")
|
||||
|
||||
assert optout_idx != -1
|
||||
assert metadata_idx != -1
|
||||
assert "if(_parsedCmd&&!_cmd)" in intercept[optout_idx:metadata_idx + 120]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Regression tests for #1360: streaming must not re-pin user scroll."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).parent.parent
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _extract_function(src: str, name: str) -> str:
|
||||
marker = f"function {name}("
|
||||
idx = src.find(marker)
|
||||
assert idx != -1, f"{name} not found"
|
||||
depth = 0
|
||||
for i, ch in enumerate(src[idx:], idx):
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[idx:i + 1]
|
||||
raise AssertionError(f"Could not extract {name}")
|
||||
|
||||
|
||||
def test_messages_scroller_disables_browser_scroll_anchoring():
|
||||
assert "overflow-anchor:none" in STYLE_CSS, (
|
||||
"#messages must disable browser scroll anchoring so tool/card inserts "
|
||||
"cannot yank the transcript while the user reads earlier content."
|
||||
)
|
||||
|
||||
|
||||
def test_scroll_repin_dead_zone_is_wider_for_mac_app_windows():
|
||||
assert "clientHeight<250" in UI_JS, (
|
||||
"The near-bottom re-pin threshold should be at least 250px so small "
|
||||
"macOS app windows and trackpad momentum do not re-pin too eagerly."
|
||||
)
|
||||
|
||||
|
||||
def test_queue_card_measurement_does_not_force_repin_during_streaming():
|
||||
fn = _extract_function(UI_JS, "_renderQueueChips")
|
||||
measurement_idx = fn.find("setTimeout(()=>")
|
||||
assert measurement_idx != -1, "queue card measurement timeout not found"
|
||||
measurement_block = fn[measurement_idx:measurement_idx + 500]
|
||||
|
||||
assert "S.activeStreamId" in measurement_block
|
||||
assert "scrollIfPinned()" in measurement_block
|
||||
assert "!S.activeStreamId" in measurement_block
|
||||
assert "scrollToBottom()" in measurement_block
|
||||
assert measurement_block.find("scrollIfPinned()") < measurement_block.find("scrollToBottom()")
|
||||
|
||||
|
||||
def test_queue_pill_click_does_not_force_repin_during_streaming():
|
||||
fn = _extract_function(UI_JS, "_updateQueuePill")
|
||||
click_idx = fn.find("pill.onclick=()=>")
|
||||
assert click_idx != -1, "queue pill click handler not found"
|
||||
click_block = fn[click_idx:click_idx + 700]
|
||||
|
||||
assert "S.activeStreamId" in click_block
|
||||
assert "scrollIfPinned()" in click_block
|
||||
assert "!S.activeStreamId" in click_block
|
||||
assert "scrollToBottom()" in click_block
|
||||
assert click_block.find("scrollIfPinned()") < click_block.find("scrollToBottom()")
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Regression tests for /api/models/live backend TTL caching."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _install_provider_model_ids(monkeypatch, fn):
|
||||
hermes_cli = types.ModuleType("hermes_cli")
|
||||
hermes_cli.__path__ = []
|
||||
models = types.ModuleType("hermes_cli.models")
|
||||
models.provider_model_ids = fn
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", hermes_cli)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.models", models)
|
||||
|
||||
|
||||
def _patch_live_models_basics(monkeypatch, routes, profile="default"):
|
||||
import api.config as config
|
||||
import api.profiles as profiles
|
||||
|
||||
routes._clear_live_models_cache()
|
||||
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
|
||||
monkeypatch.setattr(config, "get_config", lambda: {"model": {"provider": "openai"}})
|
||||
monkeypatch.setattr(config, "_resolve_provider_alias", lambda provider: provider)
|
||||
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: profile)
|
||||
|
||||
|
||||
def test_live_models_cache_hits_within_ttl(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
calls = []
|
||||
|
||||
def provider_model_ids(provider):
|
||||
calls.append(provider)
|
||||
return ["openai/gpt-test"]
|
||||
|
||||
_install_provider_model_ids(monkeypatch, provider_model_ids)
|
||||
_patch_live_models_basics(monkeypatch, routes)
|
||||
|
||||
parsed = urlparse("/api/models/live?provider=openai")
|
||||
first = routes._handle_live_models(object(), parsed)
|
||||
second = routes._handle_live_models(object(), parsed)
|
||||
|
||||
assert calls == ["openai"]
|
||||
assert first == second
|
||||
assert first["models"] == [{"id": "openai/gpt-test", "label": "GPT Test"}]
|
||||
|
||||
|
||||
def test_live_models_cache_expires(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
now = [1000.0]
|
||||
calls = []
|
||||
|
||||
def provider_model_ids(provider):
|
||||
calls.append(provider)
|
||||
return [f"{provider}/model-{len(calls)}"]
|
||||
|
||||
_install_provider_model_ids(monkeypatch, provider_model_ids)
|
||||
_patch_live_models_basics(monkeypatch, routes)
|
||||
monkeypatch.setattr(routes.time, "monotonic", lambda: now[0])
|
||||
|
||||
parsed = urlparse("/api/models/live?provider=openai")
|
||||
first = routes._handle_live_models(object(), parsed)
|
||||
now[0] += routes._LIVE_MODELS_CACHE_TTL + 1
|
||||
second = routes._handle_live_models(object(), parsed)
|
||||
|
||||
assert calls == ["openai", "openai"]
|
||||
assert first["models"][0]["id"] == "openai/model-1"
|
||||
assert second["models"][0]["id"] == "openai/model-2"
|
||||
|
||||
|
||||
def test_live_models_cache_is_profile_scoped(monkeypatch):
|
||||
import api.routes as routes
|
||||
import api.profiles as profiles
|
||||
|
||||
active_profile = ["default"]
|
||||
calls = []
|
||||
|
||||
def provider_model_ids(provider):
|
||||
calls.append((active_profile[0], provider))
|
||||
return [f"{provider}/{active_profile[0]}-model"]
|
||||
|
||||
_install_provider_model_ids(monkeypatch, provider_model_ids)
|
||||
_patch_live_models_basics(monkeypatch, routes)
|
||||
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: active_profile[0])
|
||||
|
||||
parsed = urlparse("/api/models/live?provider=openai")
|
||||
default_payload = routes._handle_live_models(object(), parsed)
|
||||
active_profile[0] = "research"
|
||||
research_payload = routes._handle_live_models(object(), parsed)
|
||||
again_payload = routes._handle_live_models(object(), parsed)
|
||||
|
||||
assert calls == [("default", "openai"), ("research", "openai")]
|
||||
assert default_payload["models"][0]["id"] == "openai/default-model"
|
||||
assert research_payload["models"][0]["id"] == "openai/research-model"
|
||||
assert again_payload == research_payload
|
||||
|
||||
|
||||
def test_live_models_cache_returns_deep_copies(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
_install_provider_model_ids(monkeypatch, lambda provider: ["openai/gpt-test"])
|
||||
_patch_live_models_basics(monkeypatch, routes)
|
||||
|
||||
parsed = urlparse("/api/models/live?provider=openai")
|
||||
first = routes._handle_live_models(object(), parsed)
|
||||
first["models"].clear()
|
||||
first["provider"] = "mutated"
|
||||
|
||||
second = routes._handle_live_models(object(), parsed)
|
||||
|
||||
assert second["provider"] == "openai"
|
||||
assert second["models"] == [{"id": "openai/gpt-test", "label": "GPT Test"}]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Regression test for #1386: CLI session import must not crash when the
|
||||
session is missing from `get_cli_sessions()` metadata at the time of import.
|
||||
|
||||
Before the fix, `_handle_session_import_cli` only assigned `model` inside
|
||||
the `for cs in get_cli_sessions(): if cs["session_id"] == sid` loop. If
|
||||
the session existed in the messages store but had no metadata row (or had
|
||||
been pruned after `get_cli_session_messages()` was called), `model` was
|
||||
unbound and `import_cli_session(sid, title, msgs, model, ...)` raised
|
||||
`UnboundLocalError`.
|
||||
|
||||
The fix initializes `model = "unknown"` before the loop so the import
|
||||
proceeds with a sensible default rather than crashing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
ROUTES_PY = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _extract_handler(name: str) -> str:
|
||||
"""Return the source of the handler function `name` from api/routes.py."""
|
||||
marker = f"def {name}("
|
||||
idx = ROUTES_PY.find(marker)
|
||||
assert idx != -1, f"{name} not found in api/routes.py"
|
||||
# Walk forward until a top-level `def ` (col 0) appears.
|
||||
next_def = ROUTES_PY.find("\ndef ", idx + len(marker))
|
||||
return ROUTES_PY[idx : next_def if next_def != -1 else len(ROUTES_PY)]
|
||||
|
||||
|
||||
def test_import_cli_initializes_model_before_metadata_loop():
|
||||
"""The fallback `model = 'unknown'` must be set BEFORE the
|
||||
`for cs in get_cli_sessions()` loop so that a metadata-less session
|
||||
cannot leave `model` unbound."""
|
||||
handler = _extract_handler("_handle_session_import_cli")
|
||||
init_idx = handler.find('model = "unknown"')
|
||||
if init_idx == -1:
|
||||
# Allow single quotes too.
|
||||
init_idx = handler.find("model = 'unknown'")
|
||||
assert init_idx != -1, (
|
||||
"Expected `model = \"unknown\"` initialization in "
|
||||
"_handle_session_import_cli before the metadata loop. Without it, "
|
||||
"import crashes when the session has messages but no metadata row."
|
||||
)
|
||||
loop_idx = handler.find("for cs in get_cli_sessions()")
|
||||
assert loop_idx != -1, "Expected `for cs in get_cli_sessions()` loop"
|
||||
assert init_idx < loop_idx, (
|
||||
"`model` must be initialized BEFORE the `for cs in get_cli_sessions()` "
|
||||
"loop, otherwise a session without a metadata row leaves `model` "
|
||||
"unbound and `import_cli_session(..., model, ...)` raises "
|
||||
"UnboundLocalError."
|
||||
)
|
||||
|
||||
|
||||
def test_import_cli_passes_model_to_import_helper():
|
||||
"""Sanity: the handler still passes the resolved model down to
|
||||
`import_cli_session` — the regression test would not catch a refactor
|
||||
that drops the argument entirely."""
|
||||
handler = _extract_handler("_handle_session_import_cli")
|
||||
assert "import_cli_session(" in handler
|
||||
# The model variable should appear as a positional or keyword arg in
|
||||
# the import_cli_session call.
|
||||
call_idx = handler.find("import_cli_session(")
|
||||
call_block = handler[call_idx : call_idx + 400]
|
||||
assert "model" in call_block, (
|
||||
"import_cli_session() call should still receive the `model` argument."
|
||||
)
|
||||
@@ -11,7 +11,7 @@ import urllib.error
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import TEST_BASE, _post, make_session_tracked
|
||||
from tests.conftest import TEST_BASE, TEST_STATE_DIR, _post, make_session_tracked
|
||||
|
||||
|
||||
def _get(path):
|
||||
@@ -218,6 +218,8 @@ def test_status_returns_summary(cleanup_test_sessions):
|
||||
assert r['title'] == 'test'
|
||||
assert r['message_count'] == 3
|
||||
assert 'model' in r
|
||||
assert r['profile'] == 'default'
|
||||
assert r['hermes_home'] == str(TEST_STATE_DIR)
|
||||
assert 'workspace' in r
|
||||
assert 'created_at' in r
|
||||
assert 'updated_at' in r
|
||||
@@ -233,6 +235,17 @@ def test_status_returns_summary(cleanup_test_sessions):
|
||||
assert r['total_tokens'] == 0
|
||||
|
||||
|
||||
def test_status_returns_profile_specific_hermes_home(cleanup_test_sessions):
|
||||
data = _post(TEST_BASE, '/api/session/new', {'profile': 'research'})
|
||||
sid = data['session']['session_id']
|
||||
cleanup_test_sessions.append(sid)
|
||||
|
||||
r = _get(f'/api/session/status?session_id={sid}')
|
||||
|
||||
assert r['profile'] == 'research'
|
||||
assert r['hermes_home'] == str(TEST_STATE_DIR / 'profiles' / 'research')
|
||||
|
||||
|
||||
def test_status_unknown_returns_404():
|
||||
try:
|
||||
_get('/api/session/status?session_id=nonexistent_zzz')
|
||||
|
||||
Reference in New Issue
Block a user