v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels * feat: adaptive session title refresh based on conversation evolution Addresses #869 — the 'Optional' part: adapt session names to current conversation context instead of only generating once from the first exchange. Backend (api/streaming.py): - Add _latest_exchange_snippets() to extract last user+assistant pair - Add _count_exchanges() to count user messages - Add _get_title_refresh_interval() to read the setting - Add _run_background_title_refresh() — refreshes title from latest exchange with LLM, skips if title is unchanged or user manually renamed - Add _maybe_schedule_title_refresh() — checks exchange count and schedules refresh after stream_end (non-blocking) Config (api/config.py): - Add auto_title_refresh_every setting (default '0' = off) - Enum validation: {'0', '5', '10', '20'} Frontend: - Settings UI dropdown (static/index.html) - Wire up load/save in panels.js - i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant) Default: off. Opt-in via Settings > Conversation > Adaptive title refresh. * test: add 37 tests for adaptive title refresh helpers Covers all five new functions introduced in this PR: _count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval, _run_background_title_refresh, _maybe_schedule_title_refresh Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> * fix(settings): show selected state on theme/skin/font-size picker cards The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and siblings — `!important` beats inline styles. Active cards showed no visual highlight. Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the sync functions. 5 regression tests added. Closes #1057 * fix: rename test file to match PR number, fix stale issue reference * docs: v0.50.211 release notes and version bump Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix. * docs(changelog): correct settings tab for adaptive title refresh The v0.50.211 entry for #1058 said "Settings → Appearance" but the toggle is actually rendered inside settingsPanePreferences (the Preferences tab) per static/index.html:604+. The commit message also had the wrong tab ("Conversation"). Updated CHANGELOG to match the actual UI surface so users can find the toggle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: create state dir before writing settings file save_settings() called SETTINGS_FILE.write_text() without ensuring the parent directory exists. In fresh environments (CI, first run without HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError. Add mkdir(parents=True, exist_ok=True) before the write. --------- Co-authored-by: Pavol Biely <biely@webtec.sk> Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
17 files changed
+730
-79
No files matched your search
@@ -2,6 +2,17 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## v0.50.211 — 2026-04-25
|
||||
|
||||
### Changed
|
||||
- **Compact sidebar timestamps** — session timestamps in the left sidebar now show short labels (`1m`, `6m`, `1h`, `1d`, `1w`) instead of verbose strings like "6 minutes ago". Keeps all existing i18n paths; bucket headers (Today, Yesterday, This week) unchanged. (`static/sessions.js`, `static/i18n.js`) [#1057 @pavolbiely]
|
||||
|
||||
### Added
|
||||
- **Adaptive session title refresh** — new opt-in setting (`Settings → Preferences → Adaptive title refresh`) re-generates the session title from the latest exchange every N turns (5, 10, or 20). Off by default. Runs in a daemon thread after stream end, never blocks the stream. Manual title renames are preserved (double-checked before and after LLM call). (`api/streaming.py`, `api/config.py`, `static/panels.js`, `static/i18n.js`, `static/index.html`) [#1058 @bergeouss]
|
||||
|
||||
### Fixed
|
||||
- **Settings picker active state** — theme, skin, and font-size picker cards in Settings → Appearance now correctly highlight the selected option. Root cause: the base CSS rule used `!important` on `border-color`, overriding the inline style set by `_syncThemePicker()` and siblings. Fix moves to an `.active` class with its own `!important` rule. (`static/style.css`, `static/boot.js`) [#1059]
|
||||
|
||||
## v0.50.210 — 2026-04-25
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
|
||||
> Everything you can do from the CLI terminal, you can do from this UI.
|
||||
>
|
||||
> Last updated: v0.50.185 (April 24, 2026) — 2107 tests collected
|
||||
> Last updated: v0.50.211 (April 25, 2026) — 2276 tests collected
|
||||
> Tests: 2107 collected (`pytest tests/ --collect-only -q`)
|
||||
> Source: <repo>/
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
|
||||
>
|
||||
> Automated coverage: 2239 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
|
||||
> Automated coverage: 2276 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
|
||||
> Run: `pytest tests/ -v --timeout=60`
|
||||
>
|
||||
> Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash.
|
||||
|
||||
@@ -1793,6 +1793,7 @@ _SETTINGS_DEFAULTS = {
|
||||
"notifications_enabled": False, # browser notification when tab is in background
|
||||
"show_thinking": True, # show/hide thinking/reasoning blocks in chat view
|
||||
"sidebar_density": "compact", # compact | detailed
|
||||
"auto_title_refresh_every": "0", # adaptive title refresh: 0=off, 5/10/20=every N exchanges
|
||||
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
|
||||
}
|
||||
_SETTINGS_LEGACY_DROP_KEYS = {"assistant_language", "bubble_layout", "default_model"}
|
||||
@@ -1893,6 +1894,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {
|
||||
_SETTINGS_ENUM_VALUES = {
|
||||
"send_key": {"enter", "ctrl+enter"},
|
||||
"sidebar_density": {"compact", "detailed"},
|
||||
"auto_title_refresh_every": {"0", "5", "10", "20"},
|
||||
}
|
||||
_SETTINGS_BOOL_KEYS = {
|
||||
"onboarding_completed",
|
||||
@@ -1961,6 +1963,7 @@ def save_settings(settings: dict) -> dict:
|
||||
resolve_default_workspace(current.get("default_workspace"))
|
||||
)
|
||||
persisted = {k: v for k, v in current.items() if k != "default_model"}
|
||||
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
SETTINGS_FILE.write_text(
|
||||
json.dumps(persisted, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
|
||||
@@ -209,6 +209,58 @@ def _first_exchange_snippets(messages):
|
||||
return user_text[:500], asst_text[:500]
|
||||
|
||||
|
||||
def _latest_exchange_snippets(messages):
|
||||
"""Return (last_user_text, last_assistant_text) snippets for title refresh.
|
||||
|
||||
Walks the message list backwards to find the last user+assistant pair,
|
||||
skipping empty or tool-call-only assistant messages.
|
||||
"""
|
||||
user_text = ''
|
||||
asst_text = ''
|
||||
for m in reversed(messages or []):
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
role = m.get('role')
|
||||
if role == 'assistant' and not asst_text:
|
||||
candidate = _message_text(m.get('content'))
|
||||
# Skip tool-call-only preambles
|
||||
if m.get('tool_calls') and (not candidate or _looks_invalid_generated_title(candidate)):
|
||||
continue
|
||||
if candidate:
|
||||
asst_text = candidate
|
||||
elif role == 'user' and not user_text:
|
||||
candidate = _message_text(m.get('content'))
|
||||
if candidate:
|
||||
user_text = candidate
|
||||
if user_text and asst_text:
|
||||
break
|
||||
return user_text[:500], asst_text[:500]
|
||||
|
||||
|
||||
def _count_exchanges(messages):
|
||||
"""Count the number of user messages (rough exchange count)."""
|
||||
count = 0
|
||||
for m in messages or []:
|
||||
if isinstance(m, dict) and m.get('role') == 'user':
|
||||
content = m.get('content', '')
|
||||
if isinstance(content, list):
|
||||
content = ' '.join(p.get('text', '') for p in content if isinstance(p, dict) and p.get('type') == 'text')
|
||||
if str(content).strip():
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _get_title_refresh_interval() -> int:
|
||||
"""Read the auto_title_refresh_every setting (0 = disabled)."""
|
||||
try:
|
||||
from api.config import load_settings
|
||||
settings = load_settings()
|
||||
val = settings.get('auto_title_refresh_every', '0')
|
||||
return int(val) if str(val).strip().isdigit() and int(val) > 0 else 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _is_provisional_title(current_title: str, messages) -> bool:
|
||||
"""Heuristic: title equals first-message substring placeholder."""
|
||||
derived = title_from(messages, '') or ''
|
||||
@@ -731,6 +783,85 @@ def _run_background_title_update(session_id: str, user_text: str, assistant_text
|
||||
put_event('stream_end', {'session_id': session_id})
|
||||
|
||||
|
||||
def _run_background_title_refresh(session_id: str, user_text: str, assistant_text: str, current_title: str, put_event, agent=None):
|
||||
"""Refresh an existing LLM-generated title using the latest exchange text.
|
||||
|
||||
Unlike _run_background_title_update, this does NOT guard on
|
||||
llm_title_generated — it assumes the title was already LLM-generated
|
||||
and the session has progressed enough to warrant a refresh.
|
||||
It does NOT emit stream_end (the caller already did).
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
s = get_session(session_id)
|
||||
except KeyError:
|
||||
return
|
||||
# Safety: skip if user manually renamed since the check
|
||||
effective = str(s.title or '').strip()
|
||||
if effective != current_title:
|
||||
_put_title_status(put_event, session_id, 'skipped', 'manual_title', effective)
|
||||
return
|
||||
if not effective or effective in ('Untitled', 'New Chat'):
|
||||
return
|
||||
aux_title_configured = _aux_title_configured()
|
||||
if agent and not aux_title_configured:
|
||||
next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text)
|
||||
if not next_title and llm_status in ('llm_error', 'llm_invalid'):
|
||||
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent, use_agent_model=True)
|
||||
else:
|
||||
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text)
|
||||
if not next_title and agent and llm_status in ('llm_error_aux', 'llm_invalid_aux'):
|
||||
next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text)
|
||||
if not next_title:
|
||||
_put_title_status(put_event, session_id, 'refresh_skipped', llm_status or 'empty', effective, raw_preview)
|
||||
return
|
||||
# Skip if the new title is essentially the same (after normalization)
|
||||
normalized_current = re.sub(r'\s+', ' ', effective).strip().lower()
|
||||
normalized_new = re.sub(r'\s+', ' ', next_title).strip().lower()
|
||||
if normalized_current == normalized_new:
|
||||
_put_title_status(put_event, session_id, 'refresh_skipped', 'same_title', effective, raw_preview)
|
||||
return
|
||||
with _get_session_agent_lock(session_id):
|
||||
with LOCK:
|
||||
s = SESSIONS.get(session_id, s)
|
||||
# Re-check: user may have renamed while we were generating
|
||||
if str(s.title or '').strip() != current_title:
|
||||
_put_title_status(put_event, session_id, 'skipped', 'manual_title', str(s.title or '').strip())
|
||||
return
|
||||
s.title = next_title
|
||||
s.llm_title_generated = True
|
||||
s.save(touch_updated_at=False)
|
||||
effective_title = s.title
|
||||
_put_title_status(put_event, session_id, 'refreshed', llm_status, effective_title, raw_preview)
|
||||
put_event('title', {'session_id': session_id, 'title': effective_title})
|
||||
logger.info("Adaptive title refresh: session=%s new_title=%r", session_id, effective_title)
|
||||
except Exception:
|
||||
logger.debug("Background title refresh failed for session %s", session_id, exc_info=True)
|
||||
|
||||
|
||||
def _maybe_schedule_title_refresh(session, put_event, agent):
|
||||
"""Check if the session is due for an adaptive title refresh and schedule it."""
|
||||
refresh_interval = _get_title_refresh_interval()
|
||||
if refresh_interval <= 0:
|
||||
return
|
||||
current_title = str(session.title or '').strip()
|
||||
if not current_title or current_title in ('Untitled', 'New Chat'):
|
||||
return
|
||||
if not getattr(session, 'llm_title_generated', False):
|
||||
return
|
||||
exchange_count = _count_exchanges(session.messages)
|
||||
if exchange_count <= 0 or exchange_count % refresh_interval != 0:
|
||||
return
|
||||
last_u, last_a = _latest_exchange_snippets(session.messages)
|
||||
if not last_u and not last_a:
|
||||
return
|
||||
threading.Thread(
|
||||
target=_run_background_title_refresh,
|
||||
args=(session.session_id, last_u, last_a, current_title, put_event, agent),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
|
||||
def _sanitize_messages_for_api(messages):
|
||||
"""Return a deep copy of messages with only API-safe fields.
|
||||
|
||||
@@ -1822,6 +1953,10 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
# which may be rotated during context compression. The client captured
|
||||
# activeSid = original session_id so they must match for stream_end to close.
|
||||
put('stream_end', {'session_id': session_id})
|
||||
# Adaptive title refresh: re-generate title from latest exchange
|
||||
# every N exchanges (when enabled in settings). Runs after stream_end
|
||||
# so it doesn't block the stream.
|
||||
_maybe_schedule_title_refresh(s, put, agent)
|
||||
finally:
|
||||
# Stop the live metering ticker
|
||||
_metering_stop.set()
|
||||
|
||||
+9
-9
@@ -710,17 +710,17 @@ function _pickSkin(name){
|
||||
|
||||
function _syncThemePicker(active){
|
||||
document.querySelectorAll('#themePickerGrid .theme-pick-btn').forEach(btn=>{
|
||||
const sel=btn.dataset.themeVal===active;
|
||||
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
|
||||
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
|
||||
btn.classList.toggle('active',btn.dataset.themeVal===active);
|
||||
btn.style.borderColor='';
|
||||
btn.style.boxShadow='';
|
||||
});
|
||||
}
|
||||
|
||||
function _syncSkinPicker(active){
|
||||
document.querySelectorAll('#skinPickerGrid .skin-pick-btn').forEach(btn=>{
|
||||
const sel=btn.dataset.skinVal===active;
|
||||
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
|
||||
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
|
||||
btn.classList.toggle('active',btn.dataset.skinVal===active);
|
||||
btn.style.borderColor='';
|
||||
btn.style.boxShadow='';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -743,9 +743,9 @@ function _pickFontSize(size){
|
||||
|
||||
function _syncFontSizePicker(active){
|
||||
document.querySelectorAll('#fontSizePickerGrid .font-size-pick-btn').forEach(btn=>{
|
||||
const sel=btn.dataset.fontSizeVal===(active||'default');
|
||||
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
|
||||
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
|
||||
btn.classList.toggle('active',btn.dataset.fontSizeVal===(active||'default'));
|
||||
btn.style.borderColor='';
|
||||
btn.style.boxShadow='';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+56
-52
@@ -308,11 +308,10 @@ const LOCALES = {
|
||||
new_conversation: 'New conversation',
|
||||
filter_conversations: 'Filter conversations...',
|
||||
session_time_unknown: 'Unknown',
|
||||
session_time_just_now: 'just now',
|
||||
session_time_minutes_ago: (n) => `${n} minute${n === 1 ? '' : 's'} ago`,
|
||||
session_time_hours_ago: (n) => `${n} hour${n === 1 ? '' : 's'} ago`,
|
||||
session_time_days_ago: (n) => `${n} day${n === 1 ? '' : 's'} ago`,
|
||||
session_time_last_week: 'last week',
|
||||
session_time_minutes_ago: (n) => `${n}m`,
|
||||
session_time_hours_ago: (n) => `${n}h`,
|
||||
session_time_days_ago: (n) => `${n}d`,
|
||||
session_time_last_week: '1w',
|
||||
session_time_bucket_today: 'Today',
|
||||
session_time_bucket_yesterday: 'Yesterday',
|
||||
session_time_bucket_this_week: 'This week',
|
||||
@@ -340,6 +339,12 @@ const LOCALES = {
|
||||
settings_sidebar_density_compact: 'Compact',
|
||||
settings_sidebar_density_detailed: 'Detailed',
|
||||
settings_desc_sidebar_density: 'Controls how much metadata the session list shows in the left sidebar.',
|
||||
settings_label_auto_title_refresh: 'Adaptive title refresh',
|
||||
settings_auto_title_refresh_off: 'Off',
|
||||
settings_auto_title_refresh_5: 'Every 5 exchanges',
|
||||
settings_auto_title_refresh_10: 'Every 10 exchanges',
|
||||
settings_auto_title_refresh_20: 'Every 20 exchanges',
|
||||
settings_desc_auto_title_refresh: 'Automatically re-generates the session title based on the latest exchange, keeping it relevant as the conversation evolves. Requires an LLM title generation model to be configured.',
|
||||
settings_desc_cli_sessions: 'Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.',
|
||||
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.',
|
||||
@@ -811,38 +816,10 @@ const LOCALES = {
|
||||
new_conversation: 'Новая беседа',
|
||||
filter_conversations: 'Фильтр бесед...',
|
||||
session_time_unknown: 'Неизвестно',
|
||||
session_time_just_now: 'только что',
|
||||
session_time_minutes_ago: (n) => {
|
||||
const mod10 = n % 10;
|
||||
const mod100 = n % 100;
|
||||
const word = mod10 === 1 && mod100 !== 11
|
||||
? 'минута'
|
||||
: (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)
|
||||
? 'минуты'
|
||||
: 'минут');
|
||||
return `${n} ${word} назад`;
|
||||
},
|
||||
session_time_hours_ago: (n) => {
|
||||
const mod10 = n % 10;
|
||||
const mod100 = n % 100;
|
||||
const word = mod10 === 1 && mod100 !== 11
|
||||
? 'час'
|
||||
: (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)
|
||||
? 'часа'
|
||||
: 'часов');
|
||||
return `${n} ${word} назад`;
|
||||
},
|
||||
session_time_days_ago: (n) => {
|
||||
const mod10 = n % 10;
|
||||
const mod100 = n % 100;
|
||||
const word = mod10 === 1 && mod100 !== 11
|
||||
? 'день'
|
||||
: (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)
|
||||
? 'дня'
|
||||
: 'дней');
|
||||
return `${n} ${word} назад`;
|
||||
},
|
||||
session_time_last_week: 'на прошлой неделе',
|
||||
session_time_minutes_ago: (n) => `${n}м`,
|
||||
session_time_hours_ago: (n) => `${n}ч`,
|
||||
session_time_days_ago: (n) => `${n}д`,
|
||||
session_time_last_week: '1н',
|
||||
session_time_bucket_today: 'Сегодня',
|
||||
session_time_bucket_yesterday: 'Вчера',
|
||||
session_time_bucket_this_week: 'На этой неделе',
|
||||
@@ -869,6 +846,12 @@ const LOCALES = {
|
||||
settings_sidebar_density_compact: 'Компактно',
|
||||
settings_sidebar_density_detailed: 'Подробно',
|
||||
settings_desc_sidebar_density: 'Управляет тем, сколько метаданных показывается в списке сеансов на левой панели.',
|
||||
settings_label_auto_title_refresh: 'Адаптивное обновление заголовка',
|
||||
settings_auto_title_refresh_off: 'Выкл',
|
||||
settings_auto_title_refresh_5: 'Каждые 5 обменов',
|
||||
settings_auto_title_refresh_10: 'Каждые 10 обменов',
|
||||
settings_auto_title_refresh_20: 'Каждые 20 обменов',
|
||||
settings_desc_auto_title_refresh: 'Автоматически переформулирует заголовок сессии на основе последнего обмена, чтобы он оставался актуальным по мере развития беседы. Требует настроенную модель генерации заголовков.',
|
||||
settings_desc_cli_sessions: 'Объединяет сеансы из Hermes CLI (state.db) в список сеансов. Нажмите на CLI-сеанс, чтобы импортировать его и продолжить разговор.',
|
||||
settings_desc_sync_insights: 'Синхронизирует использование токенов WebUI в state.db, чтобы Hermes /insights включал данные браузерных сеансов. Выключено по умолчанию.',
|
||||
settings_desc_check_updates: 'Показывает баннер, когда доступны более новые версии WebUI или Agent. Периодически выполняет git fetch в фоне.',
|
||||
@@ -1412,11 +1395,10 @@ const LOCALES = {
|
||||
new_conversation: 'Nueva conversación',
|
||||
filter_conversations: 'Filtrar conversaciones...',
|
||||
session_time_unknown: 'Desconocido',
|
||||
session_time_just_now: 'justo ahora',
|
||||
session_time_minutes_ago: (n) => `hace ${n} minuto${n === 1 ? '' : 's'}`,
|
||||
session_time_hours_ago: (n) => `hace ${n} hora${n === 1 ? '' : 's'}`,
|
||||
session_time_days_ago: (n) => `hace ${n} día${n === 1 ? '' : 's'}`,
|
||||
session_time_last_week: 'la semana pasada',
|
||||
session_time_minutes_ago: (n) => `${n}m`,
|
||||
session_time_hours_ago: (n) => `${n}h`,
|
||||
session_time_days_ago: (n) => `${n}d`,
|
||||
session_time_last_week: '1sem',
|
||||
session_time_bucket_today: 'Hoy',
|
||||
session_time_bucket_yesterday: 'Ayer',
|
||||
session_time_bucket_this_week: 'Esta semana',
|
||||
@@ -1444,6 +1426,12 @@ const LOCALES = {
|
||||
settings_sidebar_density_compact: 'Compacta',
|
||||
settings_sidebar_density_detailed: 'Detallada',
|
||||
settings_desc_sidebar_density: 'Controla cuántos metadatos muestra la lista de sesiones en la barra lateral izquierda.',
|
||||
settings_label_auto_title_refresh: 'Actualización adaptativa del título',
|
||||
settings_auto_title_refresh_off: 'Desactivado',
|
||||
settings_auto_title_refresh_5: 'Cada 5 intercambios',
|
||||
settings_auto_title_refresh_10: 'Cada 10 intercambios',
|
||||
settings_auto_title_refresh_20: 'Cada 20 intercambios',
|
||||
settings_desc_auto_title_refresh: 'Regenera automáticamente el título de la sesión basándose en el último intercambio, manteniéndolo relevante a medida que evoluciona la conversación. Requiere un modelo LLM de generación de títulos configurado.',
|
||||
settings_desc_cli_sessions: 'Fusiona las sesiones del CLI de Hermes (state.db) en la lista de sesiones. Haz clic en una sesión de CLI para importarla y continuar la conversación.',
|
||||
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.',
|
||||
@@ -1997,6 +1985,12 @@ const LOCALES = {
|
||||
settings_sidebar_density_compact: 'Kompakt',
|
||||
settings_sidebar_density_detailed: 'Detailliert',
|
||||
settings_desc_sidebar_density: 'Steuert, wie viele Metadaten die Sitzungsliste in der linken Seitenleiste anzeigt.',
|
||||
settings_label_auto_title_refresh: 'Adaptive Titelaktualisierung',
|
||||
settings_auto_title_refresh_off: 'Aus',
|
||||
settings_auto_title_refresh_5: 'Alle 5 Antworten',
|
||||
settings_auto_title_refresh_10: 'Alle 10 Antworten',
|
||||
settings_auto_title_refresh_20: 'Alle 20 Antworten',
|
||||
settings_desc_auto_title_refresh: 'Generiert den Sitzungstitel automatisch anhand des letzten Austauschs neu und hält ihn so aktuell, während sich das Gespräch entwickelt. Erfordert ein konfiguriertes LLM-Titelgenerierungsmodell.',
|
||||
settings_desc_cli_sessions: 'Fügt Sitzungen aus der Hermes CLI (state.db) in die Sitzungsliste ein. Klicken Sie auf eine CLI-Sitzung, um sie zu importieren und das Gespräch fortzusetzen.',
|
||||
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.',
|
||||
@@ -2314,11 +2308,10 @@ const LOCALES = {
|
||||
new_conversation: '新建对话',
|
||||
filter_conversations: '筛选对话…',
|
||||
session_time_unknown: '未知',
|
||||
session_time_just_now: '刚刚',
|
||||
session_time_minutes_ago: (n) => `${n} 分钟前`,
|
||||
session_time_hours_ago: (n) => `${n} 小时前`,
|
||||
session_time_days_ago: (n) => `${n} 天前`,
|
||||
session_time_last_week: '上周',
|
||||
session_time_minutes_ago: (n) => `${n}分`,
|
||||
session_time_hours_ago: (n) => `${n}小时`,
|
||||
session_time_days_ago: (n) => `${n}天`,
|
||||
session_time_last_week: '1周',
|
||||
session_time_bucket_today: '今天',
|
||||
session_time_bucket_yesterday: '昨天',
|
||||
session_time_bucket_this_week: '本周',
|
||||
@@ -2377,6 +2370,12 @@ const LOCALES = {
|
||||
settings_sidebar_density_compact: '紧凑',
|
||||
settings_sidebar_density_detailed: '详细',
|
||||
settings_desc_sidebar_density: '控制左侧会话列表展示多少元信息。',
|
||||
settings_label_auto_title_refresh: '\u81ea\u9002\u5e94\u6807\u9898\u66f4\u65b0',
|
||||
settings_auto_title_refresh_off: '\u5173\u95ed',
|
||||
settings_auto_title_refresh_5: '\u6bcf 5 \u8f6e\u5bf9\u8bdd',
|
||||
settings_auto_title_refresh_10: '\u6bcf 10 \u8f6e\u5bf9\u8bdd',
|
||||
settings_auto_title_refresh_20: '\u6bcf 20 \u8f6e\u5bf9\u8bdd',
|
||||
settings_desc_auto_title_refresh: '\u57fa\u4e8e\u6700\u65b0\u5bf9\u8bdd\u81ea\u52a8\u91cd\u65b0\u751f\u6210\u4f1a\u8bdd\u6807\u9898\uff0c\u4f7f\u5176\u968f\u5bf9\u8bdd\u53d1\u5c55\u4fdd\u6301\u76f8\u5173\u3002\u9700\u8981\u914d\u7f6e LLM \u6807\u9898\u751f\u6210\u6a21\u578b\u3002',
|
||||
settings_desc_cli_sessions: '将 Hermes CLI(state.db)中的会话合并到会话列表。点击某个 CLI 会话可导入并继续对话。',
|
||||
settings_desc_sync_insights: '将 WebUI token 使用情况同步到 state.db,使 hermes /insights 包含浏览器会话数据。默认关闭。',
|
||||
settings_desc_check_updates: '当有更新的 WebUI 或助手版本时显示横幅。会在后台定期执行 git fetch。',
|
||||
@@ -2943,6 +2942,12 @@ const LOCALES = {
|
||||
settings_sidebar_density_compact: '精簡',
|
||||
settings_sidebar_density_detailed: '詳細',
|
||||
settings_desc_sidebar_density: '控制左側對話清單要顯示多少額外資訊。',
|
||||
settings_label_auto_title_refresh: '\u81ea\u9002\u61c9\u6a19\u984c\u66f4\u65b0',
|
||||
settings_auto_title_refresh_off: '\u95dc\u9589',
|
||||
settings_auto_title_refresh_5: '\u6bcf 5 \u8f2a\u5c0d\u8a71',
|
||||
settings_auto_title_refresh_10: '\u6bcf 10 \u8f2a\u5c0d\u8a71',
|
||||
settings_auto_title_refresh_20: '\u6bcf 20 \u8f2a\u5c0d\u8a71',
|
||||
settings_desc_auto_title_refresh: '\u57fa\u65bc\u6700\u65b0\u5c0d\u8a71\u81ea\u52d5\u91cd\u65b0\u751f\u6210\u6703\u8a71\u6a19\u984c\uff0c\u4f7f\u5176\u968f\u5c0d\u8a71\u767c\u5c55\u4fdd\u6301\u76f8\u95dc\u3002\u9700\u8981\u914d\u7f6e LLM \u6a19\u984c\u751f\u6210\u6a21\u578b\u3002',
|
||||
settings_desc_cli_sessions: '將 Hermes CLI (的 state.db) 中的會話添加到會話清單。點擊一個 CLI 會話將導入它並繼續對話。',
|
||||
settings_desc_sync_insights: '將 WebUI token 使用情況同步到 state.db,使 hermes /insights 包含瀏覽器會話數據。預設未啟用。',
|
||||
settings_desc_check_updates: '當有更新的 WebUI 或助手版本時顯示標記。將在後台正常執行 Git-Fetch。',
|
||||
@@ -3151,11 +3156,10 @@ const LOCALES = {
|
||||
session_time_bucket_this_week: '\u672c\u9031',
|
||||
session_time_bucket_today: '\u4eca\u5929',
|
||||
session_time_bucket_yesterday: '\u6628\u5929',
|
||||
session_time_days_ago: (d) => `${d} 天前`,
|
||||
session_time_hours_ago: (h) => `${h} 小時前`,
|
||||
session_time_just_now: '\u525b\u525b',
|
||||
session_time_last_week: '\u4e0a\u9031',
|
||||
session_time_minutes_ago: (m) => `${m} 分鐘前`,
|
||||
session_time_days_ago: (d) => `${d}\u5929`,
|
||||
session_time_hours_ago: (h) => `${h}\u5c0f\u6642`,
|
||||
session_time_last_week: '1\u9031',
|
||||
session_time_minutes_ago: (m) => `${m}\u5206`,
|
||||
session_time_unknown: '\u672a\u77e5',
|
||||
settings_unsaved_changes: '\u60a8\u6709\u672a\u5132\u5b58\u7684\u8b8a\u66f4\u3002',
|
||||
sign_out_failed: '\u767b\u51fa\u5931\u6557\uff1a',
|
||||
|
||||
@@ -652,6 +652,16 @@
|
||||
</select>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sidebar_density">Controls how much metadata the session list shows in the left sidebar.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsAutoTitleRefresh" data-i18n="settings_label_auto_title_refresh">Adaptive title refresh</label>
|
||||
<select id="settingsAutoTitleRefresh" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||
<option value="0" data-i18n="settings_auto_title_refresh_off">Off</option>
|
||||
<option value="5" data-i18n="settings_auto_title_refresh_5">Every 5 exchanges</option>
|
||||
<option value="10" data-i18n="settings_auto_title_refresh_10">Every 10 exchanges</option>
|
||||
<option value="20" data-i18n="settings_auto_title_refresh_20">Every 20 exchanges</option>
|
||||
</select>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_auto_title_refresh">Automatically re-generates the session title based on the latest exchange, keeping it relevant as the conversation evolves. Requires an LLM title generation model to be configured.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsShowCliSessions" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
|
||||
@@ -2342,6 +2342,12 @@ async function loadSettingsPanel(){
|
||||
sidebarDensitySel.value=settings.sidebar_density==='detailed'?'detailed':'compact';
|
||||
sidebarDensitySel.addEventListener('change',_markSettingsDirty,{once:false});
|
||||
}
|
||||
const autoTitleRefreshSel=$('settingsAutoTitleRefresh');
|
||||
if(autoTitleRefreshSel){
|
||||
const val=String(settings.auto_title_refresh_every||'0');
|
||||
autoTitleRefreshSel.value=['0','5','10','20'].includes(val)?val:'0';
|
||||
autoTitleRefreshSel.addEventListener('change',_markSettingsDirty,{once:false});
|
||||
}
|
||||
// Bot name
|
||||
const botNameField=$('settingsBotName');
|
||||
if(botNameField){botNameField.value=settings.bot_name||'Hermes';botNameField.addEventListener('input',_markSettingsDirty,{once:false});}
|
||||
@@ -2643,6 +2649,7 @@ async function saveSettings(andClose){
|
||||
body.notifications_enabled=!!($('settingsNotificationsEnabled')||{}).checked;
|
||||
body.show_thinking=window._showThinking!==false;
|
||||
body.sidebar_density=sidebarDensity;
|
||||
body.auto_title_refresh_every=(($('settingsAutoTitleRefresh')||{}).value||'0');
|
||||
const botName=(($('settingsBotName')||{}).value||'').trim();
|
||||
body.bot_name=botName||'Hermes';
|
||||
// Password: only act if the field has content; blank = leave auth unchanged
|
||||
|
||||
+2
-2
@@ -689,7 +689,7 @@ function _formatRelativeSessionTime(timestampMs, nowMs = Date.now()) {
|
||||
const {startOfToday, startOfYesterday, startOfWeek, startOfLastWeek} = _sessionCalendarBoundaries(nowMs);
|
||||
const dayDiff = Math.max(0, _localDayOrdinal(nowMs) - _localDayOrdinal(timestampMs));
|
||||
if (timestampMs >= startOfToday) {
|
||||
if (diffMs < minute) return t('session_time_just_now');
|
||||
if (diffMs < minute) return t('session_time_minutes_ago', 1);
|
||||
if (diffMs < hour) {
|
||||
const minutes = Math.floor(diffMs / minute);
|
||||
return t('session_time_minutes_ago', minutes);
|
||||
@@ -697,7 +697,7 @@ function _formatRelativeSessionTime(timestampMs, nowMs = Date.now()) {
|
||||
const hours = Math.floor(diffMs / hour);
|
||||
return t('session_time_hours_ago', hours);
|
||||
}
|
||||
if (timestampMs >= startOfYesterday) return t('session_time_bucket_yesterday');
|
||||
if (timestampMs >= startOfYesterday) return t('session_time_days_ago', 1);
|
||||
if (timestampMs >= startOfWeek) return t('session_time_days_ago', dayDiff);
|
||||
if (timestampMs >= startOfLastWeek) return t('session_time_last_week');
|
||||
return _formatSessionDate(timestampMs, nowMs);
|
||||
|
||||
@@ -1661,6 +1661,10 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
|
||||
#mainSettings .theme-pick-btn:hover,
|
||||
#mainSettings .skin-pick-btn:hover,
|
||||
#mainSettings .font-size-pick-btn:hover{border-color:var(--accent-bg-strong)!important;background:var(--surface)!important;}
|
||||
/* Active/selected state for picker cards — must use !important to beat the base border-color:var(--border)!important rule above */
|
||||
#mainSettings .theme-pick-btn.active,
|
||||
#mainSettings .skin-pick-btn.active,
|
||||
#mainSettings .font-size-pick-btn.active{border-color:var(--accent)!important;box-shadow:0 0 0 1px var(--accent-bg-strong)!important;background:var(--surface)!important;}
|
||||
|
||||
/* Responsive: tighten canvas on small screens. */
|
||||
@media (max-width: 768px){
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Tests for adaptive session title refresh helpers (PR #1058).
|
||||
|
||||
Covers all five new functions added to api/streaming.py:
|
||||
- _count_exchanges
|
||||
- _latest_exchange_snippets
|
||||
- _get_title_refresh_interval
|
||||
- _run_background_title_refresh
|
||||
- _maybe_schedule_title_refresh
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import threading
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure the project root is on sys.path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from api.streaming import (
|
||||
_count_exchanges,
|
||||
_latest_exchange_snippets,
|
||||
_get_title_refresh_interval,
|
||||
_run_background_title_refresh,
|
||||
_maybe_schedule_title_refresh,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_auth_sessions():
|
||||
"""Snapshot and restore api.auth._sessions around each test.
|
||||
|
||||
Importing api.streaming can trigger api.config.load_settings() which may
|
||||
call into api.auth and create a real session token. Without this fixture,
|
||||
that stale token leaks into test_auth_session_persistence.py tests (which
|
||||
assume _sessions starts empty) when our file runs first alphabetically.
|
||||
"""
|
||||
import api.auth as _auth
|
||||
snapshot = dict(_auth._sessions)
|
||||
yield
|
||||
_auth._sessions.clear()
|
||||
_auth._sessions.update(snapshot)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _user_msg(text):
|
||||
return {'role': 'user', 'content': text}
|
||||
|
||||
|
||||
def _asst_msg(text, tool_calls=None):
|
||||
msg = {'role': 'assistant', 'content': text}
|
||||
if tool_calls is not None:
|
||||
msg['tool_calls'] = tool_calls
|
||||
return msg
|
||||
|
||||
|
||||
def _tool_only_asst():
|
||||
"""Assistant message that only has tool_calls, no real text."""
|
||||
return {'role': 'assistant', 'content': '', 'tool_calls': [{'id': 't1', 'type': 'function'}]}
|
||||
|
||||
|
||||
def _make_session(title='My Title', llm_title_generated=True, messages=None, session_id='sid1'):
|
||||
s = MagicMock()
|
||||
s.title = title
|
||||
s.llm_title_generated = llm_title_generated
|
||||
s.messages = messages or []
|
||||
s.session_id = session_id
|
||||
s.save = MagicMock()
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _count_exchanges
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCountExchanges:
|
||||
def test_empty_messages_returns_zero(self):
|
||||
assert _count_exchanges([]) == 0
|
||||
|
||||
def test_none_messages_returns_zero(self):
|
||||
assert _count_exchanges(None) == 0
|
||||
|
||||
def test_counts_only_user_messages(self):
|
||||
msgs = [_user_msg('hello'), _asst_msg('hi'), _user_msg('world')]
|
||||
assert _count_exchanges(msgs) == 2
|
||||
|
||||
def test_skips_empty_user_messages(self):
|
||||
msgs = [_user_msg(''), _user_msg(' '), _user_msg('real question')]
|
||||
assert _count_exchanges(msgs) == 1
|
||||
|
||||
def test_counts_list_content_user_messages(self):
|
||||
msgs = [
|
||||
{'role': 'user', 'content': [{'type': 'text', 'text': 'list question'}]},
|
||||
]
|
||||
assert _count_exchanges(msgs) == 1
|
||||
|
||||
def test_skips_empty_list_content(self):
|
||||
msgs = [
|
||||
{'role': 'user', 'content': [{'type': 'text', 'text': ' '}]},
|
||||
]
|
||||
assert _count_exchanges(msgs) == 0
|
||||
|
||||
def test_ignores_non_user_roles(self):
|
||||
msgs = [_asst_msg('response'), {'role': 'system', 'content': 'system prompt'}]
|
||||
assert _count_exchanges(msgs) == 0
|
||||
|
||||
def test_ignores_non_dict_entries(self):
|
||||
msgs = ['not a dict', _user_msg('real'), None]
|
||||
assert _count_exchanges(msgs) == 1
|
||||
|
||||
def test_five_exchanges(self):
|
||||
msgs = []
|
||||
for i in range(5):
|
||||
msgs.append(_user_msg(f'question {i}'))
|
||||
msgs.append(_asst_msg(f'answer {i}'))
|
||||
assert _count_exchanges(msgs) == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _latest_exchange_snippets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLatestExchangeSnippets:
|
||||
def test_empty_returns_empty_strings(self):
|
||||
u, a = _latest_exchange_snippets([])
|
||||
assert u == '' and a == ''
|
||||
|
||||
def test_none_returns_empty_strings(self):
|
||||
u, a = _latest_exchange_snippets(None)
|
||||
assert u == '' and a == ''
|
||||
|
||||
def test_basic_pair(self):
|
||||
msgs = [_user_msg('first q'), _asst_msg('first a'),
|
||||
_user_msg('second q'), _asst_msg('second a')]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert u == 'second q'
|
||||
assert a == 'second a'
|
||||
|
||||
def test_returns_latest_not_first(self):
|
||||
msgs = [_user_msg('old q'), _asst_msg('old a'),
|
||||
_user_msg('new q'), _asst_msg('new a')]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert u == 'new q'
|
||||
|
||||
def test_skips_tool_call_only_assistant(self):
|
||||
"""An assistant msg with tool_calls and no real text should be skipped."""
|
||||
msgs = [_user_msg('q'), _asst_msg('real answer'),
|
||||
_user_msg('q2'), _tool_only_asst()]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
# _tool_only_asst should be skipped; fall back to previous real assistant
|
||||
assert a == 'real answer'
|
||||
assert u == 'q2'
|
||||
|
||||
def test_truncates_long_content(self):
|
||||
long_text = 'x' * 600
|
||||
msgs = [_user_msg(long_text), _asst_msg(long_text)]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert len(u) == 500
|
||||
assert len(a) == 500
|
||||
|
||||
def test_no_assistant_message(self):
|
||||
msgs = [_user_msg('q')]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert u == 'q'
|
||||
assert a == ''
|
||||
|
||||
def test_no_user_message(self):
|
||||
msgs = [_asst_msg('a')]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert u == ''
|
||||
assert a == 'a'
|
||||
|
||||
def test_ignores_non_dict_entries(self):
|
||||
msgs = ['noise', _user_msg('q'), None, _asst_msg('a')]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert u == 'q'
|
||||
assert a == 'a'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_title_refresh_interval
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetTitleRefreshInterval:
|
||||
def test_returns_int_for_valid_setting(self):
|
||||
# _get_title_refresh_interval does a local import: `from api.config import load_settings`
|
||||
# so patch the source module, not api.streaming
|
||||
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': '5'}):
|
||||
assert _get_title_refresh_interval() == 5
|
||||
|
||||
def test_returns_zero_for_off_setting(self):
|
||||
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': '0'}):
|
||||
assert _get_title_refresh_interval() == 0
|
||||
|
||||
def test_returns_zero_when_key_absent(self):
|
||||
with patch('api.config.load_settings', return_value={}):
|
||||
assert _get_title_refresh_interval() == 0
|
||||
|
||||
def test_returns_zero_on_exception(self):
|
||||
with patch('api.config.load_settings', side_effect=Exception('boom')):
|
||||
assert _get_title_refresh_interval() == 0
|
||||
|
||||
def test_valid_values_10_and_20(self):
|
||||
for val in ('10', '20'):
|
||||
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': val}):
|
||||
assert _get_title_refresh_interval() == int(val)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_background_title_refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunBackgroundTitleRefresh:
|
||||
def _make_put_event(self):
|
||||
events = []
|
||||
def put(name, data):
|
||||
events.append((name, data))
|
||||
return put, events
|
||||
|
||||
def _make_session_obj(self, title='Old Title'):
|
||||
s = MagicMock()
|
||||
s.title = title
|
||||
s.llm_title_generated = True
|
||||
s.save = MagicMock()
|
||||
return s
|
||||
|
||||
def test_skips_when_title_changed_before_call(self):
|
||||
"""If the title has changed (manual rename) since the refresh was scheduled, skip."""
|
||||
put, events = self._make_put_event()
|
||||
with patch('api.streaming.get_session') as mock_get, \
|
||||
patch('api.streaming.SESSIONS', {}), \
|
||||
patch('api.streaming.LOCK', threading.Lock()):
|
||||
s = self._make_session_obj(title='Different Title')
|
||||
mock_get.return_value = s
|
||||
_run_background_title_refresh(
|
||||
'sid', 'user', 'asst', 'Old Title', put, agent=None
|
||||
)
|
||||
# No 'title' event should have been emitted
|
||||
assert not any(name == 'title' for name, _ in events)
|
||||
|
||||
def test_skips_if_session_not_found(self):
|
||||
put, events = self._make_put_event()
|
||||
with patch('api.streaming.get_session', side_effect=KeyError('not found')):
|
||||
_run_background_title_refresh('sid', 'u', 'a', 'title', put)
|
||||
assert events == []
|
||||
|
||||
def test_skips_when_title_is_untitled(self):
|
||||
put, events = self._make_put_event()
|
||||
with patch('api.streaming.get_session') as mock_get:
|
||||
s = self._make_session_obj(title='Untitled')
|
||||
mock_get.return_value = s
|
||||
_run_background_title_refresh('sid', 'u', 'a', 'Untitled', put)
|
||||
assert not any(name == 'title' for name, _ in events)
|
||||
|
||||
def test_skips_same_title(self):
|
||||
"""If the LLM generates a title identical to the current one, no event is emitted."""
|
||||
put, events = self._make_put_event()
|
||||
with patch('api.streaming.get_session') as mock_get, \
|
||||
patch('api.streaming._aux_title_configured', return_value=True), \
|
||||
patch('api.streaming._generate_llm_session_title_via_aux',
|
||||
return_value=('Old Title', 'llm_ok', 'raw')), \
|
||||
patch('api.streaming.SESSIONS', {}), \
|
||||
patch('api.streaming.LOCK', threading.Lock()):
|
||||
s = self._make_session_obj(title='Old Title')
|
||||
mock_get.return_value = s
|
||||
_run_background_title_refresh('sid', 'u', 'a', 'Old Title', put)
|
||||
assert not any(name == 'title' for name, _ in events)
|
||||
|
||||
def test_emits_title_event_on_new_title(self):
|
||||
put, events = self._make_put_event()
|
||||
s = self._make_session_obj(title='Old Title')
|
||||
# Use a real dict for SESSIONS so .get() works, pre-populated with our session
|
||||
fake_sessions = {'sid': s}
|
||||
with patch('api.streaming.get_session', return_value=s), \
|
||||
patch('api.streaming._aux_title_configured', return_value=True), \
|
||||
patch('api.streaming._generate_llm_session_title_via_aux',
|
||||
return_value=('New Refreshed Title', 'llm_ok', 'raw')), \
|
||||
patch('api.streaming.SESSIONS', fake_sessions), \
|
||||
patch('api.streaming.LOCK', threading.Lock()):
|
||||
_run_background_title_refresh('sid', 'u', 'a', 'Old Title', put)
|
||||
title_events = [(n, d) for n, d in events if n == 'title']
|
||||
assert len(title_events) == 1
|
||||
assert title_events[0][1]['title'] == 'New Refreshed Title'
|
||||
|
||||
def test_exceptions_are_silently_swallowed(self):
|
||||
"""Any unexpected error inside must not propagate — it's a background daemon."""
|
||||
put, events = self._make_put_event()
|
||||
with patch('api.streaming.get_session', side_effect=RuntimeError('oops')):
|
||||
# Should not raise
|
||||
_run_background_title_refresh('sid', 'u', 'a', 'title', put)
|
||||
assert events == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _maybe_schedule_title_refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMaybeScheduleTitleRefresh:
|
||||
def _noop_put(self, name, data):
|
||||
pass
|
||||
|
||||
def test_does_nothing_when_disabled(self):
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=0):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
|
||||
def test_does_nothing_when_title_is_empty(self):
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
session = _make_session(title='', messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
|
||||
def test_does_nothing_for_untitled(self):
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
session = _make_session(title='Untitled', messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
|
||||
def test_does_nothing_when_title_not_llm_generated(self):
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
session = _make_session(llm_title_generated=False,
|
||||
messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
|
||||
def test_does_nothing_when_exchange_count_not_at_interval(self):
|
||||
"""Refresh only fires when exchange_count % interval == 0 (and > 0)."""
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
# 4 exchanges — not a multiple of 5
|
||||
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 4)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
|
||||
def test_spawns_thread_at_exact_interval(self):
|
||||
"""Refresh fires when exchange_count == refresh_interval."""
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
spawned = []
|
||||
with patch('threading.Thread') as mock_thread_cls:
|
||||
mock_thread = MagicMock()
|
||||
mock_thread_cls.return_value = mock_thread
|
||||
# 5 user messages = 5 exchanges
|
||||
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert mock_thread_cls.called
|
||||
assert mock_thread.start.called
|
||||
|
||||
def test_spawns_thread_at_multiple_of_interval(self):
|
||||
"""Refresh fires at 10 exchanges when interval is 5."""
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
with patch('threading.Thread') as mock_thread_cls:
|
||||
mock_thread = MagicMock()
|
||||
mock_thread_cls.return_value = mock_thread
|
||||
# 10 exchanges
|
||||
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 10)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert mock_thread_cls.called
|
||||
|
||||
def test_does_nothing_when_no_exchange_content(self):
|
||||
"""Even at interval, if both snippets are empty, don't spawn."""
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5), \
|
||||
patch('api.streaming._latest_exchange_snippets', return_value=('', '')):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Regression tests for settings picker active-state highlighting.
|
||||
|
||||
The theme, skin, and font-size pickers in the Appearance settings tab must show
|
||||
the currently-selected option with a visible accent border. This was broken because
|
||||
the CSS rule used !important on border-color:var(--border) which overrode the inline
|
||||
style that _syncThemePicker/etc. set. Fixed by moving to .active CSS class + !important
|
||||
override on the active state.
|
||||
|
||||
Issue: #1059 (settings picker active state)
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
BOOT_JS = (Path(__file__).parent.parent / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (Path(__file__).parent.parent / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestSettingsPickerActiveState:
|
||||
"""The selected picker card must be visually distinct via the .active class."""
|
||||
|
||||
def test_theme_picker_uses_active_class(self):
|
||||
"""_syncThemePicker must toggle .active class, not set inline borderColor."""
|
||||
idx = BOOT_JS.find("function _syncThemePicker(")
|
||||
assert idx >= 0, "_syncThemePicker function not found in boot.js"
|
||||
body = BOOT_JS[idx:idx + 300]
|
||||
assert "classList.toggle" in body, (
|
||||
"_syncThemePicker must use classList.toggle('active', ...) — "
|
||||
"inline style.borderColor is overridden by !important CSS rules"
|
||||
)
|
||||
# Confirm no accent/border2 color values set inline (clearing with '' is OK)
|
||||
assert "var(--accent)" not in body and "var(--border2)" not in body, (
|
||||
"_syncThemePicker must not set var(--accent) or var(--border2) inline — "
|
||||
"those are overridden by !important CSS rules"
|
||||
)
|
||||
|
||||
def test_font_size_picker_uses_active_class(self):
|
||||
"""_syncFontSizePicker must toggle .active class."""
|
||||
idx = BOOT_JS.find("function _syncFontSizePicker(")
|
||||
assert idx >= 0, "_syncFontSizePicker function not found in boot.js"
|
||||
body = BOOT_JS[idx:idx + 300]
|
||||
assert "classList.toggle" in body, (
|
||||
"_syncFontSizePicker must use classList.toggle('active', ...)"
|
||||
)
|
||||
assert "var(--accent)" not in body and "var(--border2)" not in body, (
|
||||
"_syncFontSizePicker must not set var(--accent) or var(--border2) inline"
|
||||
)
|
||||
|
||||
def test_skin_picker_uses_active_class(self):
|
||||
"""_syncSkinPicker must toggle .active class."""
|
||||
idx = BOOT_JS.find("function _syncSkinPicker(")
|
||||
assert idx >= 0, "_syncSkinPicker function not found in boot.js"
|
||||
body = BOOT_JS[idx:idx + 300]
|
||||
assert "classList.toggle" in body, (
|
||||
"_syncSkinPicker must use classList.toggle('active', ...)"
|
||||
)
|
||||
assert "var(--accent)" not in body and "var(--border2)" not in body, (
|
||||
"_syncSkinPicker must not set var(--accent) or var(--border2) inline"
|
||||
)
|
||||
|
||||
def test_css_active_rule_beats_base_rule(self):
|
||||
"""CSS must have a .active rule with !important that overrides the base border-color rule."""
|
||||
assert ".theme-pick-btn.active" in STYLE_CSS, (
|
||||
"style.css must have a .theme-pick-btn.active rule"
|
||||
)
|
||||
assert ".font-size-pick-btn.active" in STYLE_CSS, (
|
||||
"style.css must have a .font-size-pick-btn.active rule"
|
||||
)
|
||||
assert ".skin-pick-btn.active" in STYLE_CSS, (
|
||||
"style.css must have a .skin-pick-btn.active rule"
|
||||
)
|
||||
# The active rule must use !important to beat the base !important rule
|
||||
idx = STYLE_CSS.find(".theme-pick-btn.active")
|
||||
rule = STYLE_CSS[idx:idx + 200]
|
||||
assert "!important" in rule, (
|
||||
".theme-pick-btn.active must use !important to override "
|
||||
"the base border-color:var(--border)!important rule"
|
||||
)
|
||||
|
||||
def test_active_rule_uses_accent_color(self):
|
||||
"""The .active rule must apply the accent color to make selection visible."""
|
||||
idx = STYLE_CSS.find(".theme-pick-btn.active")
|
||||
rule = STYLE_CSS[idx:idx + 200]
|
||||
assert "var(--accent)" in rule, (
|
||||
".theme-pick-btn.active must set border-color to var(--accent)"
|
||||
)
|
||||
@@ -33,8 +33,21 @@ class TestSessionPersistence(unittest.TestCase):
|
||||
sessions_file.unlink()
|
||||
|
||||
def _simulate_restart(self) -> None:
|
||||
"""Reload auth module to simulate a fresh process start."""
|
||||
importlib.reload(auth)
|
||||
"""Reload auth module to simulate a fresh process start.
|
||||
|
||||
api.auth does `from api.config import STATE_DIR` at module level, so
|
||||
`_SESSIONS_FILE` is computed from api.config.STATE_DIR at reload time.
|
||||
We temporarily override api.config.STATE_DIR so the reload uses the
|
||||
test state dir without reloading api.config itself (which would
|
||||
invalidate imported references like STREAM_PARTIAL_TEXT in other tests).
|
||||
"""
|
||||
import api.config as _config
|
||||
_saved = _config.STATE_DIR
|
||||
_config.STATE_DIR = _TEST_STATE
|
||||
try:
|
||||
importlib.reload(auth)
|
||||
finally:
|
||||
_config.STATE_DIR = _saved
|
||||
|
||||
def test_session_survives_restart(self) -> None:
|
||||
"""A session created before restart should still verify after reload."""
|
||||
|
||||
@@ -85,7 +85,7 @@ def test_chinese_locale_includes_representative_translations():
|
||||
"approval_heading: '需要审批'",
|
||||
"tab_tasks: '任务'",
|
||||
"tab_profiles: '配置'",
|
||||
"session_time_just_now: '刚刚'",
|
||||
"session_time_bucket_today: '今天'",
|
||||
"onboarding_title: '欢迎使用 Hermes Web UI'",
|
||||
"onboarding_complete: '引导完成'",
|
||||
]
|
||||
|
||||
@@ -85,7 +85,7 @@ def test_russian_locale_includes_representative_translations():
|
||||
"approval_heading: '\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u0435'",
|
||||
"tab_tasks: '\u0417\u0430\u0434\u0430\u0447\u0438'",
|
||||
"tab_profiles: '\u041f\u0440\u043e\u0444\u0438\u043b\u0438'",
|
||||
"session_time_just_now: '\u0442\u043e\u043b\u044c\u043a\u043e \u0447\u0442\u043e'",
|
||||
"session_time_bucket_today: '\u0421\u0435\u0433\u043e\u0434\u043d\u044f'",
|
||||
"onboarding_title: '\u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c \u0432 Hermes Web UI'",
|
||||
"onboarding_complete: '\u041f\u0435\u0440\u0432\u0438\u0447\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0430'",
|
||||
"profile_default_label: '\u0028\u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e\u0029'",
|
||||
|
||||
@@ -42,11 +42,10 @@ def _run_session_time_case(script_body: str) -> dict:
|
||||
process.env.TZ = 'UTC';
|
||||
const translations = {{
|
||||
session_time_unknown: 'Unknown',
|
||||
session_time_just_now: 'just now',
|
||||
session_time_minutes_ago: (n) => `${{n}} minute${{n === 1 ? '' : 's'}} ago`,
|
||||
session_time_hours_ago: (n) => `${{n}} hour${{n === 1 ? '' : 's'}} ago`,
|
||||
session_time_days_ago: (n) => `${{n}} day${{n === 1 ? '' : 's'}} ago`,
|
||||
session_time_last_week: 'last week',
|
||||
session_time_minutes_ago: (n) => `${{n}}m`,
|
||||
session_time_hours_ago: (n) => `${{n}}h`,
|
||||
session_time_days_ago: (n) => `${{n}}d`,
|
||||
session_time_last_week: '1w',
|
||||
session_time_bucket_today: 'Today',
|
||||
session_time_bucket_yesterday: 'Yesterday',
|
||||
session_time_bucket_this_week: 'This week',
|
||||
@@ -117,7 +116,7 @@ def test_relative_time_uses_calendar_boundaries_and_year_for_old_sessions():
|
||||
}));
|
||||
"""
|
||||
)
|
||||
assert result["relative"] == "2 days ago"
|
||||
assert result["relative"] == "2d"
|
||||
assert result["bucket"] == "This week"
|
||||
assert "2024" in result["oldDate"]
|
||||
|
||||
@@ -134,7 +133,7 @@ def test_relative_time_today_bucket():
|
||||
}));
|
||||
"""
|
||||
)
|
||||
assert result["relative"] == "2 hours ago"
|
||||
assert result["relative"] == "2h"
|
||||
assert result["bucket"] == "Today"
|
||||
|
||||
|
||||
@@ -151,15 +150,14 @@ def test_relative_time_handles_just_now_and_dst_safe_yesterday_boundary():
|
||||
}));
|
||||
"""
|
||||
)
|
||||
assert result["justNow"] == "just now"
|
||||
assert result["yesterday"] == "Yesterday"
|
||||
assert result["justNow"] == "1m"
|
||||
assert result["yesterday"] == "1d"
|
||||
assert result["yesterdayBucket"] == "Yesterday"
|
||||
|
||||
|
||||
def test_relative_time_strings_are_localized_in_english_and_spanish_bundles():
|
||||
for key in (
|
||||
"session_time_unknown",
|
||||
"session_time_just_now",
|
||||
"session_time_minutes_ago",
|
||||
"session_time_hours_ago",
|
||||
"session_time_days_ago",
|
||||
|
||||
Reference in new issue
Block a user