Merge commit 'refs/pull/6083/head' of github.com:nesquena/hermes-webui into stage/6083-brick

This commit is contained in:
nesquena-hermes committed 2026-07-15 18:23:03 +00:00
commit 078a64cabe
5 files changed
+82 -15

No files matched your search

+11 -5
View File
@@ -4056,6 +4056,14 @@ def _session_is_evictable(s) -> bool:
on-disk ``message_count`` being at least the in-memory message count.
A metadata-only stub is inherently backed by disk, so it is evictable.
The persistence requirement holds even for a session with ZERO messages.
``new_session()`` deliberately does not touch disk until the first message
(#1171), so between "New Conversation" and the first send, this cache is the
session's ONLY copy. Evicting it does not discard a recreatable empty shell:
``get_session()`` has no recreate path and raises ``KeyError``, so the very
next ``/api/session/draft`` or ``/api/chat/start`` 404s and the session can
never be started. Sessions the user is still composing must stay resident.
Anything we cannot positively prove is safe stays resident. Using slightly
more RAM for a session we are unsure about is strictly better than evicting
an active or unsaved session (task safety invariant: a half-done memory fix
@@ -4077,14 +4085,12 @@ def _session_is_evictable(s) -> bool:
if getattr(s, '_loaded_metadata_only', False):
return True
in_memory_count = len(getattr(s, 'messages', None) or [])
if in_memory_count == 0:
# A zero-message session has nothing to lose. If it was never persisted
# (brand new, no sidecar) dropping it only discards an empty shell; the
# next access recreates it. If it is persisted, it is trivially clean.
return True
disk_count = _persisted_message_count(sid)
if disk_count is None:
return False # cannot prove it is on disk → keep it resident
if in_memory_count == 0:
# Persisted and empty → trivially clean, nothing to lose.
return True
return disk_count >= in_memory_count
+17 -7
View File
@@ -200,7 +200,7 @@
</button>
</div>
</div>
<div class="session-search sidebar-search"><div class="session-search-field"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="sessionSearch" placeholder="Filter conversations..." data-i18n-placeholder="filter_conversations" oninput="filterSessions()" autocomplete="off"><button type="button" id="sessionSearchClear" class="session-search-clear has-tooltip has-tooltip--bottom-right" aria-label="Clear conversation filter" data-tooltip="Clear conversation filter" onclick="clearSessionSearch()" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div></div>
<div class="session-search sidebar-search"><div class="session-search-field"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="sessionSearch" type="search" placeholder="Filter conversations..." data-i18n-placeholder="filter_conversations" oninput="filterSessions()" autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore="true" data-form-type="other"><button type="button" id="sessionSearchClear" class="session-search-clear has-tooltip has-tooltip--bottom-right" aria-label="Clear conversation filter" data-tooltip="Clear conversation filter" onclick="clearSessionSearch()" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div></div>
<div class="session-list" id="sessionList"></div>
</div>
<!-- Tasks (cron) panel -->
@@ -225,7 +225,7 @@
</div>
</div>
<div class="kanban-filter-stack">
<div class="sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="kanbanSearch" placeholder="Search tasks" data-i18n-placeholder="kanban_search_tasks" oninput="filterKanban()"></div>
<div class="sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="kanbanSearch" type="search" placeholder="Search tasks" data-i18n-placeholder="kanban_search_tasks" oninput="filterKanban()" autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore="true" data-form-type="other"></div>
<select id="kanbanAssigneeFilter" onchange="loadKanban(true)" aria-label="Assignee filter"></select>
<select id="kanbanTenantFilter" onchange="loadKanban(true)" aria-label="Tenant filter"></select>
<label class="kanban-check"><input id="kanbanIncludeArchived" type="checkbox" onchange="loadKanban(true)"> <span data-i18n="kanban_include_archived">Include archived</span></label>
@@ -253,7 +253,7 @@
<button class="panel-head-btn has-tooltip has-tooltip--bottom" onclick="openSkillCreate()" data-tooltip="New skill" data-i18n-title="new_skill" aria-label="New skill"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
</div>
</div>
<div class="skills-search sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="skillsSearch" placeholder="Search skills..." data-i18n-placeholder="search_skills" oninput="filterSkills()"></div>
<div class="skills-search sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="skillsSearch" type="search" placeholder="Search skills..." data-i18n-placeholder="search_skills" oninput="filterSkills()" autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore="true" data-form-type="other"></div>
<div class="skills-list" id="skillsList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Memory panel -->
@@ -353,8 +353,9 @@
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
<input id="settingsSearch" data-i18n-placeholder="settings_search_placeholder"
placeholder="Search settings…" oninput="filterSettings(this.value)" autocomplete="off">
<input id="settingsSearch" type="search" data-i18n-placeholder="settings_search_placeholder"
placeholder="Search settings…" oninput="filterSettings(this.value)" autocomplete="off"
data-1p-ignore data-lpignore="true" data-bwignore="true" data-form-type="other">
<div id="settingsSearchResults" class="settings-search-results" style="display:none"></div>
</div>
<div class="settings-menu-items">
@@ -1541,11 +1542,20 @@
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_password">Enter a new password to set or change it. Leave blank to keep current setting.</div>
<div id="settingsAuthStatus" style="margin-bottom:8px;display:none"></div>
<input type="password" id="settingsPassword" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
<!-- These password inputs need an explicit <form> owner. Without one they are
"unowned" fields, so Chromium groups every field in the document into a
single synthetic password form and picks the first text input in the DOM
(the sidebar conversation filter) as the form's username field — which it
then autofills with the saved account name. display:contents keeps the
layout byte-identical; onsubmit is neutralised because the panel saves via
JS, not a form submit. -->
<form id="settingsPasswordForm" style="display:contents" autocomplete="off" onsubmit="return false">
<input type="password" id="settingsPassword" autocomplete="new-password" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
<div id="settingsCurrentPasswordBlock" style="display:none;margin-top:6px">
<label for="settingsCurrentPassword" style="font-size:11px;display:block;margin-bottom:4px" data-i18n="current_password_label">Current Password</label>
<input type="password" id="settingsCurrentPassword" placeholder="Enter current password…" data-i18n-placeholder="current_password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
<input type="password" id="settingsCurrentPassword" autocomplete="current-password" placeholder="Enter current password…" data-i18n-placeholder="current_password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
</form>
<div id="settingsPasswordEnvLock" data-i18n="password_env_var_locked" style="display:none;margin-top:6px;padding:8px 10px;font-size:11px;color:var(--muted);background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;line-height:1.45">The HERMES_WEBUI_PASSWORD environment variable is currently set and takes precedence. Unset it and restart the server to manage the password from here.</div>
</div>
<div id="settingsAuthDisabledWarning" style="display:none;margin-top:8px;padding:10px 12px;border-radius:8px;border:1px solid rgba(220,80,80,.35);background:rgba(220,80,80,.08)">
+10 -2
View File
@@ -197,7 +197,11 @@ function _renderOnboardingApiKeyField(){
const labelKey=keyOptional?'onboarding_api_key_label_optional':'onboarding_api_key_label';
const placeholderKey=keyOptional?'onboarding_api_key_placeholder_optional':'onboarding_api_key_placeholder';
const helpHtml=keyOptional?`<p class="onboarding-copy onboarding-api-key-help">${esc(t('onboarding_api_key_help_keyless')||'')}</p>`:'';
return `<label class="onboarding-field" id="onboardingApiKeyField"><span>${t(labelKey)}</span><input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t(placeholderKey)}" oninput="ONBOARDING.form.apiKey=this.value" onblur="_runOnboardingProbe()"></label>${helpHtml}`;
// The <form> owner is load-bearing, not decoration: an unowned password input
// makes Chromium treat the whole document as one synthetic password form and
// autofill the saved account name into the first text input it finds (the
// sidebar conversation filter). display:contents keeps the layout unchanged.
return `<form style="display:contents" autocomplete="off" onsubmit="return false"><label class="onboarding-field" id="onboardingApiKeyField"><span>${t(labelKey)}</span><input id="onboardingApiKeyInput" type="password" autocomplete="off" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t(placeholderKey)}" oninput="ONBOARDING.form.apiKey=this.value" onblur="_runOnboardingProbe()"></label></form>${helpHtml}`;
}
function _getOnboardingSelectedModel(){
@@ -392,11 +396,15 @@ function _renderOnboardingBody(){
if(key==='password'){
_setOnboardingNotice(settings.password_enabled?t('onboarding_notice_password_enabled'):t('onboarding_notice_password_recommended'), settings.password_enabled?'success':'info');
// See the api-key field above: the <form> owner keeps this password input from
// turning the whole document into one synthetic credential form.
body.innerHTML=`
<form style="display:contents" autocomplete="off" onsubmit="return false">
<label class="onboarding-field">
<span>${t('onboarding_password_label')}</span>
<input id="onboardingPasswordInput" type="password" value="${esc(ONBOARDING.form.password||'')}" placeholder="${t('onboarding_password_placeholder')}" oninput="ONBOARDING.form.password=this.value">
<input id="onboardingPasswordInput" type="password" autocomplete="new-password" value="${esc(ONBOARDING.form.password||'')}" placeholder="${t('onboarding_password_placeholder')}" oninput="ONBOARDING.form.password=this.value">
</label>
</form>
<p class="onboarding-copy">${t('onboarding_password_help')}</p>`;
return;
}
+6 -1
View File
@@ -1565,7 +1565,12 @@
.session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;overscroll-behavior-y:contain;touch-action:pan-y;overflow-anchor:none;}
.sidebar-search{position:relative;padding:8px 12px;flex-shrink:0;}
.session-search-field{position:relative;display:flex;align-items:center;width:100%;}
.sidebar-search input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 10px 7px 32px;font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s,background .15s;box-sizing:border-box;}
.sidebar-search input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 10px 7px 32px;font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s,background .15s;box-sizing:border-box;-webkit-appearance:none;appearance:none;}
/* The search inputs are type="search" so password managers don't mistake them
for username fields. Suppress WebKit's native clear affordance: these fields
ship their own clear button (e.g. #sessionSearchClear). */
.sidebar-search input::-webkit-search-cancel-button,
.sidebar-search input::-webkit-search-decoration{-webkit-appearance:none;appearance:none;display:none;}
.session-search input{padding-right:34px;}
.sidebar-search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);}
.sidebar-search input::placeholder{color:var(--muted);opacity:.7;}
@@ -333,3 +333,41 @@ def test_eviction_skips_active_but_still_bounds_clean_entries(isolated_session_e
# It may briefly sit slightly above cap because actives are non-evictable,
# but it must NOT grow unbounded with the 40 churned sessions.
assert len(SESSIONS) <= _cfg.SESSIONS_MAX + len(actives)
def test_unsaved_new_session_survives_churn_and_stays_startable(isolated_session_env):
"""A brand-new, never-persisted session must not be evicted.
``new_session()`` keeps a session in RAM only until its first message
(#1171), so the cache is its ONLY copy. The original #4765 predicate treated
any zero-message session as evictable, reasoning that an empty shell "is
recreated on next access" — but ``get_session()`` has no recreate path and
raises ``KeyError``, so ``/api/session/draft`` and ``/api/chat/start`` both
404 and the session can never be started.
Real-world trigger: a browser password manager autofilled the sidebar
conversation filter, whose content search pulls every hit through
``get_session()``. That churn blew past the cap and dropped the session the
user was composing in.
"""
from api import config as _cfg
from api.config import SESSIONS
from api.models import get_session, new_session
_cfg.SESSIONS_MAX = 5
composing = new_session()
sid = composing.session_id
assert not (_cfg.SESSION_DIR / f"{sid}.json").exists(), (
"precondition: new_session() must not persist before the first message"
)
# Content-search-style churn: far more persisted sessions than the cap.
for i in range(40):
_insert(_make_persisted_session(i))
assert sid in SESSIONS, "unsaved new session was evicted — its only copy is gone"
# The chokepoint both failing routes go through.
assert get_session(sid, metadata_only=True) is not None
assert get_session(sid).session_id == sid